plot-io 0.1.0__py3-none-any.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 (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/axis.py ADDED
@@ -0,0 +1,574 @@
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 math
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from . import _backend
9
+ from .limits import check_limits, needs_quantiles, resolve_limits
10
+ from .scales import AXIS_SCALES, Scale, get_scale
11
+ from .settings import (Setting, Settings, boolean, color, either, literal, number, one_of,
12
+ pair, table, text)
13
+ from .text import Font, box
14
+ from .numbers import NumberStyle, check_format, format_axis, number_settings
15
+ from .ticks import Tick, minor_linear, numeric_ticks
16
+
17
+ __all__ = ["Axis", "AxisState", "Mapper"]
18
+
19
+
20
+ def _ticks_value(v: Any) -> Any:
21
+ if v is False:
22
+ return False
23
+ if isinstance(v, (list, tuple)):
24
+ return [float(x) if not isinstance(x, str) else x for x in v]
25
+ raise ValueError(f"expected False or a list of values, got {v!r}")
26
+
27
+
28
+ def _minor_value(v: Any) -> Any:
29
+ if isinstance(v, bool):
30
+ return v
31
+ if isinstance(v, (list, tuple)):
32
+ return [float(x) for x in v]
33
+ raise ValueError(f"expected True, False or a list of values, got {v!r}")
34
+
35
+
36
+ def _rotation(v: Any) -> Any:
37
+ if v == "auto":
38
+ return "auto"
39
+ return number(-180, 180)(v)
40
+
41
+
42
+ def _dash(v: Any) -> Any:
43
+ if isinstance(v, (list, tuple)):
44
+ return [number(lo=0)(x) for x in v]
45
+ return one_of("solid", "dashed", "dotted", "dashdot")(v)
46
+
47
+
48
+ class Axis(Settings):
49
+ """An axis of a plot (``plot.x_axis``, ``plot.y_axis``) or of a colorbar."""
50
+
51
+ @property
52
+ def nominal_scale(self) -> str:
53
+ """``scale``, or ``"linear"`` while unset (before it is detected from the data)."""
54
+ s = self.get("scale")
55
+ return "linear" if s is None else s
56
+
57
+ SETTINGS = table(
58
+ label=Setting(text, doc="Axis title: a string or rich content."),
59
+ label_size=Setting(number(positive=True),
60
+ doc="Font size of the label (pt); default: the base font size.", length=True),
61
+ scale=Setting(one_of(*AXIS_SCALES),
62
+ doc="`linear`, `log`, `log2`, `log10`, `sqrt`, `exp`, `symlog`, "
63
+ "`logit`, `time` or `category`; default: `category` for "
64
+ "categorical series, `time` for dates, else `linear`."),
65
+ linthresh=Setting(number(positive=True),
66
+ doc="Half-width of the linear region of a `symlog` axis."),
67
+ tz=Setting(lambda v: v,
68
+ doc="Time zone of a time axis (a `tzinfo`); default: the zone of the "
69
+ "first aware date, else naive (UTC)."),
70
+ limits=Setting(check_limits,
71
+ doc="`\"auto\"`, `\"lo/hi\"` (`min`, `max`, numbers, `q:F`) or `(lo, hi)`."),
72
+ padding=Setting(either(literal("auto"), number(lo=0)),
73
+ doc="Space added to automatic limits, as a fraction of the range; "
74
+ "`\"auto\"`: `auto_padding`, or 0 on axes of series drawn to "
75
+ "exact limits (box value axes, heatmaps, …) and in plots "
76
+ "without axes."),
77
+ auto_padding=Setting(number(lo=0),
78
+ doc="The fraction `padding=\"auto\"` adds on each side."),
79
+ invert=Setting(boolean, doc="Reverse the axis."),
80
+ ticks=Setting(_ticks_value, doc="`False` (no ticks) or a list of tick values."),
81
+ tick_labels=Setting(lambda v: [text(x) for x in v], doc="Labels of `ticks`."),
82
+ minor_ticks=Setting(_minor_value,
83
+ doc="`True`, `False` or a list of values; default: on for log10."),
84
+ tick_format=Setting(check_format,
85
+ doc="Number format: `auto`, `percent`, `sci`, `eng`, `si`, `int`, "
86
+ "a format string or a function."),
87
+ tick_rotation=Setting(_rotation,
88
+ doc="Label rotation in degrees, or `\"auto\"` (45° when "
89
+ "category labels overlap)."),
90
+ max_ticks=Setting(either(literal("auto"), number(lo=1, integer=True)),
91
+ doc="Most major ticks; `\"auto\"`: one per `tick_spacing` of "
92
+ "axis length, at least 2, at most `auto_max_ticks`."),
93
+ tick_spacing=Setting(pair(number(positive=True)),
94
+ doc="Space per automatic tick, in tick label font sizes, on "
95
+ "horizontal and vertical axes."),
96
+ time_tick_spacing=Setting(pair(number(positive=True)),
97
+ doc="`tick_spacing` of time axes."),
98
+ auto_max_ticks=Setting(pair(number(lo=2, integer=True)),
99
+ doc="Most automatic ticks on numeric and time axes."),
100
+ sci_limits=Setting(pair(number(integer=True)),
101
+ doc="Exponents beyond which labels become mantissas × 10ᵏ."),
102
+ tick_length=Setting(number(lo=0), doc="Tick length (pt).", length=True),
103
+ minor_tick_length=Setting(number(lo=0),
104
+ doc="Minor tick length (pt); default 0.6 × `tick_length`.", length=True),
105
+ tick_direction=Setting(one_of("out", "in", "both"), doc="`out`, `in` or `both`."),
106
+ tick_width=Setting(number(lo=0), doc="Tick line width (pt).", length=True),
107
+ tick_size=Setting(number(positive=True),
108
+ doc="Font size of tick labels (pt).", length=True),
109
+ tick_pad=Setting(number(lo=0), doc="Gap between ticks and labels (pt).", length=True),
110
+ label_pad=Setting(number(lo=0), doc="Gap between tick labels and the label (pt).", length=True),
111
+ color=Setting(color,
112
+ doc="Color of the axis line, ticks and labels (the default of "
113
+ "`line_color`, `tick_color`, `tick_label_color`, `label_color`)."),
114
+ line_color=Setting(color, doc="Axis line color."),
115
+ tick_color=Setting(color, doc="Tick color."),
116
+ tick_label_color=Setting(color, doc="Tick label color."),
117
+ label_color=Setting(color, doc="Axis title color."),
118
+ label_weight=Setting(one_of("normal", "bold"), doc="Axis title weight."),
119
+ label_style=Setting(one_of("normal", "italic"), doc="Axis title style."),
120
+ side=Setting(one_of("left", "right", "bottom", "top"),
121
+ doc="Where the ticks, labels and axis line go: `bottom` or `top` (x "
122
+ "axis), `left` or `right` (y axis)."),
123
+ tick_label_weight=Setting(one_of("normal", "bold"),
124
+ doc="Tick label weight."),
125
+ mirror=Setting(boolean, doc="Ticks also on the opposite side of the axes."),
126
+ spine_offset=Setting(number(lo=0),
127
+ doc="Distance the axis line (with its ticks and labels) is "
128
+ "moved away from the axes (pt).", length=True),
129
+ spine_trim=Setting(boolean,
130
+ doc="Axis line from the first to the last major tick only."),
131
+ include_zero=Setting(either(boolean, literal("auto")),
132
+ doc="Extend automatic limits to 0 (linear and sqrt scales), "
133
+ "before padding and `nice`: `True`, or `\"auto\"` where a "
134
+ "series on the axis asks for it (Vega-Lite's rule: scatter "
135
+ "axes, the value axis of lines, areas and boxes)."),
136
+ zero_line=Setting(boolean, doc="A line across the plot at 0."),
137
+ zero_line_color=Setting(color,
138
+ doc="Zero line color; default `line_color`."),
139
+ zero_line_width=Setting(number(lo=0),
140
+ doc="Zero line width (pt); default `line_width`.",
141
+ length=True),
142
+ nice=Setting(boolean,
143
+ doc="Extend automatic limits to the enclosing major ticks (linear and "
144
+ "log scales)."),
145
+ line_width=Setting(number(lo=0), doc="Axis line width (pt).", length=True),
146
+ grid=Setting(boolean, doc="Major grid lines across the plot."),
147
+ grid_color=Setting(color, doc="Grid line color."),
148
+ grid_width=Setting(number(lo=0), doc="Grid line width (pt).", length=True),
149
+ grid_style=Setting(_dash, doc="Grid line style or dash list."),
150
+ minor_grid=Setting(boolean,
151
+ doc="Minor grid lines: at the minor ticks, or halfway between "
152
+ "major ticks on linear axes without minor ticks."),
153
+ minor_grid_color=Setting(color,
154
+ doc="Minor grid color; default `grid_color`."),
155
+ minor_grid_width=Setting(number(lo=0),
156
+ doc="Minor grid width (pt); default half `grid_width`.",
157
+ length=True),
158
+ minor_grid_style=Setting(_dash,
159
+ doc="Minor grid style; default `grid_style`."),
160
+ visible=Setting(boolean, doc="Draw the axis (ticks, labels)."),
161
+ font_size=Setting(number(positive=True), inherited=True, doc="Base font size (pt).", length=True),
162
+ font_family=Setting(str, inherited=True, doc="Font family."),
163
+ text_color=Setting(color, inherited=True, doc="Text color."),
164
+ **number_settings(),
165
+ )
166
+
167
+ def __init__(self, owner: Any, role: str) -> None:
168
+ self._owner = owner
169
+ self.role = role
170
+ super().__init__()
171
+
172
+ def _parent(self) -> Settings | None:
173
+ return self._owner
174
+
175
+ def _theme_keys(self, name: str) -> tuple[str, ...]:
176
+ from .colorbar import Colorbar
177
+ if isinstance(self._owner, Colorbar):
178
+ if name in Colorbar.SETTINGS:
179
+ return (f"Axis.{name}",)
180
+ return (f"Colorbar.{name}", f"Axis.{name}")
181
+ return (f"{self.role.upper()}Axis.{name}", f"Axis.{name}")
182
+
183
+ def __call__(self, label: Any = None, **settings: Any) -> "Axis":
184
+ if label is not None:
185
+ settings["label"] = label
186
+ return self.set(**settings)
187
+
188
+ def __repr__(self) -> str:
189
+ return f"Axis({self.role})"
190
+
191
+
192
+ class Mapper:
193
+ """Data → page coordinate on one axis: ``page = a·f(v) + b``."""
194
+
195
+ __slots__ = ("function", "a", "b", "forward", "options")
196
+
197
+ def __init__(self, scale: Scale | None, flo: float, fhi: float, p0: float, p1: float) -> None:
198
+ self.function = scale.function if scale is not None else None
199
+ self.options = scale.options if scale is not None else {}
200
+ self.forward = scale.f if scale is not None else (lambda v: v)
201
+ span = fhi - flo
202
+ self.a = (p1 - p0) / span if span else 0.0
203
+ self.b = p0 - self.a * flo
204
+
205
+ def __call__(self, v: float) -> float:
206
+ return self.a * self.forward(v) + self.b
207
+
208
+ def many(self, values: Any) -> Any:
209
+ return _backend.scaled(values, self.a, self.b, self.function, **self.options)
210
+
211
+ def position(self, f: float) -> float:
212
+ """Page coordinate of a value already through the scale function."""
213
+ return self.a * f + self.b
214
+
215
+
216
+ class AxisState:
217
+ """An axis while drawing: resolved scale, limits, categories and ticks."""
218
+
219
+ def __init__(self, axis: Axis) -> None:
220
+ self.axis = axis
221
+ self.scale: Scale | None = None # None: category
222
+ self.categories: list[Any] = []
223
+ self.lo = 0.0 # data limits (category: index range)
224
+ self.hi = 1.0
225
+ self.flo = 0.0 # limits after the scale function
226
+ self.fhi = 1.0
227
+ self.data_lo = math.nan # union of the series' extents
228
+ self.data_hi = math.nan
229
+ self.ticks: list[Tick] = []
230
+ self.factor = 0 # ×10^factor written at the axis end
231
+ self.rotation = 0.0
232
+ self.length = 0.0
233
+ self.auto_invert = False # inverted by a series (heatmap rows)
234
+ self.bare = False # category axis of heatmaps only: labels only
235
+ self._auto = (False, False) # automatic low / high limit (for `nice`)
236
+ self._zero_pad = False # a series wants exact limits (heatmaps)
237
+ self._base = (0.0, 1.0, 0.0, 1.0) # lo, hi, flo, fhi before `nice`
238
+ self.shared: "AxisState | None" = None # the group's leader (shared axes)
239
+ self.hide_labels = False # an inner plot of a shared group
240
+ self.auto_label: Any = None # the column name of the series' data
241
+ self.tz: Any = None # time zone of a time axis
242
+ self.offset: str | None = None # larger date unit common to the time ticks
243
+
244
+ def on_far_side(self) -> bool:
245
+ """Whether the axis is drawn on the top (x) or right (y) side of the axes."""
246
+ return self.axis.get("side") in ("top", "right")
247
+
248
+ @property
249
+ def is_category(self) -> bool:
250
+ return self.scale is None
251
+
252
+ def f(self, v: float) -> float:
253
+ return self.scale.f(v) if self.scale is not None else v
254
+
255
+ @property
256
+ def inverted(self) -> bool:
257
+ if self.shared is not None:
258
+ return self.shared.inverted
259
+ a = self.axis
260
+ return a.get("invert") if a.is_set("invert") else self.auto_invert
261
+
262
+ def tick_out(self) -> tuple[float, float]:
263
+ """Tick extent outside and inside the axes."""
264
+ a = self.axis
265
+ if self.bare and not a.is_set("tick_length"):
266
+ return 0.0, 0.0
267
+ n = a.get("tick_length")
268
+ d = a.get("tick_direction")
269
+ return (n if d in ("out", "both") else 0.0), (n if d in ("in", "both") else 0.0)
270
+
271
+ def mapper(self, p0: float, p1: float) -> Mapper:
272
+ inv = self.inverted
273
+ if inv:
274
+ p0, p1 = p1, p0
275
+ return Mapper(self.scale, self.flo, self.fhi, p0, p1)
276
+
277
+ # -- limits ------------------------------------------------------------------
278
+
279
+ def resolve(self, series: Sequence[Any], padded: bool) -> None:
280
+ """Scale, categories and limits from the series drawn on this axis."""
281
+ role = self.axis.role
282
+ scale_name = self.axis.get("scale")
283
+ cats: list[Any] = []
284
+ categorical = False
285
+ for s in series:
286
+ c = s.categories(role)
287
+ if c is not None:
288
+ categorical = True
289
+ for k in c:
290
+ if k not in cats:
291
+ cats.append(k)
292
+ on_axis = [s for s in series if s.has_axis(role)]
293
+ self.auto_invert = any(s.inverts(role) for s in on_axis)
294
+ times = [s for s in on_axis if getattr(s, "time_role", lambda r: None)(role)]
295
+ if scale_name is None:
296
+ scale_name = "category" if categorical else ("time" if times else "linear")
297
+ self.tz = self.axis.get("tz")
298
+ if self.tz is None and times:
299
+ self.tz = times[0].time_role(role)[1]
300
+ from .series.heatmap import Heatmap
301
+ self.bare = (scale_name == "category" and bool(on_axis)
302
+ and all(isinstance(s, Heatmap) for s in on_axis))
303
+ if scale_name == "category":
304
+ if any(s.categories(role) is None and s.has_axis(role) for s in series):
305
+ raise ValueError(f"{role} axis mixes categorical and numeric series")
306
+ self.scale = None
307
+ self.categories = cats
308
+ n = max(len(cats), 1)
309
+ self.lo, self.hi = -0.5, n - 0.5
310
+ spec = self.axis.get("limits")
311
+ if spec != "auto":
312
+ lo, hi, _, _ = resolve_limits(spec, self.lo, self.hi)
313
+ self.lo, self.hi = float(lo), float(hi)
314
+ self.flo, self.fhi = self.lo, self.hi
315
+ return
316
+ if categorical:
317
+ raise ValueError(f"{role} axis has categorical series but scale {scale_name!r}")
318
+ scale = self.scale = get_scale(scale_name, self.axis.get("linthresh"))
319
+ count, lo, hi = 0, math.inf, -math.inf
320
+ zero_pad = False
321
+ for s in series:
322
+ if not s.has_axis(role):
323
+ continue
324
+ n, a, b = s.extent(role, scale)
325
+ if n:
326
+ count += n
327
+ lo, hi = min(lo, a), max(hi, b)
328
+ zero_pad = zero_pad or s.zero_padding(role)
329
+ if not count:
330
+ lo, hi = (1.0, 10.0) if scale.name.startswith("log") else (0.0, 1.0)
331
+ self.data_lo, self.data_hi = lo, hi
332
+ zero = self.axis.get("include_zero")
333
+ if zero == "auto":
334
+ zero = any(s.zero_default(role) for s in on_axis)
335
+ if zero and scale.name in ("linear", "sqrt"):
336
+ lo, hi = min(lo, 0.0), max(hi, 0.0)
337
+ spec = self.axis.get("limits")
338
+ qs = needs_quantiles(spec)
339
+ qmap: dict[float, float] = {}
340
+ if qs:
341
+ from array import array
342
+ vals = array("d")
343
+ for s in series:
344
+ if s.has_axis(role):
345
+ v = s.values(role)
346
+ if v is not None:
347
+ vals.extend(_backend.doubles(v))
348
+ if scale.function is not None:
349
+ vals = array("d", [v for v in vals if scale.contains(v)])
350
+ for q, r in zip(qs, _backend.quantiles(vals, qs)):
351
+ qmap[q] = r
352
+ lo2, hi2, lo_auto, hi_auto = resolve_limits(spec, lo, hi, lambda q: qmap[q])
353
+ flo, fhi = scale.f(lo2), scale.f(hi2)
354
+ if not (math.isfinite(flo) and math.isfinite(fhi)):
355
+ raise ValueError(f"{role} axis limits {lo2!r}/{hi2!r} are outside the "
356
+ f"{scale.name} scale's domain")
357
+ if flo > fhi:
358
+ flo, fhi = fhi, flo
359
+ lo_auto, hi_auto = hi_auto, lo_auto
360
+ if fhi == flo:
361
+ flo, fhi = flo - 0.5, fhi + 0.5
362
+ if scale.name == "sqrt" and flo < 0:
363
+ flo, fhi = 0.0, fhi + 0.5
364
+ else:
365
+ pad = self.axis.get("padding")
366
+ if pad == "auto":
367
+ pad = self.axis.get("auto_padding") if padded and not zero_pad else 0.0
368
+ d = (fhi - flo) * pad
369
+ if lo_auto:
370
+ flo -= d
371
+ if scale.name == "sqrt" and flo < 0:
372
+ flo = 0.0
373
+ if hi_auto:
374
+ fhi += d
375
+ if not math.isfinite(scale.inverse(flo)):
376
+ flo = scale.f(lo2) if scale.f(lo2) < fhi else flo + d
377
+ if not math.isfinite(scale.inverse(fhi)):
378
+ fhi = scale.f(hi2)
379
+ self.flo, self.fhi = flo, fhi
380
+ self.lo, self.hi = scale.inverse(flo), scale.inverse(fhi)
381
+ self._auto = (lo_auto, hi_auto)
382
+ self._zero_pad = zero_pad
383
+ self._base = (self.lo, self.hi, self.flo, self.fhi)
384
+
385
+ def copy_limits(self, other: "AxisState") -> None:
386
+ """Take the scale, categories and limits of ``other`` (a shared group's leader)."""
387
+ for k in ("scale", "categories", "lo", "hi", "flo", "fhi", "data_lo", "data_hi",
388
+ "auto_invert", "bare", "_auto", "_zero_pad", "_base"):
389
+ setattr(self, k, getattr(other, k))
390
+ self.categories = list(other.categories)
391
+
392
+ # -- ticks -------------------------------------------------------------------
393
+
394
+ def tick_font(self) -> Font:
395
+ a = self.axis
396
+ return Font(a.get("tick_size"), a.get("font_family"), a.get("tick_label_weight"),
397
+ color=a.get("tick_label_color"))
398
+
399
+ def label_font(self) -> Font:
400
+ a = self.axis
401
+ return Font(a.get("label_size"), a.get("font_family"), a.get("label_weight"),
402
+ a.get("label_style"), color=a.get("label_color"))
403
+
404
+ def compute_ticks(self, canvas: Any, length: float, horizontal: bool) -> None:
405
+ """Ticks for an axis ``length`` points long (``horizontal``: labels side by side);
406
+ with ``nice``, automatic limits are then extended to the enclosing major ticks."""
407
+ lead = self.shared
408
+ if lead is not None:
409
+ self.copy_limits(lead)
410
+ if lead.length and abs(lead.length - length) <= 0.5:
411
+ self.lo, self.hi, self.flo, self.fhi = lead.lo, lead.hi, lead.flo, lead.fhi
412
+ self.ticks, self.factor = list(lead.ticks), lead.factor
413
+ self.rotation, self.length = lead.rotation, length
414
+ return
415
+ nice = (self.scale is not None and self.axis.get("nice") and any(self._auto)
416
+ and not self._zero_pad
417
+ and (self.scale.name == "linear" or self.scale.name.startswith("log")))
418
+ if nice:
419
+ self.lo, self.hi, self.flo, self.fhi = self._base
420
+ self._compute_ticks(canvas, length, horizontal)
421
+ if not nice:
422
+ return
423
+ assert self.scale is not None
424
+ majors = sorted(self.major_positions())
425
+ if self.scale.name == "linear":
426
+ if len(majors) < 2:
427
+ return
428
+ step = majors[1] - majors[0]
429
+ else:
430
+ step = 1.0
431
+ flo, fhi = self.flo, self.fhi
432
+ eps = step * 1e-9
433
+ if self._auto[0]:
434
+ flo = math.floor((flo + eps) / step) * step
435
+ if self._auto[1]:
436
+ fhi = math.ceil((fhi - eps) / step) * step
437
+ if (flo, fhi) != (self.flo, self.fhi) and math.isfinite(self.scale.inverse(flo)) \
438
+ and math.isfinite(self.scale.inverse(fhi)):
439
+ self.flo, self.fhi = flo, fhi
440
+ self.lo, self.hi = self.scale.inverse(flo), self.scale.inverse(fhi)
441
+ self._compute_ticks(canvas, length, horizontal)
442
+
443
+ def minor_grid_positions(self) -> list[float]:
444
+ """Values of the minor grid lines: the minor ticks, else (linear axes) halfway
445
+ between major ticks."""
446
+ minors = [t.value for t in self.ticks if not t.major]
447
+ if minors or self.scale is None or self.scale.name != "linear":
448
+ return minors
449
+ majors = sorted(t.value for t in self.ticks if t.major)
450
+ if len(majors) < 2:
451
+ return []
452
+ step = majors[1] - majors[0]
453
+ lo, hi = min(self.lo, self.hi), max(self.lo, self.hi)
454
+ out = [m + step / 2 for m in [majors[0] - step] + majors]
455
+ return [v for v in out if lo <= v <= hi]
456
+
457
+
458
+ def _max_ticks(self, length: float, horizontal: bool, font_size: float,
459
+ time: bool = False) -> int:
460
+ """``max_ticks``, or the automatic count for an axis of ``length`` pt."""
461
+ a = self.axis
462
+ most = a.get("max_ticks")
463
+ if most != "auto":
464
+ return int(most)
465
+ spacing = a.get("time_tick_spacing" if time else "tick_spacing")[0 if horizontal else 1]
466
+ cap = a.get("auto_max_ticks")[1 if time else 0]
467
+ return min(cap, max(2, int(length / (spacing * font_size)) + 1))
468
+ def _compute_ticks(self, canvas: Any, length: float, horizontal: bool) -> None:
469
+ a = self.axis
470
+ self.length = length
471
+ self.factor = 0
472
+ self.rotation = 0.0
473
+ given = a.get("ticks")
474
+ font = self.tick_font()
475
+ if given is False:
476
+ self.ticks = []
477
+ return
478
+ if self.is_category:
479
+ self._category_ticks(canvas, length, horizontal, font)
480
+ return
481
+ assert self.scale is not None
482
+ self.offset = None
483
+ if self.scale.name == "time" and given is None:
484
+ from .times import time_ticks
485
+ most = self._max_ticks(length, horizontal, font.size, time=True)
486
+ fmt = a.get("tick_format")
487
+ pairs, self.offset = time_ticks(self.lo, self.hi, most, self.tz,
488
+ fmt if fmt != "auto" else "auto")
489
+ self.ticks = [Tick(v, lab) for v, lab in pairs]
490
+ rot = a.get("tick_rotation")
491
+ self.rotation = 0.0 if rot == "auto" else float(rot)
492
+ return
493
+ fmt = a.get("tick_format")
494
+ style = NumberStyle.of(a)
495
+ if given is not None:
496
+ labels = a.get("tick_labels")
497
+ values = [float(v) for v in given]
498
+ if labels is None:
499
+ labels, self.factor = format_axis(values, fmt, style, a.get("sci_limits"))
500
+ self.ticks = [Tick(v, s) for v, s in zip(values, labels)]
501
+ else:
502
+ fs = font.size
503
+ max_ticks = self._max_ticks(length, horizontal, fs)
504
+
505
+ def fits(values: list[float], step: float) -> bool:
506
+ if not horizontal or len(values) < 2:
507
+ return True
508
+ labels, _ = format_axis(values, fmt, style, a.get("sci_limits"))
509
+ widest = max(box(canvas, s, font)[0] for s in labels)
510
+ span = self.fhi - self.flo
511
+ if not span:
512
+ return True
513
+ gap = length * abs(self.f(values[1]) - self.f(values[0])) / span
514
+ return widest + 0.6 * fs <= gap
515
+
516
+ minor = a.get("minor_ticks")
517
+ self.ticks, self.factor = numeric_ticks(
518
+ self.scale, self.lo, self.hi, max_ticks, fmt, style, a.get("sci_limits"),
519
+ minor=minor is not False, length=length,
520
+ fits=fits if self.scale.name == "linear" else None)
521
+ if minor is True and self.scale.name == "linear":
522
+ majors = [t.value for t in self.ticks if t.major]
523
+ if len(majors) >= 2:
524
+ step = majors[1] - majors[0]
525
+ have = set(majors)
526
+ self.ticks += [Tick(v, None, False) for v in minor_linear(self.lo, self.hi, step)
527
+ if not any(abs(v - m) < step * 1e-6 for m in have)]
528
+ minors = a.get("minor_ticks")
529
+ if isinstance(minors, list):
530
+ self.ticks += [Tick(v, None, False) for v in minors]
531
+ lo, hi = min(self.flo, self.fhi), max(self.flo, self.fhi)
532
+ eps = (hi - lo) * 1e-9
533
+ self.ticks = [t for t in self.ticks
534
+ if lo - eps <= self.f(t.value) <= hi + eps]
535
+ rot = a.get("tick_rotation")
536
+ self.rotation = 0.0 if rot == "auto" else float(rot)
537
+
538
+ def _category_ticks(self, canvas: Any, length: float, horizontal: bool, font: Font) -> None:
539
+ a = self.axis
540
+ cats = self.categories
541
+ labels = a.get("tick_labels") or [str(c) if not isinstance(c, (list, str)) else c
542
+ for c in cats]
543
+ fmt = a.get("tick_format")
544
+ if fmt != "auto" and not a.get("tick_labels"):
545
+ if all(isinstance(c, (int, float)) and not isinstance(c, bool) for c in cats):
546
+ labels = format_axis([float(c) for c in cats], fmt, NumberStyle.of(a), None)[0]
547
+ elif callable(fmt):
548
+ labels = [fmt(c) for c in cats]
549
+ elif fmt not in ("percent", "sci", "eng", "si", "int"):
550
+ labels = [fmt.format(c) for c in cats]
551
+ ticks = [Tick(float(i), lab) for i, lab in enumerate(labels)]
552
+ span = abs(self.fhi - self.flo) or 1.0
553
+ step = length / span # points per category
554
+ rot = a.get("tick_rotation")
555
+ fs = font.size
556
+ if not ticks:
557
+ self.ticks = []
558
+ return
559
+ if horizontal:
560
+ widest = max(box(canvas, lab, font)[0] for lab in labels)
561
+ if rot == "auto":
562
+ rot = 45.0 if widest + 0.5 * fs > step else 0.0
563
+ need = (widest + 0.5 * fs) if rot == 0 else 1.6 * fs
564
+ else:
565
+ rot = 0.0 if rot == "auto" else rot
566
+ need = 1.3 * fs
567
+ self.rotation = float(rot)
568
+ every = max(1, math.ceil(need / step)) if step > 0 else 1
569
+ if every > 1:
570
+ ticks = [t for i, t in enumerate(ticks) if i % every == 0]
571
+ self.ticks = ticks
572
+
573
+ def major_positions(self) -> list[float]:
574
+ return [self.f(t.value) for t in self.ticks if t.major]