plot-io 0.12.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/axis.py ADDED
@@ -0,0 +1,1347 @@
1
+ """Axes: settings (:class:`Axis`) and their state while a figure is drawn
2
+ (:class:`AxisState`: scale, limits, ticks, mapping to the page)."""
3
+
4
+ import inspect
5
+ import math
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from vec_io.arrays import doubles
10
+
11
+ from ._methods import AxisMixin
12
+ from . import kernels
13
+ from .transform import affine, apply
14
+ from .limits import check_limits, needs_quantiles, resolve_limits, symmetric
15
+ from .scales import AXIS_SCALES, Scale, get_scale
16
+ from .color.values import color
17
+ from . import groups
18
+ from .settings import (Setting, Settings, boolean, either, literal, number, one_of, pair,
19
+ table, text, time_zone)
20
+ from .validators import angle, length
21
+ from .text import Font, font_of, lines_box, wrap
22
+ from .numbers import NumberStyle
23
+ from .formatters import CategoryFormatter, NumberFormatter, TimeFormatter
24
+ from .ticks import (MAX_TICKS, Every, Tick, minor_linear, numeric_ticks, power_labels,
25
+ step_values)
26
+ from .times import axis_value, calendar_ticks, from_seconds
27
+
28
+ __all__ = ["Axis", "AxisState", "Mapper"]
29
+
30
+
31
+ def _shrunk(q: list[tuple[float, float]], d: float) -> list[tuple[float, float]]:
32
+ """A rectangle's corners (in order around it) moved ``d`` px inward along its sides
33
+ (outward for a negative ``d``)."""
34
+ out = []
35
+ for k in range(4):
36
+ p, a, b = q[k], q[(k + 1) % 4], q[(k - 1) % 4]
37
+ move = [0.0, 0.0]
38
+ for o in (a, b):
39
+ ux, uy = o[0] - p[0], o[1] - p[1]
40
+ n = math.hypot(ux, uy)
41
+ if n > 2 * d and n > 0:
42
+ move[0] += d * ux / n
43
+ move[1] += d * uy / n
44
+ out.append((p[0] + move[0], p[1] + move[1]))
45
+ return out
46
+
47
+
48
+ def _values(v: Any, what: str) -> list[Any] | None:
49
+ """The items of a non-string iterable (``None`` for anything else), each a number, a
50
+ date or text."""
51
+ if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__") or (
52
+ getattr(v, "shape", None) == ()):
53
+ return None
54
+ out: list[Any] = []
55
+ for x in v:
56
+ if isinstance(x, str):
57
+ out.append(x)
58
+ continue
59
+ try:
60
+ out.append(axis_value(x))
61
+ except ValueError:
62
+ raise ValueError(f"{what}: expected numbers, dates or text, got {x!r}") from None
63
+ return out
64
+
65
+
66
+ def _ticks_value(v: Any) -> Any:
67
+ if v is False or isinstance(v, Every) or callable(v):
68
+ return v
69
+ out = _values(v, "ticks")
70
+ if out is None:
71
+ raise ValueError(f"expected False, a list of values, plt.every(step) or a function "
72
+ f"f(lo, hi), got {v!r}")
73
+ return out
74
+
75
+
76
+ def _minor_value(v: Any) -> Any:
77
+ if isinstance(v, bool) or isinstance(v, Every) or callable(v):
78
+ return v
79
+ out = _values(v, "minor_ticks")
80
+ if out is None:
81
+ raise ValueError(f"expected True, False, a list of values, plt.every(step) or a "
82
+ f"function f(lo, hi), got {v!r}")
83
+ return out
84
+
85
+
86
+ def _check_count(labels: list[Any], ticks: list[Any], what: str) -> None:
87
+ if len(labels) != len(ticks):
88
+ raise ValueError(f"tick_labels: {len(labels)} labels for {len(ticks)} {what}")
89
+
90
+
91
+ _rotation = either(literal("auto"), angle(-180, 180))
92
+
93
+
94
+ def _region(v: Any) -> Any:
95
+ from .genome.regions import parse_regions
96
+ return parse_regions(v)
97
+
98
+
99
+ def _chr_sizes(v: Any) -> Any:
100
+ """A mapping of chromosome names to sizes (bp)."""
101
+ from collections.abc import Mapping
102
+ if not isinstance(v, Mapping):
103
+ raise ValueError(f"expected a mapping of chromosome names to sizes, got {v!r}")
104
+ from .genome.regions import parse_bp
105
+ return {str(k): parse_bp(n, f"the size of {k!r}", positive=True) for k, n in v.items()}
106
+
107
+
108
+ def _scale_bar(v: Any) -> Any:
109
+ from .genome.scale import check_scale_bar
110
+ return check_scale_bar(v)
111
+
112
+
113
+ def _tick_format(v: Any) -> Any:
114
+ from .genome.scale import check_tick_format
115
+ return check_tick_format(v)
116
+
117
+
118
+ def _origin(v: Any) -> Any:
119
+ from .transform import check_origin
120
+ return check_origin(v)
121
+
122
+
123
+
124
+ class Axis(AxisMixin, Settings):
125
+ """An axis of a plot (``plot.x_axis``, ``plot.y_axis``); color and size axes
126
+ (:mod:`plot_io.mapped`) are axes too.
127
+
128
+ Scales (``scale``): ``linear``; ``log``, ``log10``, ``log2``, ``ln`` (v > 0; they place
129
+ data alike and differ in their ticks); ``sqrt`` (v ≥ 0); ``exp`` (v < 709); ``symlog``
130
+ (sign(v)·log₁₀(1 + abs(v)/``linthresh``)); ``logit`` (0 < p < 1); ``time`` (seconds since
131
+ 1970-01-01 UTC); ``category``; ``genome`` (bp less the axis' ``origin``, limits the
132
+ axis' ``region`` without padding, tick labels in ``unit``). Values outside the domain
133
+ are missing, except the ends of shapes (bar baselines, fill edges, stems, error-bar
134
+ ends), drawn at the axes' edge. Mixing category and numeric series on an axis raises.
135
+
136
+ Dates (``datetime``, ``date``, ``datetime64``, pandas timestamps) make a ``time`` axis:
137
+ naive values read as UTC, aware ones shown in their zone (``tz``). Ticks follow the
138
+ calendar (years, months, days, hours, minutes, seconds), labels are concise (``2026``,
139
+ ``Mar``, ``Mar 14``, ``14:30``) with the unit common to every tick written at the end of
140
+ the axis; ``tick_format`` takes a ``strftime`` pattern or a function of a ``datetime``.
141
+
142
+ Limits: ``"auto"`` is the union of the series' extents plus ``padding`` (per side, in
143
+ position space); ``"lo/hi"`` takes ``min``, ``max``, a number, an ISO date or ``q:F`` (a
144
+ quantile of the axis' values) each side (``"0/max"``, ``"q:0.01/q:0.99"``), or
145
+ ``(lo, hi)`` with ``None`` automatic. A single value widens by ±0.5 (a time: by one unit
146
+ of its data). Limits high to low, or ``invert=True``, reverse the axis.
147
+ ``include_zero="auto"`` reaches 0 where a series asks (both axes of scatter plots, the
148
+ value axis of lines, areas and boxes), before padding. ``snap`` moves automatic limits
149
+ onto major ticks (linear and log axes): the default ``("4%", "8%")`` extends a limit to
150
+ the next tick within 8% of the axis or pulls it back to a tick it passed by at most 4%;
151
+ ``True`` always extends, ``False`` keeps the padded limits.
152
+
153
+ Ticks: linear steps 1, 2, 2.5 (not with a ``precision``) or 5 × 10ᵏ, at most
154
+ ``max_ticks`` (on x the step grows until labels do not overlap); log decades labelled
155
+ 10ᵏ (below three decades 1, 2 and 5 × 10ᵏ as numbers), minor ticks 2–9; ``log2`` powers of two; ``ln`` powers of e;
156
+ ``symlog`` 0, ±``linthresh``, then decades; ``logit`` 0.5, 0.1/0.9, 0.01/0.99, …;
157
+ categories one each, turned 45° when they overlap (``tick_rotation="auto"``), else every
158
+ k-th. Tick labels still closer than ``tick_label_gap`` (chosen or given, either axis)
159
+ are labelled every k-th, then turned 45° (``"auto"``, x), wrapped at spaces, made smaller
160
+ down to ``label_min_size``, and else one is drawn with a warning; a y label at the corner
161
+ over an x label is left out; a dendrogram's leaves (and the axes sharing them) all labelled, turned 90°, then
162
+ smaller down to ``label_min_size``, and every k-th only below it. Labels share the fewest decimals that tell them apart; beyond 10^``sci_limits``
163
+ they are mantissas and ×10ᵏ is written at the end of the axis. ``ticks`` takes values
164
+ (numbers, dates, ISO text, category names), :func:`every` (``every("3 months")``,
165
+ ``every(1)``: each decade) or a function of the limits; ``ticks=False`` hides them.
166
+ ``grid`` and ``minor_grid`` draw at the major and minor ticks, ``zero_line`` at 0, under
167
+ the series. ``side`` moves ticks, labels and axis line; ``mirror`` repeats the ticks on
168
+ the other side; ``spine_offset`` moves the axis line away; ``spine_trim`` draws it from
169
+ the first to the last major tick. ``color`` sets the default of ``line_color``,
170
+ ``tick_color``, ``tick_label_color`` and ``label_color``.
171
+ """
172
+
173
+ @property
174
+ def nominal_scale(self) -> str:
175
+ """``scale``, or ``"linear"`` while unset (before it is detected from the data)."""
176
+ s = self.resolve("scale")
177
+ return "linear" if s is None else s
178
+
179
+ SETTINGS = table(
180
+ label=Setting(text, doc="Axis title: a string or rich content; `\\n` starts a line "
181
+ "in a row band."),
182
+ label_position=Setting(one_of("axis", "band", "left", "inside"),
183
+ doc="Where the label of a plot's y axis goes: `axis` (beside "
184
+ "the tick labels); in a band left of the plot, lined up "
185
+ "with the labels of the other plots of its grid column "
186
+ "(row labels): `band` (rotated) or `left` (horizontal, "
187
+ "right-aligned); `inside` (top left inside the axes)."),
188
+ **groups.TextStyle.embed("label_", docs={
189
+ "font_size": "Font size of the label; default: the base font size.",
190
+ "font_weight": "Axis title weight: `normal`, `bold` or 1–1000.",
191
+ "font_style": "Axis title style: `normal`, `italic`, `oblique` or "
192
+ "`\"oblique 10deg\"`.",
193
+ "color": "Axis title color."}),
194
+ scale=Setting(one_of(*AXIS_SCALES),
195
+ doc="`linear`, `log` (base 10), `ln` (base e), `log2`, `log10`, `sqrt`, "
196
+ "`exp`, `symlog`, `logit`, `time`, `category` or `genome`; "
197
+ "default: `category` for categorical series, `time` for dates, `genome` for genome "
198
+ "series, else `linear`."),
199
+ region=Setting(_region,
200
+ doc="Genome scale: the region drawn, its limits: "
201
+ "`\"chr2:143,120,001-143,780,000\"` (1-based, inclusive), "
202
+ "`(chr, start, end)` (0-based, half-open) or a mapping; genome "
203
+ "series read it. A chromosome name (`\"chr1\"`): the whole "
204
+ "chromosome (`chr_sizes`). A list of regions or names: drawn "
205
+ "side by side (`region_widths`, `region_gap`), each series "
206
+ "reading each."),
207
+ chr_sizes=Setting(_chr_sizes,
208
+ doc="Genome scale: chromosome sizes (a mapping, "
209
+ "`gwseq_io.get_chr_sizes(\"mm10\")`) giving the end of a whole "
210
+ "chromosome (`region=\"chr1\"`); `None`: the largest size the "
211
+ "figure's readers give."),
212
+ region_widths=Setting(one_of("span", "equal"),
213
+ doc="Genome scale over several regions: the width of each, "
214
+ "its share of the bp (`span`) or `equal`."),
215
+ region_gap=Setting(length(lo=0, percent="em"),
216
+ doc="Genome scale over several regions: the gap between them."),
217
+ unit=Setting(one_of("auto", "bp", "kb", "Mb", "Gb", aliases={"mb": "Mb", "gb": "Gb"}),
218
+ doc="Genome scale: unit of the tick labels; `auto`: with ticks about a "
219
+ "fifth of the span apart, the largest unit in which they are at "
220
+ "least 1 apart and at most 1000, else the largest in which they are "
221
+ "at most 1000 with at most 3 decimals, else bp."),
222
+ unit_in=Setting(one_of("label", "ticks"),
223
+ doc="Genome scale: where the unit is written, in the axis label "
224
+ "(`chr2 (Mb)`) or after each tick label (`500kb`)."),
225
+ scale_bar=Setting(_scale_bar,
226
+ doc="Genome scale (x): a scale bar at the axis' right end (at "
227
+ "each region's) with its length written left of it, in "
228
+ "place of the ticks and tick labels; the label is then the "
229
+ "chromosome alone. `True`: the largest 1, 2 or 5 × 10ᵏ bp at "
230
+ "most a fifth of the axis; a length (`\"10kb\"`); `False`: "
231
+ "ticks."),
232
+ origin=Setting(_origin,
233
+ doc="The value drawn at 0 for the axis' series (a series' own "
234
+ "`x_origin` wins): a number, or `\"center\"`, `\"start\"`, "
235
+ "`\"end\"` of the region (genome scale), else of each series' "
236
+ "range. Values are drawn at `(v − origin) × x_scale`; limits, "
237
+ "ticks and decorations are in the drawn units."),
238
+ linthresh=Setting(number(positive=True),
239
+ doc="Half-width of the linear region of a `symlog` axis."),
240
+ tz=Setting(time_zone,
241
+ doc="Time zone of a time axis (a `tzinfo`, or a zone name such as "
242
+ "`\"Europe/Paris\"`); default: the zone of the first aware date, else "
243
+ "naive (UTC)."),
244
+ limits=Setting(check_limits,
245
+ doc="`\"auto\"`, `\"lo/hi\"` or `[lo, hi]` (a tuple too); each side a "
246
+ "number, `\"min\"`, `\"max\"`, `\"q:F\"` (a quantile), a date, or "
247
+ "`\"auto\"`/`None`: `\"0/max\"` is `[0, \"max\"]`; in a pair on a "
248
+ "category axis, a category by name (shown whole); `\"-m/m\"`: "
249
+ "symmetric about the `center` (or 0), `m` the farthest value."),
250
+ padding=Setting(either(literal("auto"), length(lo=0, percent="data_range")),
251
+ doc="Space added to automatic limits, as a fraction of the range; "
252
+ "`\"auto\"`: `auto_padding`, or 0 on axes of series drawn to "
253
+ "exact limits (box value axes, heatmaps, …) and in plots "
254
+ "without axes."),
255
+ auto_padding=Setting(length(lo=0, percent="data_range"),
256
+ doc="The fraction `padding=\"auto\"` adds on each side."),
257
+ sticky=Setting(boolean,
258
+ doc="No padding past the baseline of bars and histograms when an "
259
+ "automatic limit is on it (matplotlib's sticky edges)."),
260
+ invert=Setting(boolean, doc="Reverse the axis (limits given high to low, "
261
+ "`(2, 0)`, reverse it too)."),
262
+ ticks=Setting(_ticks_value,
263
+ doc="`False` (no ticks), the tick values: any iterable (a list, a "
264
+ "range, an array) of numbers, dates, `datetime64` or ISO 8601 "
265
+ "text, on a category axis categories by name or index; "
266
+ "`plt.every(step)` (`plt.every(\"2 months\")`; decades on log "
267
+ "axes); or a function `f(lo, hi)` of the limits (datetimes on a "
268
+ "time axis) returning the values."),
269
+ tick_labels=Setting(lambda v: [text(x) for x in v],
270
+ doc="Labels of `ticks` (of the categories without `ticks`), as "
271
+ "many as they; markup is read, `\\n` starts a line."),
272
+ minor_ticks=Setting(_minor_value,
273
+ doc="`True`, `False`, or the minor tick values, "
274
+ "`plt.every(step)` or a function (as `ticks`); default: on "
275
+ "for base-10 logs."),
276
+ tick_format=Setting(_tick_format,
277
+ doc="Number format: `auto`, `percent`, `sci`, `eng`, `si`, `int`, "
278
+ "a format string (`\"{:.2f}\"`, printf `\"%.2f\"` or a "
279
+ "bare spec `\".2f\"`; strftime `%` codes on a time axis) or "
280
+ "a function; on a genome axis also `chr:pos` "
281
+ "(`chr2:143,250,000`)."),
282
+ tick_rotation=Setting(_rotation,
283
+ doc="Label rotation in degrees, or `\"auto\"` (45° when "
284
+ "x labels overlap; 90° for a dendrogram's leaves)."),
285
+ label_min_size=Setting(length(positive=True, font=True),
286
+ doc="Smallest font size tick labels that overlap shrink to "
287
+ "(a dendrogram's leaves: before some are left out)."),
288
+ max_ticks=Setting(either(literal("auto"), number(lo=1, hi=MAX_TICKS, integer=True)),
289
+ doc="Most major ticks; `\"auto\"`: one per `tick_spacing` of "
290
+ "axis length, at least 2, at most `auto_max_ticks`."),
291
+ tick_spacing=Setting(pair(length(positive=True, percent="em", em="tick_label_font_size")),
292
+ doc="Space per automatic tick, in tick label font sizes, on "
293
+ "horizontal and vertical axes."),
294
+ time_tick_spacing=Setting(pair(length(positive=True, percent="em",
295
+ em="tick_label_font_size")),
296
+ doc="`tick_spacing` of time axes."),
297
+ tick_label_gap=Setting(length(lo=0, percent="em", em="tick_label_font_size"),
298
+ doc="Smallest gap between neighbouring tick labels side by "
299
+ "side: fewer numeric ticks, or category labels rotated "
300
+ "and thinned out, below it."),
301
+ category_label_spacing=Setting(pair(length(positive=True, percent="em",
302
+ em="tick_label_font_size")),
303
+ doc="Distance between category labels: rotated "
304
+ "(horizontal axes) and stacked (vertical "
305
+ "axes); labels closer than it are thinned out."),
306
+ auto_max_ticks=Setting(pair(number(lo=2, hi=MAX_TICKS, integer=True)),
307
+ doc="Most automatic ticks on numeric and time axes."),
308
+ sci_limits=Setting(pair(number(integer=True)),
309
+ doc="Exponents beyond which labels become mantissas × 10ᵏ."),
310
+ tick_length=Setting(length(lo=0, percent="across"), doc="Tick length."),
311
+ minor_tick_length=Setting(length(lo=0, percent="tick_length"),
312
+ doc="Minor tick length; default 0.6 × `tick_length`."),
313
+ tick_direction=Setting(one_of("out", "in", "both"), doc="`out`, `in` or `both`."),
314
+ **groups.Stroke.embed("tick_", only=("color", "line_width"),
315
+ docs={"color": "Tick color.", "line_width": "Tick line width."}),
316
+ **groups.TextStyle.embed("tick_label_", only=("font_size", "font_weight", "color"),
317
+ docs={"font_size": "Font size of tick labels.",
318
+ "font_weight": "Tick label weight: `normal`, `bold` or "
319
+ "1–1000.",
320
+ "color": "Tick label color."}),
321
+ tick_pad=Setting(length(lo=0, percent="across"), doc="Gap between ticks and labels."),
322
+ label_pad=Setting(length(lo=0, percent="across"), doc="Gap between tick labels and the label."),
323
+ color=Setting(color,
324
+ doc="Color of the axis line, ticks and labels (the default of "
325
+ "`line_color`, `tick_color`, `tick_label_color`, `label_color`)."),
326
+ **groups.Stroke.embed("line_", only=("color", "line_width"),
327
+ docs={"color": "Axis line color.", "line_width": "Axis line width."}),
328
+ side=Setting(one_of("left", "right", "bottom", "top"),
329
+ doc="Where the ticks, labels and axis line go: `bottom` or `top` (x "
330
+ "axis), `left` or `right` (y axis)."),
331
+ mirror=Setting(boolean, doc="Ticks also on the opposite side of the axes."),
332
+ spine_offset=Setting(length(lo=0, percent="across"),
333
+ doc="Distance the axis line (with its ticks and labels) is "
334
+ "moved away from the axes."),
335
+ spine_trim=Setting(boolean,
336
+ doc="Axis line from the first to the last major tick only."),
337
+ include_zero=Setting(either(boolean, literal("auto")),
338
+ doc="Extend automatic limits to 0 (linear and sqrt scales), "
339
+ "before padding and `snap`: `True`, or `\"auto\"` where a "
340
+ "series on the axis asks for it (Vega-Lite's rule: scatter "
341
+ "axes, the value axis of lines, areas and boxes)."),
342
+ zero_line=Setting(boolean, doc="A line across the plot at 0."),
343
+ **groups.Stroke.embed("zero_line_", only=("color", "line_width"),
344
+ docs={"color": "Zero line color; default `line_color`.",
345
+ "line_width": "Zero line width; default `line_width`."}),
346
+ snap=Setting(either(boolean, pair(length(lo=0, percent="data_range"))),
347
+ doc="Automatic limits on major ticks (linear and log scales): `True`, "
348
+ "out to the enclosing ticks; `(back, out)`, out to a tick within "
349
+ "`out` (`%` of the axis), or back to one within `back` that "
350
+ "is still outside the data; `False`: as padded."),
351
+ grid=Setting(boolean, doc="Major grid lines across the plot."),
352
+ **groups.Stroke.embed("grid_", only=("color", "line_width", "line_style"),
353
+ docs={"color": "Grid line color.", "line_width": "Grid line width.",
354
+ "line_style": "Grid line style or dash list."}),
355
+ minor_grid=Setting(boolean,
356
+ doc="Minor grid lines: at the minor ticks, or halfway between "
357
+ "major ticks on linear axes without minor ticks."),
358
+ **groups.Stroke.embed("minor_grid_", only=("color", "line_width", "line_style"),
359
+ checks={"line_width": length(lo=0, percent="grid_width")},
360
+ docs={"color": "Minor grid color; default `grid_color`.",
361
+ "line_width": "Minor grid width; default half `grid_width`.",
362
+ "line_style": "Minor grid style; default `grid_style`."}),
363
+ visible=Setting(boolean, doc="Draw the axis (ticks, labels)."),
364
+ **groups.Font.embed(),
365
+ text_color=Setting(color, inherited=True, doc="Text color."),
366
+ **groups.NumberFormat.embed(),
367
+ )
368
+
369
+ def __init__(self, owner: Any, role: str) -> None:
370
+ self._owner = owner
371
+ self.role = role
372
+ fig = getattr(owner, "figure", None)
373
+ #: the figure's axis of this role, whose own values this axis takes (see
374
+ #: :func:`plot_io.settings.cascade`)
375
+ self._figure_axis: Settings | None = getattr(fig, f"{role}_axis", None) \
376
+ if role in ("x", "y") else None
377
+ super().__init__()
378
+
379
+ def _parent(self) -> Settings | None:
380
+ return self._owner
381
+
382
+ def _shared(self) -> Settings | None:
383
+ """The figure's axis of this role (``figure.x_axis``), for a plot's x or y axis."""
384
+ return self._figure_axis
385
+
386
+ def _levels(self, name: str) -> tuple[tuple[str, ...], ...]:
387
+ """One level: ``XAxis``/``YAxis`` keys before the class's; a color axis takes
388
+ ``Colorbar`` keys of the axis settings a colorbar lacks (its guide's)."""
389
+ classes = tuple(c for level in super()._levels(name) for c in level)
390
+ if self.role == "c":
391
+ from .colorbar import Colorbar
392
+ return (classes,) if name in Colorbar.SETTINGS else (("Colorbar",) + classes,)
393
+ return ((f"{self.role.upper()}Axis",) + classes,)
394
+
395
+ def _level_key(self) -> Any:
396
+ return self.role
397
+
398
+ def _box(self, kind: str, name: str) -> float:
399
+ if kind in ("across", "along"):
400
+ vertical = self.role == "y"
401
+ if (kind == "across") == vertical:
402
+ kind = "axes_width"
403
+ else:
404
+ kind = "axes_height"
405
+ return super()._box(kind, name)
406
+
407
+ def __call__(self, label: Any = None, **settings: Any) -> "Axis":
408
+ """Set the axis title (``label``) and any axis settings; returns the axis."""
409
+ if label is not None:
410
+ settings["label"] = label
411
+ return self.set(**settings)
412
+
413
+ def __repr__(self) -> str:
414
+ return f"Axis({self.role})"
415
+
416
+
417
+ class Mapper:
418
+ """Data → page coordinate on one axis: ``page = a·f(v) + b``."""
419
+
420
+ __slots__ = ("function", "a", "b", "forward", "options", "flo", "fhi")
421
+
422
+ def __init__(self, scale: Scale | None, flo: float, fhi: float, p0: float, p1: float) -> None:
423
+ self.function = scale.function if scale is not None else None
424
+ self.options = scale.options if scale is not None else {}
425
+ self.forward = scale.f if scale is not None else (lambda v: v)
426
+ self.flo, self.fhi = flo, fhi
427
+ span = fhi - flo
428
+ self.a = (p1 - p0) / span if span else 0.0
429
+ self.b = p0 - self.a * flo
430
+
431
+ def __call__(self, v: float) -> float:
432
+ return self.a * self.forward(v) + self.b
433
+
434
+ def many(self, values: Any) -> Any:
435
+ return kernels.scaled(values, self.a, self.b, self.function, **self.options)
436
+
437
+ def ends(self, values: Any) -> Any:
438
+ """As :meth:`many`, for the ends of shapes (bar baselines, band edges, segment
439
+ ends): values below the scale's domain (0 on a log axis) at the edge of the low
440
+ limit, values above it at the edge of the high limit."""
441
+ if self.function is None:
442
+ return self.many(values)
443
+ return kernels.scaled(values, self.a, self.b, self.function, **self.options,
444
+ below=self.position(self.flo), above=self.position(self.fhi))
445
+
446
+ def position(self, f: float) -> float:
447
+ """Page coordinate of a value already through the scale function."""
448
+ return self.a * f + self.b
449
+
450
+ def affine(self, origin: float, scale: float, role: str) -> "Mapper":
451
+ """This mapper for a series whose values ``v`` are drawn at ``(v - origin) *
452
+ scale`` (``x_origin``…): on a linear or time axis, still ``a·v + b``."""
453
+ if self.function is not None:
454
+ raise ValueError(f"{role}_origin, {role}_scale and {role}_extent need a linear "
455
+ f"or time {role} axis")
456
+ m = Mapper(None, 0.0, 1.0, 0.0, 1.0)
457
+ m.a, m.b = self.a * scale, self.b - self.a * scale * origin
458
+ return m
459
+
460
+
461
+ def _about(scale: Scale, center: float, flo: float, fhi: float, lo_auto: bool, hi_auto: bool,
462
+ lo: float, hi: float) -> tuple[float, float, float, float]:
463
+ """Limits symmetric about ``center`` (on the scale): the automatic sides as far from it
464
+ as the farther side (both automatic) or the given side."""
465
+ fc = scale.f(center)
466
+ if not math.isfinite(fc):
467
+ return flo, fhi, lo, hi
468
+ a, b = sorted((flo, fhi))
469
+ d = max(fc - a, b - fc) if lo_auto and hi_auto else (b - fc if lo_auto else fc - a)
470
+ if not d > 0:
471
+ return flo, fhi, lo, hi
472
+ return fc - d, fc + d, scale.inverse(fc - d), scale.inverse(fc + d)
473
+
474
+
475
+ class AxisState:
476
+ """An axis while drawing: resolved scale, limits, categories and ticks."""
477
+
478
+ def __init__(self, axis: Axis) -> None:
479
+ self.axis = axis
480
+ self.scale: Scale | None = None # None: category
481
+ self.categories: list[Any] = []
482
+ self.lo = 0.0 # data limits (category: index range)
483
+ self.hi = 1.0
484
+ self.flo = 0.0 # limits after the scale function
485
+ self.fhi = 1.0
486
+ self.data_lo = math.nan # union of the series' extents
487
+ self.data_hi = math.nan
488
+ self.moved = False # a series' x_origin, … moved its values
489
+ self.ticks: list[Tick] = []
490
+ #: what is written at the end of the axis (×10ᵏ, ``+1×10⁹``, a date), or None
491
+ self.offset_label: Any = None
492
+ self.rotation = 0.0
493
+ self.length = 0.0
494
+ self.auto_invert = False # inverted by a series (heatmap rows)
495
+ self.reversed = False # limits given high to low: drawn inverted
496
+ self._auto = (False, False) # automatic low / high limit (for `snap`)
497
+ self._zero_pad = False # a series wants exact limits (heatmaps)
498
+ self._base = (0.0, 1.0, 0.0, 1.0) # lo, hi, flo, fhi before `snap`
499
+ self._inner = (0.0, 1.0) # flo, fhi before padding (`snap` keeps them)
500
+ self._pad_px = 0.0 # padding in px, added when the length is known
501
+ self._roomers: list[Any] = [] # series with labels past their values
502
+ self._pad_frac = 0.0 # padding as a fraction of the range
503
+ self._unpadded_px = self._base # limits before the px padding
504
+ self.shared: "AxisState | None" = None # the group's leader (shared axes)
505
+ self.hide_labels = False # an inner plot of a shared group
506
+ self.auto_label: Any = None # the column name of the series' data
507
+ self.tz: Any = None # time zone of a time axis
508
+ self.genome = False # a genome scale (linear, in bp)
509
+ self.whole = False # integer ticks only (grid rows and columns)
510
+ self.region: Any = None # its region (the axis', else a series')
511
+ self.origin = 0.0 # the position drawn at 0 on a genome axis
512
+ #: the regions of a genome axis over several, side by side
513
+ #: (:class:`plot_io.genome.scale.Segments`), else None
514
+ self.segments: Any = None
515
+ #: the label of each region of an axis over several: ``[(Block, Segment)]``
516
+ self.segment_titles: list[Any] = []
517
+ self.hide_title = False # an inner plot of a group: tick labels only
518
+ #: the axis label's lines as last measured (wrapped to the plot's box)
519
+ self.title_block: Any = None
520
+ #: the height of the ×10ᵏ offset's own row under the label row (0: beside the label)
521
+ self.offset_row = 0.0
522
+ #: the font size the tick labels were made at when even then only one fits
523
+ self.crowded: float | None = None
524
+ #: ``[(value, label)]`` the calls on the axis give as its ticks (named rows)
525
+ self.call_ticks: list[tuple[float, Any]] | None = None
526
+ #: whether those ticks' labels are thinned until none overlap
527
+ self.call_thin = False
528
+ #: whether the labels are a dendrogram's leaves (:meth:`_fit_leaves`)
529
+ self.call_leaves = False
530
+ #: the leaves' font size when shrunk to fit, else ``None``
531
+ self.leaf_size: float | None = None
532
+ #: px the axis leaves at its low and high ends of the axes (a y axis over the
533
+ #: value lane of a plot with interval lanes, :mod:`plot_io.genome.lanes`)
534
+ self.span = (0.0, 0.0)
535
+
536
+ def on_far_side(self) -> bool:
537
+ """Whether the axis is drawn on the top (x) or right (y) side of the axes."""
538
+ return self.axis.resolve("side") in ("top", "right")
539
+
540
+ @property
541
+ def is_category(self) -> bool:
542
+ return self.scale is None
543
+
544
+ def f(self, v: float) -> float:
545
+ return self.scale.f(v) if self.scale is not None else v
546
+
547
+ @property
548
+ def inverted(self) -> bool:
549
+ if self.shared is not None:
550
+ return self.shared.inverted
551
+ a = self.axis
552
+ if self.reversed:
553
+ return True
554
+ return a.resolve("invert") if a.is_set("invert") else self.auto_invert
555
+
556
+ def tick_out(self, marks: bool = False) -> tuple[float, float]:
557
+ """Tick extent outside and inside the axes: of all ticks (the room they take), or
558
+ with ``marks`` of the major tick marks."""
559
+ a = self.axis
560
+ n = a.resolve("tick_length")
561
+ if not marks and any(not t.major for t in self.ticks):
562
+ n = max(n, a.resolve("minor_tick_length"))
563
+ d = a.resolve("tick_direction")
564
+ return (n if d in ("out", "both") else 0.0), (n if d in ("in", "both") else 0.0)
565
+
566
+ def mapper(self, p0: float, p1: float) -> Mapper:
567
+ """Data → page from the axes' low end ``p0`` to their high end ``p1``, less the
568
+ axis' ``span`` at each end."""
569
+ lo, hi = self.span
570
+ if lo or hi:
571
+ d = 1.0 if p1 >= p0 else -1.0
572
+ p0, p1 = p0 + d * lo, p1 - d * hi
573
+ inv = self.inverted
574
+ if inv:
575
+ p0, p1 = p1, p0
576
+ return Mapper(self.scale, self.flo, self.fhi, p0, p1)
577
+
578
+ # -- limits ------------------------------------------------------------------
579
+
580
+ def resolve(self, series: Sequence[Any], padded: bool) -> None:
581
+ """Scale, categories and limits from the series drawn on this axis."""
582
+ role = self.axis.role
583
+ scale_name = self.axis.resolve("scale")
584
+ cats: list[Any] = []
585
+ categorical = False
586
+ for s in series:
587
+ c = s.categories(role)
588
+ if c is not None:
589
+ categorical = True
590
+ for k in c:
591
+ if k not in cats:
592
+ cats.append(k)
593
+ on_axis = [s for s in series if s.has_axis(role)]
594
+ self._roomers = [s for s in on_axis if hasattr(s, "label_room")]
595
+ self.auto_invert = any(s.inverts(role) for s in on_axis)
596
+ self.whole = bool(on_axis) and all(
597
+ getattr(s, "whole_ticks", lambda r: False)(role) for s in on_axis)
598
+ times = [s for s in on_axis if getattr(s, "time_role", lambda r: None)(role)]
599
+ genome = [s for s in on_axis if getattr(s, "scale_of", lambda r: None)(role) == "genome"]
600
+ if scale_name is None:
601
+ scale_name = "category" if categorical else ("time" if times else
602
+ "genome" if genome else "linear")
603
+ self.genome = scale_name == "genome"
604
+ if self.genome:
605
+ scale_name = "linear"
606
+ from .genome.series import axis_region
607
+ self.region = axis_region(self.axis) or next(
608
+ (r for r in (s.region_of(role) if hasattr(s, "region_of") else
609
+ getattr(s, "region", None) for s in genome) if r is not None),
610
+ None)
611
+ from .genome.scale import origin_of, segments_of
612
+ self.segments = segments_of(self.axis._owner) if role == "x" else None
613
+ if self.segments is not None:
614
+ self._check_segmented(on_axis)
615
+ self.region = None
616
+ o = origin_of(self.axis.resolve("origin"), self.region)
617
+ self.origin = 0.0 if o is None else o
618
+ self.tz = self.axis.resolve("tz")
619
+ if self.tz is None and times:
620
+ self.tz = times[0].time_role(role)[1]
621
+ if scale_name == "category":
622
+ if any(s.categories(role) is None and s.has_axis(role) for s in series):
623
+ raise ValueError(f"{role} axis mixes categorical and numeric series")
624
+ self.scale = None
625
+ self.categories = cats
626
+ n = max(len(cats), 1)
627
+ self.lo, self.hi = -0.5, n - 0.5
628
+ spec = self._named_limits(self.axis.resolve("limits"))
629
+ if spec != "auto":
630
+ lo, hi, _, _ = resolve_limits(spec, self.lo, self.hi)
631
+ self.lo, self.hi = float(lo), float(hi)
632
+ self.flo, self.fhi = self.lo, self.hi
633
+ return
634
+ if categorical:
635
+ raise ValueError(f"{role} axis has categorical series but scale {scale_name!r}")
636
+ scale = self.scale = get_scale(scale_name, self.axis.resolve("linthresh"))
637
+ count, lo, hi = 0, math.inf, -math.inf
638
+ zero_pad, self.moved = False, False
639
+ sticky: list[float] = []
640
+ for s in series:
641
+ if not s.has_axis(role):
642
+ continue
643
+ t = affine(s, role) # x_origin, …: limits in the drawn units
644
+ if t is not None and scale.function is not None:
645
+ raise ValueError(f"{role}_origin, {role}_scale and {role}_extent need a "
646
+ f"linear or time {role} axis")
647
+ n, a, b = s.extent(role, scale)
648
+ if n:
649
+ if t is not None:
650
+ finite = math.isfinite(b - a)
651
+ a, b = sorted((apply(t, a), apply(t, b)))
652
+ if finite and not math.isfinite(b - a):
653
+ kind = getattr(getattr(s, "series", s), "kind", "series")
654
+ raise ValueError(f"{kind}: {role}_origin, {role}_scale or "
655
+ f"{role}_extent draw its {role} values beyond the "
656
+ f"number range")
657
+ self.moved = True
658
+ count += n
659
+ lo, hi = min(lo, a), max(hi, b)
660
+ zero_pad = zero_pad or s.zero_padding(role)
661
+ sticky.extend(s.sticky(role) if t is None else [apply(t, v) for v in s.sticky(role)])
662
+ if not count:
663
+ lo, hi = (1.0, 10.0) if scale.base is not None else (0.0, 1.0)
664
+ self.data_lo, self.data_hi = lo, hi
665
+ zero = self.axis.resolve("include_zero")
666
+ if zero == "auto":
667
+ zero = any(s.zero_default(role) for s in on_axis)
668
+ spec = self._named_limits(self.axis.resolve("limits"))
669
+ if self.genome: # the region (drawn), without padding or 0
670
+ zero, zero_pad = False, True
671
+ if spec == "auto" and self.region is not None:
672
+ spec = (self.region.start - self.origin, self.region.end - self.origin)
673
+ elif spec == "auto" and self.segments is not None:
674
+ spec = (0.0, self.segments.total)
675
+ if zero and scale.name in ("linear", "sqrt"):
676
+ lo, hi = min(lo, 0.0), max(hi, 0.0)
677
+ qs = needs_quantiles(spec)
678
+ qmap: dict[float, float] = {}
679
+ if qs:
680
+ from array import array
681
+ vals = array("d")
682
+ for s in series:
683
+ if s.has_axis(role):
684
+ v = s.values(role)
685
+ if v is not None:
686
+ t = affine(s, role)
687
+ v = doubles(v)
688
+ vals.extend(v if t is None else kernels.scaled(v, t[1], -t[0] * t[1]))
689
+ vals = array("d", [v for v in vals if math.isfinite(v) and (
690
+ scale.function is None or scale.contains(v))])
691
+ if len(vals): # none: quantile sides at the data's ends
692
+ for q, r in zip(qs, kernels.quantiles(vals, qs)):
693
+ qmap[q] = r
694
+ lo2, hi2, lo_auto, hi_auto = resolve_limits(spec, lo, hi,
695
+ (lambda q: qmap[q]) if qmap else None)
696
+ flo, fhi = scale.f(lo2), scale.f(hi2)
697
+ if not (math.isfinite(flo) and math.isfinite(fhi)):
698
+ what = {"c": "color", "s": "size"}.get(role, role)
699
+ raise ValueError(f"{what} axis limits {lo2!r}/{hi2!r} are outside the "
700
+ f"{scale.name} scale's domain")
701
+ center = self.axis.resolve("center") if "center" in self.axis.SETTINGS else None
702
+ if symmetric(spec):
703
+ flo, fhi, lo2, hi2 = _about(scale, 0.0 if center is None else center, flo, fhi,
704
+ True, True, lo2, hi2)
705
+ elif center is not None and (lo_auto or hi_auto):
706
+ flo, fhi, lo2, hi2 = _about(scale, center, flo, fhi, lo_auto, hi_auto, lo2, hi2)
707
+ self.reversed = flo > fhi and not (lo_auto or hi_auto) # (2, 0): inverted
708
+ if flo > fhi:
709
+ flo, fhi = fhi, flo
710
+ lo_auto, hi_auto = hi_auto, lo_auto
711
+ inner = (flo, fhi)
712
+ self._pad_frac = 0.0
713
+ if fhi == flo and scale.name == "time": # one time: a unit of its data each side
714
+ from .times import time_unit
715
+ u = time_unit(flo, self.tz)
716
+ flo, fhi = flo - u, fhi + u
717
+ elif fhi - flo < 1e-280: # one value (or too close to map to px)
718
+ flo, fhi = (flo + fhi) / 2 - 0.5, (flo + fhi) / 2 + 0.5
719
+ if scale.name == "sqrt" and flo < 0:
720
+ flo, fhi = 0.0, fhi + 0.5
721
+ else:
722
+ if sticky and self.axis.resolve("sticky"): # a side on a baseline: unpadded
723
+ tol = 1e-12 * max(1.0, abs(flo), abs(fhi))
724
+ fs = [f for f in (scale.f(v) for v in sticky) if math.isfinite(f)]
725
+ lo_auto = lo_auto and not any(abs(f - flo) <= tol for f in fs)
726
+ hi_auto = hi_auto and not any(abs(f - fhi) <= tol for f in fs)
727
+ name: str | None = "padding"
728
+ if self.axis.get("padding") == "auto":
729
+ name = "auto_padding" if padded and not zero_pad else None
730
+ pad, self._pad_px = self._padding(name)
731
+ self._pad_frac = pad
732
+ d = (fhi - flo) * pad
733
+ if lo_auto:
734
+ flo -= d
735
+ if scale.name == "sqrt" and flo < 0:
736
+ flo = 0.0
737
+ if hi_auto:
738
+ fhi += d
739
+ if not math.isfinite(scale.inverse(flo)):
740
+ flo = scale.f(lo2) if scale.f(lo2) < fhi else flo + d
741
+ if not math.isfinite(scale.inverse(fhi)):
742
+ fhi = scale.f(hi2)
743
+ self._inner = inner
744
+ self.flo, self.fhi = flo, fhi
745
+ self.lo, self.hi = scale.inverse(flo), scale.inverse(fhi)
746
+ self._auto = (lo_auto, hi_auto)
747
+ self._zero_pad = zero_pad
748
+ self._base = (self.lo, self.hi, self.flo, self.fhi)
749
+ self._unpadded_px = self._base
750
+
751
+ def _check_segmented(self, on_axis: Sequence[Any]) -> None:
752
+ """An axis over several regions: only genome series, no limits or origin."""
753
+ role = self.axis.role
754
+ plain = [s for s in on_axis
755
+ if getattr(s, "scale_of", lambda r: None)(role) != "genome"]
756
+ if plain:
757
+ what = getattr(getattr(plain[0], "series", plain[0]), "kind", "a series")
758
+ raise ValueError(f"{what} has plain {role} values, which an axis over several "
759
+ f"regions cannot place: draw it over one region, or mark "
760
+ f"regions with fig.vspan(region)")
761
+ for name in ("limits", "origin"):
762
+ if self.axis.resolve(name) not in (None, "auto"):
763
+ raise ValueError(f"{role} axis over several regions: {name} cannot be set "
764
+ f"(each region is drawn whole)")
765
+
766
+ def _named_limits(self, spec: Any) -> Any:
767
+ """``limits`` with its category names (``("b", "d")``) as positions that show those
768
+ categories whole."""
769
+ if spec == "auto" or not any(isinstance(s, tuple) and s[0] == "name" for s in spec):
770
+ return spec
771
+ if not self.is_category:
772
+ bad = next(s[1] for s in spec if isinstance(s, tuple) and s[0] == "name")
773
+ raise ValueError(f"limits: {bad!r} is not a value of the {self.axis.role} axis "
774
+ f"(a number, a date or ISO 8601 text)")
775
+ at = [self.value(s[1]) if isinstance(s, tuple) and s[0] == "name" else s
776
+ for s in spec]
777
+ sign = -1.0 if all(isinstance(v, float) for v in at) and at[0] > at[1] else 1.0
778
+ return tuple(v - sign * 0.5 * (1 if i == 0 else -1)
779
+ if isinstance(s, tuple) and s[0] == "name" else v
780
+ for i, (s, v) in enumerate(zip(spec, at)))
781
+
782
+ def _padding(self, name: str | None) -> tuple[float, float]:
783
+ """Padding setting ``name`` as (fraction of the range, px): `%` is of the range,
784
+ lengths are px, added once the axis length is known."""
785
+ if name is None:
786
+ return 0.0, 0.0
787
+ a = self.axis
788
+ px = a.resolve(name, of=0.0)
789
+ return a.resolve(name, of=1.0) - px, px
790
+
791
+ def _apply_px_padding(self, length: float) -> None:
792
+ """Add the px part of the padding to the automatic limits of an axis ``length`` px
793
+ long."""
794
+ if not self._pad_px or self.scale is None:
795
+ return
796
+ lo_auto, hi_auto = self._auto
797
+ n = int(lo_auto) + int(hi_auto)
798
+ lo, hi, flo, fhi = self._unpadded_px
799
+ if not n or length <= n * self._pad_px or fhi == flo:
800
+ return
801
+ d = (fhi - flo) * self._pad_px / (length - n * self._pad_px)
802
+ if lo_auto:
803
+ flo -= d
804
+ if self.scale.name == "sqrt" and flo < 0:
805
+ flo = 0.0
806
+ if hi_auto:
807
+ fhi += d
808
+ if not (math.isfinite(self.scale.inverse(flo)) and math.isfinite(self.scale.inverse(fhi))):
809
+ return
810
+ self.flo, self.fhi = flo, fhi
811
+ self.lo, self.hi = self.scale.inverse(flo), self.scale.inverse(fhi)
812
+ self._base = (self.lo, self.hi, self.flo, self.fhi)
813
+
814
+ def _apply_label_room(self, fonts: Any, length: float) -> None:
815
+ """Widen automatic limits until the labels series draw past their values (outside
816
+ bar labels) fit within an axis ``length`` px long, with the axis padding past them."""
817
+ if not self._roomers or self.scale is None or length <= 0 or not any(self._auto):
818
+ return
819
+ scale = self.scale
820
+ needs = []
821
+ for s in self._roomers:
822
+ t = affine(s, self.axis.role)
823
+ for v, px, d in s.label_room(fonts, self.axis.role):
824
+ if t is not None:
825
+ v, d = apply(t, v), (d if t[1] > 0 else -d)
826
+ f = scale.f(v)
827
+ k = (px + self._pad_px) / length + self._pad_frac
828
+ if math.isfinite(f) and px > 0 and k < 0.5:
829
+ needs.append((f, k, d))
830
+ if not needs:
831
+ return
832
+ lo_auto, hi_auto = self._auto
833
+ flo, fhi = self.flo, self.fhi
834
+ for _ in range(8): # each side's need depends on the other limit
835
+ nlo, nhi = flo, fhi
836
+ for f, k, d in needs:
837
+ if d > 0 and hi_auto:
838
+ nhi = max(nhi, (f - k * flo) / (1 - k))
839
+ elif d < 0 and lo_auto:
840
+ nlo = min(nlo, (f - k * fhi) / (1 - k))
841
+ if (nlo, nhi) == (flo, fhi):
842
+ break
843
+ flo, fhi = nlo, nhi
844
+ if (flo, fhi) == (self.flo, self.fhi) or not (
845
+ math.isfinite(scale.inverse(flo)) and math.isfinite(scale.inverse(fhi))):
846
+ return
847
+ in_lo, in_hi = self._inner # snapping keeps the labels' room
848
+ self._inner = (flo if flo < self.flo else in_lo, fhi if fhi > self.fhi else in_hi)
849
+ self.flo, self.fhi = flo, fhi
850
+ self.lo, self.hi = scale.inverse(flo), scale.inverse(fhi)
851
+ self._base = (self.lo, self.hi, self.flo, self.fhi)
852
+
853
+ def copy_limits(self, other: "AxisState") -> None:
854
+ """Take the scale, categories and limits of ``other`` (a shared group's leader)."""
855
+ for k in ("scale", "categories", "lo", "hi", "flo", "fhi", "data_lo", "data_hi",
856
+ "segments",
857
+ "auto_invert", "reversed", "_auto", "_zero_pad", "_base", "_inner",
858
+ "_pad_px", "_pad_frac", "_unpadded_px"):
859
+ setattr(self, k, getattr(other, k))
860
+ self.categories = list(other.categories)
861
+
862
+ # -- ticks -------------------------------------------------------------------
863
+
864
+ def tick_font(self) -> Font:
865
+ a = self.axis
866
+ size = self.leaf_size or a.resolve("tick_label_font_size")
867
+ return font_of(a, size, a.resolve("tick_label_weight"),
868
+ color=a.resolve("tick_label_color"), markup=False)
869
+
870
+ def label_font(self) -> Font:
871
+ a = self.axis
872
+ return font_of(a, a.resolve("label_font_size"), a.resolve("label_weight"),
873
+ a.resolve("label_style"), a.resolve("label_color"))
874
+
875
+ def compute_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
876
+ """Ticks for an axis ``length`` px long (``horizontal``: labels side by side);
877
+ with ``snap``, automatic limits are then moved onto major ticks."""
878
+ lead = self.shared
879
+ if lead is not None:
880
+ self.copy_limits(lead)
881
+ if lead.length and abs(lead.length - length) <= 0.5:
882
+ self.lo, self.hi, self.flo, self.fhi = lead.lo, lead.hi, lead.flo, lead.fhi
883
+ self.ticks, self.offset_label = list(lead.ticks), lead.offset_label
884
+ self.rotation, self.length = lead.rotation, length
885
+ self.leaf_size = lead.leaf_size
886
+ self.crowded = lead.crowded
887
+ return
888
+ mode = self.axis.get("snap") if self.scale is not None else False
889
+ on = (self.scale is not None and mode is not False and any(self._auto)
890
+ and not self._zero_pad
891
+ and (self.scale.name in ("linear", "logit") or self.scale.base is not None))
892
+ self._apply_px_padding(length)
893
+ self._apply_label_room(fonts, length)
894
+ if on:
895
+ self.lo, self.hi, self.flo, self.fhi = self._base
896
+ self._compute_ticks(fonts, length, horizontal)
897
+ if not on:
898
+ return
899
+ assert self.scale is not None
900
+ base_flo, base_fhi = self._base[2], self._base[3]
901
+ if mode is True:
902
+ back = out = math.inf
903
+ else: # `%` of the axis, px: as position-space distances
904
+ px, frac = self.axis.resolve("snap", of=0.0), self.axis.resolve("snap", of=1.0)
905
+ span = base_fhi - base_flo
906
+ back, out = (((p / length if length > 0 else 0.0) + f - p) * span
907
+ for p, f in zip(px, frac))
908
+ in_lo, in_hi = self._inner
909
+ # The wider range can need a larger step (labels must fit): repeat until stable.
910
+ for _ in range(8):
911
+ majors = sorted(self.major_positions())
912
+ if self.scale.name == "linear":
913
+ if len(majors) < 2:
914
+ return
915
+ step = majors[1] - majors[0]
916
+ else:
917
+ step = 1.0
918
+ flo, fhi = self.flo, self.fhi
919
+ eps = step * 1e-9
920
+ if self._auto[0]:
921
+ outer = math.floor((base_flo + eps) / step) * step
922
+ tick = math.ceil((base_flo - eps) / step) * step
923
+ if base_flo - outer <= out:
924
+ flo = outer
925
+ elif tick - base_flo <= back and tick < in_lo - eps:
926
+ flo = tick
927
+ else:
928
+ flo = base_flo
929
+ if self._auto[1]:
930
+ outer = math.ceil((base_fhi - eps) / step) * step
931
+ tick = math.floor((base_fhi + eps) / step) * step
932
+ if outer - base_fhi <= out:
933
+ fhi = outer
934
+ elif base_fhi - tick <= back and tick > in_hi + eps:
935
+ fhi = tick
936
+ else:
937
+ fhi = base_fhi
938
+ if (flo, fhi) == (self.flo, self.fhi) or not math.isfinite(self.scale.inverse(flo)) \
939
+ or not math.isfinite(self.scale.inverse(fhi)) or not flo < fhi:
940
+ return
941
+ self.flo, self.fhi = flo, fhi
942
+ self.lo, self.hi = self.scale.inverse(flo), self.scale.inverse(fhi)
943
+ self._compute_ticks(fonts, length, horizontal)
944
+
945
+ def minor_grid_positions(self) -> list[float]:
946
+ """Values of the minor grid lines: the minor ticks, else (linear axes) halfway
947
+ between major ticks."""
948
+ minors = [t.value for t in self.ticks if not t.major]
949
+ if minors or self.scale is None or self.scale.name != "linear":
950
+ return minors
951
+ majors = sorted(t.value for t in self.ticks if t.major)
952
+ if len(majors) < 2:
953
+ return []
954
+ step = majors[1] - majors[0]
955
+ lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
956
+ out = [m + step / 2 for m in [majors[0] - step] + majors]
957
+ return [v for v in out if lo <= v <= hi]
958
+
959
+
960
+ def _max_ticks(self, length: float, horizontal: bool, time: bool = False) -> int:
961
+ """``max_ticks``, or the automatic count for an axis of ``length`` px."""
962
+ a = self.axis
963
+ most = a.resolve("max_ticks")
964
+ if most != "auto":
965
+ return int(most)
966
+ spacing = a.resolve("time_tick_spacing" if time else "tick_spacing")[0 if horizontal else 1]
967
+ cap = a.resolve("auto_max_ticks")[1 if time else 0]
968
+ return min(cap, max(2, int(length / spacing) + 1))
969
+
970
+ def value(self, v: Any) -> float:
971
+ """``v`` in the axis' units: a number (numpy's too), a date or time (``datetime``,
972
+ ``date``, ``datetime64`` or ISO 8601 text: seconds since the epoch) or, on a
973
+ category axis, a category by name (its index; numbers are positions)."""
974
+ if self.is_category and not isinstance(v, (int, float)):
975
+ try:
976
+ if v in self.categories:
977
+ return float(self.categories.index(v))
978
+ except (TypeError, ValueError): # values that do not compare to names
979
+ pass
980
+ try:
981
+ return axis_value(v)
982
+ except ValueError:
983
+ what = "a category, a number" if self.is_category else "a number, a date"
984
+ raise ValueError(f"{v!r} is not a value of the {self.axis.role} axis ({what} "
985
+ f"or ISO 8601 text)") from None
986
+
987
+ def formatter(self) -> Any:
988
+ """The axis' tick label formatter: of categories, times or numbers (``tick_format``;
989
+ a genome axis' unit)."""
990
+ a = self.axis
991
+ fmt = a.resolve("tick_format")
992
+ style = NumberStyle.of(a)
993
+ if self.is_category:
994
+ return CategoryFormatter(self.categories, fmt, style)
995
+ assert self.scale is not None
996
+ if self.scale.name == "time":
997
+ return TimeFormatter(fmt, self.tz)
998
+ if self.genome:
999
+ from .genome.scale import tick_format
1000
+ fmt = tick_format(self) or fmt
1001
+ elif fmt == "chr:pos":
1002
+ raise ValueError("tick_format=\"chr:pos\" needs a genome axis (scale=\"genome\")")
1003
+ return NumberFormatter(fmt, style, a.resolve("sci_limits"))
1004
+
1005
+ def _limits_of(self, f: Any) -> tuple[Any, Any]:
1006
+ """The arguments of a ticks function ``f(lo, hi)``: the limits low to high, as
1007
+ datetimes on a time axis."""
1008
+ lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
1009
+ if self.scale is not None and self.scale.name == "time":
1010
+ return from_seconds(lo, self.tz), from_seconds(hi, self.tz)
1011
+ return lo, hi
1012
+
1013
+ def _every(self, e: Every) -> tuple[list[float], list[Any] | None]:
1014
+ """The values of ``plt.every(step)`` within the limits, and their labels when the
1015
+ step makes them (powers on a log axis)."""
1016
+ lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
1017
+ role = self.axis.role
1018
+ name = "category" if self.scale is None else self.scale.name
1019
+ if e.unit is not None:
1020
+ if name != "time":
1021
+ raise ValueError(f"{role} axis: {e!r} needs a time axis")
1022
+ unit, size = {"millisecond": ("microsecond", 1000), "week": ("day", 7)}.get(
1023
+ e.unit, (e.unit, 1))
1024
+ return calendar_ticks(lo, hi, unit, int(e.step) * size, self.tz), None
1025
+ base = self.scale.base if self.scale is not None else None
1026
+ if name == "category" or base is not None:
1027
+ if not float(e.step).is_integer():
1028
+ what = "categories" if name == "category" else "powers of the base"
1029
+ raise ValueError(f"{role} axis: {e!r} needs a whole number of {what}")
1030
+ n = int(e.step)
1031
+ if name == "category":
1032
+ return [float(i) for i in range(0, len(self.categories), n)
1033
+ if lo - 1e-9 <= i <= hi + 1e-9], None
1034
+ assert base is not None
1035
+ flo, fhi = min(self.flo, self.fhi), max(self.flo, self.fhi)
1036
+ ks = [k for k in range(math.ceil(flo - 1e-9), math.floor(fhi + 1e-9) + 1)
1037
+ if k % n == 0]
1038
+ return [base ** k for k in ks], power_labels(
1039
+ base, ks, self.axis.resolve("tick_format"), NumberStyle.of(self.axis))
1040
+ return step_values(lo, hi, e.step), None
1041
+
1042
+ def _spec_values(self, spec: Any) -> tuple[list[float], list[Any] | None]:
1043
+ """The values of ``ticks`` or ``minor_ticks`` given as values, ``plt.every`` or a
1044
+ function ``f(lo, hi)``, in axis units, and labels the step makes."""
1045
+ if isinstance(spec, Every):
1046
+ return self._every(spec)
1047
+ if callable(spec):
1048
+ limits = self._limits_of(spec)
1049
+ try:
1050
+ inspect.signature(spec).bind(*limits)
1051
+ except TypeError as e:
1052
+ raise ValueError(f"{self.axis.role} axis: the function of the ticks or "
1053
+ f"minor_ticks setting takes (lo, hi): {e}") from None
1054
+ except ValueError: # no signature (a builtin): called as is
1055
+ pass
1056
+ got = spec(*limits)
1057
+ if isinstance(got, (str, bytes)) or not hasattr(got, "__iter__"):
1058
+ raise ValueError(f"{self.axis.role} axis: the ticks function returned "
1059
+ f"{got!r}, not a list of values")
1060
+ spec = list(got)
1061
+ return [self.value(v) for v in spec], None
1062
+
1063
+ def _compute_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
1064
+ if self.segments is not None:
1065
+ from .genome.scale import segment_ticks
1066
+ segment_ticks(self, fonts, length, horizontal)
1067
+ return
1068
+ self.crowded = None
1069
+ self._choose_ticks(fonts, length, horizontal)
1070
+ if not self.call_leaves: # leaves: :meth:`_fit_leaves`
1071
+ self._fit_labels(fonts, length, horizontal)
1072
+
1073
+ def _overlaps(self, fonts: Any, length: float, horizontal: bool) -> bool:
1074
+ """Whether two neighbouring tick labels, placed as drawn, are closer than
1075
+ ``tick_label_gap``."""
1076
+ from .boxes import tick_label_runs
1077
+ from .labels import _overlap, run_quad
1078
+ mapper = self.mapper(0.0, length) if horizontal else self.mapper(length, 0.0)
1079
+ quads = [q for q in (run_quad(fonts, r) for r in
1080
+ tick_label_runs(self, fonts, mapper, 0.0, horizontal, False,
1081
+ True))
1082
+ if q is not None]
1083
+ quads.sort(key=lambda q: sum(p[0 if horizontal else 1] for p in q))
1084
+ half = -float(self.axis.resolve("tick_label_gap")) / 2
1085
+ inner = [_shrunk(q, half) for q in quads]
1086
+ return any(_overlap(p, q) for p, q in zip(inner, inner[1:]))
1087
+
1088
+ def _fit_labels(self, fonts: Any, length: float, horizontal: bool) -> None:
1089
+ """Tick labels closer than ``tick_label_gap``, chosen or given: every k-th
1090
+ labelled (the others' ticks kept), then turned 45° (``tick_rotation="auto"``, x),
1091
+ then wrapped at spaces (unrotated x labels), then smaller down to
1092
+ ``label_min_size``, each with as few labels left out as fit; else one label
1093
+ (``crowded``: the axis warns when drawn)."""
1094
+ if length <= 0:
1095
+ return
1096
+ labelled = [i for i, t in enumerate(self.ticks)
1097
+ if t.major and t.label is not None and t.label != ""]
1098
+ if len(labelled) < 2 or not self._overlaps(fonts, length, horizontal):
1099
+ return
1100
+ a = self.axis
1101
+ full = list(self.ticks)
1102
+ order = sorted(labelled, key=lambda i: self.f(full[i].value))
1103
+ n = len(order)
1104
+
1105
+ def thinned() -> bool:
1106
+ for k in range(1, max(n - 1, 1) + 1):
1107
+ drop = {i for j, i in enumerate(order) if j % k}
1108
+ self.ticks = [Tick(t.value, None, t.major) if i in drop else t
1109
+ for i, t in enumerate(full)]
1110
+ if not self._overlaps(fonts, length, horizontal):
1111
+ return True
1112
+ return False
1113
+
1114
+ if thinned():
1115
+ return
1116
+ if horizontal and not self.rotation and a.resolve("tick_rotation") == "auto":
1117
+ self.rotation = 45.0
1118
+ if thinned():
1119
+ return
1120
+ if horizontal and not self.rotation:
1121
+ room = max(length / max(n, 1), 1.0)
1122
+ font = self.tick_font()
1123
+ wrapped = [Tick(t.value, wrap(fonts, t.label, font, room), t.major)
1124
+ if i in labelled else t for i, t in enumerate(full)]
1125
+ if wrapped != full:
1126
+ full = wrapped
1127
+ if thinned():
1128
+ return
1129
+ base = self.tick_font().size
1130
+ least = min(base, float(a.resolve("label_min_size")))
1131
+ size = base
1132
+ while size > least:
1133
+ size = max(least, size * 0.9)
1134
+ self.leaf_size = size
1135
+ if thinned():
1136
+ return
1137
+ self.ticks = [Tick(t.value, None, t.major) if i in order[1:] else t
1138
+ for i, t in enumerate(full)]
1139
+ self.crowded = size
1140
+
1141
+ def _choose_ticks(self, fonts: Any, length: float, horizontal: bool) -> None:
1142
+ a = self.axis
1143
+ self.length = length
1144
+ self.offset_label = None
1145
+ self.rotation = 0.0
1146
+ self.leaf_size = None
1147
+ given = a.resolve("ticks")
1148
+ font = self.tick_font()
1149
+ if given is False:
1150
+ self.ticks = []
1151
+ return
1152
+ if self.is_category:
1153
+ self._category_ticks(fonts, length, horizontal, font)
1154
+ return
1155
+ assert self.scale is not None
1156
+ if not (math.isfinite(self.fhi - self.flo) and math.isfinite(self.lo)
1157
+ and math.isfinite(self.hi)):
1158
+ origin = a.resolve("origin")
1159
+ raise ValueError(f"{self.axis.role} axis limits {self.lo!r}/{self.hi!r}: the range is "
1160
+ f"not finite on the {self.scale.name} scale"
1161
+ + (f" (origin {origin!r})" if origin is not None else "")
1162
+ + (f"; check the series' {self.axis.role}_origin, "
1163
+ f"{self.axis.role}_scale and {self.axis.role}_extent"
1164
+ if self.moved else ""))
1165
+ form = self.formatter()
1166
+ if isinstance(form, TimeFormatter) and given is None:
1167
+ most = self._max_ticks(length, horizontal, time=True)
1168
+ pairs, self.offset_label = form.ticks(self.lo, self.hi, most)
1169
+ self.ticks = [Tick(v, lab) for v, lab in pairs]
1170
+ rot = a.resolve("tick_rotation")
1171
+ self.rotation = 0.0 if rot == "auto" else float(rot)
1172
+ return
1173
+ if given is None and self.call_ticks is not None and a.resolve("tick_labels") is None:
1174
+ self.ticks = [Tick(float(v), lab) for v, lab in self.call_ticks]
1175
+ if self.call_leaves and len(self.ticks) > 1:
1176
+ span = abs(self.fhi - self.flo) or 1.0
1177
+ self.ticks = self._fit_leaves(fonts, self.ticks, length / span, horizontal,
1178
+ font)
1179
+ return
1180
+ if self.call_thin:
1181
+ self._thin_labels(fonts, font, length, horizontal)
1182
+ elif given is not None:
1183
+ labels = self._given_labels()
1184
+ values, made = self._spec_values(given)
1185
+ if labels is not None:
1186
+ _check_count(labels, values, "ticks")
1187
+ elif made is not None:
1188
+ labels = made
1189
+ else:
1190
+ labels, self.offset_label = form(values)
1191
+ self.ticks = [Tick(v, s) for v, s in zip(values, labels)]
1192
+ else:
1193
+ label_gap = a.resolve("tick_label_gap")
1194
+ max_ticks = self._max_ticks(length, horizontal)
1195
+
1196
+ def fits(values: list[float], step: float) -> bool:
1197
+ if len(values) < 2:
1198
+ return True
1199
+ labels = form(values).labels
1200
+ span = self.fhi - self.flo
1201
+ if not span:
1202
+ return True
1203
+ gap = length * abs(self.f(values[1]) - self.f(values[0])) / span
1204
+ if not horizontal: # stacked labels must not overlap
1205
+ return max(lines_box(fonts, s, font)[1] for s in labels) <= gap
1206
+ widest = max(lines_box(fonts, s, font)[0] for s in labels)
1207
+ return widest + label_gap <= gap
1208
+
1209
+ minor = a.resolve("minor_ticks")
1210
+ linear = self.scale.name == "linear"
1211
+ self.ticks, factor = numeric_ticks(
1212
+ self.scale, self.lo, self.hi, max_ticks, form.fmt, form.style,
1213
+ form.sci_limits, minor=minor is not False, length=length,
1214
+ fits=fits if linear else None,
1215
+ min_step=1.0 if self.whole or self.genome else 0.0)
1216
+ if linear: # labelled together: the additive offset
1217
+ values = [t.value for t in self.ticks if t.major]
1218
+ labels, self.offset_label = form(values)
1219
+ self.ticks = [Tick(v, s) for v, s in zip(values, labels)] + [
1220
+ t for t in self.ticks if not t.major]
1221
+ else:
1222
+ self.offset_label = form.offset(factor)
1223
+ if minor is True and linear:
1224
+ majors = [t.value for t in self.ticks if t.major]
1225
+ if len(majors) >= 2:
1226
+ step = majors[1] - majors[0]
1227
+ have = set(majors)
1228
+ self.ticks += [Tick(v, None, False) for v in minor_linear(self.lo, self.hi, step)
1229
+ if not any(abs(v - m) < step * 1e-6 for m in have)]
1230
+ minors = a.resolve("minor_ticks")
1231
+ if not isinstance(minors, bool) and minors is not None:
1232
+ self.ticks += [Tick(v, None, False) for v in self._spec_values(minors)[0]]
1233
+ lo, hi = min(self.flo, self.fhi), max(self.flo, self.fhi)
1234
+ eps = (hi - lo) * 1e-9
1235
+ self.ticks = [t for t in self.ticks
1236
+ if lo - eps <= self.f(t.value) <= hi + eps]
1237
+ rot = a.resolve("tick_rotation")
1238
+ self.rotation = 0.0 if rot == "auto" else float(rot)
1239
+
1240
+ def _thin_labels(self, fonts: Any, font: Font, length: float, horizontal: bool) -> None:
1241
+ """Drop the labels that collide: from the low end, a label is kept when it clears
1242
+ the last kept one by ``tick_label_gap``; the others' ticks stay, unlabelled."""
1243
+ span = self.fhi - self.flo
1244
+ if len(self.ticks) < 2 or not span or length <= 0:
1245
+ return
1246
+ gap = self.axis.resolve("tick_label_gap")
1247
+ out, last = [], None
1248
+ for t in sorted(self.ticks, key=lambda t: self.f(t.value)):
1249
+ if t.label in (None, ""):
1250
+ out.append(t)
1251
+ continue
1252
+ at = length * (self.f(t.value) - self.flo) / span
1253
+ half = lines_box(fonts, t.label, font)[0 if horizontal else 1] / 2
1254
+ if last is not None and at - half < last + gap:
1255
+ out.append(Tick(t.value, None, t.major))
1256
+ continue
1257
+ out.append(t)
1258
+ last = at + half
1259
+ self.ticks = out
1260
+
1261
+ def _category_index(self, v: Any) -> int:
1262
+ """The index of a category given in ``ticks``: by name, else by index."""
1263
+ x = self.value(v)
1264
+ if x.is_integer() and 0 <= x < len(self.categories):
1265
+ return int(x)
1266
+ raise ValueError(f"ticks: {v!r} is neither a category of the {self.axis.role} axis "
1267
+ f"nor the index of one")
1268
+
1269
+ def _given_labels(self) -> list[Any] | None:
1270
+ """``tick_labels``, their markup read (unless ``markup`` is off)."""
1271
+ labels = self.axis.resolve("tick_labels")
1272
+ if labels is None or not self.axis.resolve("markup"):
1273
+ return labels
1274
+ from .markup import parse
1275
+ return [parse(x) for x in labels]
1276
+
1277
+ def _category_ticks(self, fonts: Any, length: float, horizontal: bool, font: Font) -> None:
1278
+ a = self.axis
1279
+ given = a.resolve("ticks")
1280
+ index = list(range(len(self.categories))) if given is None else [
1281
+ self._category_index(v) for v in self._spec_values(given)[0]]
1282
+ labels = self._given_labels()
1283
+ if labels is not None:
1284
+ _check_count(labels, index, "categories" if given is None else "ticks")
1285
+ else:
1286
+ labels = self.formatter()(index).labels
1287
+ lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
1288
+ ticks = sorted((Tick(float(i), lab) for i, lab in zip(index, labels)
1289
+ if lo - 1e-9 <= i <= hi + 1e-9), key=lambda t: t.value)
1290
+ labels = [t.label for t in ticks]
1291
+ span = abs(self.fhi - self.flo) or 1.0
1292
+ step = length / span # px per category
1293
+ rot = a.resolve("tick_rotation")
1294
+ gap = a.resolve("tick_label_gap")
1295
+ rotated, stacked = a.resolve("category_label_spacing")
1296
+ if not ticks:
1297
+ self.ticks = []
1298
+ return
1299
+ if self.call_leaves:
1300
+ self.ticks = self._fit_leaves(fonts, ticks, step, horizontal, font)
1301
+ return
1302
+ if horizontal:
1303
+ widest = max(lines_box(fonts, lab, font)[0] for lab in labels)
1304
+ if rot == "auto":
1305
+ rot = 45.0 if widest + gap > step else 0.0
1306
+ need = (widest + gap) if rot == 0 else rotated
1307
+ else:
1308
+ rot = 0.0 if rot == "auto" else rot
1309
+ need = stacked
1310
+ self.rotation = float(rot)
1311
+ every = max(1, math.ceil(need / step)) if step > 0 else 1
1312
+ if every > 1:
1313
+ ticks = [t for i, t in enumerate(ticks) if i % every == 0]
1314
+ self.ticks = ticks
1315
+
1316
+ def _fit_leaves(self, fonts: Any, ticks: list[Tick], step: float, horizontal: bool,
1317
+ font: Font) -> list[Tick]:
1318
+ """A dendrogram's leaf labels, ``step`` px apart: flat when they fit, else rotated
1319
+ 90° (x; ``tick_rotation="auto"``), then at a smaller font size (down to
1320
+ ``label_min_size``), and only then every k-th."""
1321
+ a = self.axis
1322
+ rot = a.resolve("tick_rotation")
1323
+ gap = a.resolve("tick_label_gap")
1324
+ rotated, stacked = a.resolve("category_label_spacing")
1325
+ if horizontal:
1326
+ widest = max(lines_box(fonts, t.label, font)[0] for t in ticks)
1327
+ if rot == "auto":
1328
+ rot = 0.0 if widest + gap <= step else 90.0
1329
+ # at 90° the labels lie side by side as stacked lines do
1330
+ need = (widest + gap) if rot == 0 else stacked if abs(rot) == 90 else rotated
1331
+ else:
1332
+ rot = 0.0 if rot == "auto" else rot
1333
+ need = stacked
1334
+ self.rotation = float(rot)
1335
+ if step <= 0:
1336
+ return ticks
1337
+ base = font.size
1338
+ if need > step:
1339
+ size = min(base, max(a.resolve("label_min_size"), base * step / need))
1340
+ if size < base:
1341
+ self.leaf_size = size
1342
+ need *= size / base
1343
+ every = max(1, math.ceil(need / step - 1e-9))
1344
+ return ticks if every == 1 else [t for i, t in enumerate(ticks) if i % every == 0]
1345
+
1346
+ def major_positions(self) -> list[float]:
1347
+ return [self.f(t.value) for t in self.ticks if t.major]