plot-io 0.12.0__py3-none-win_arm64.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-arm64.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/levels.py ADDED
@@ -0,0 +1,509 @@
1
+ """Level axes: a column of categories as an aesthetic (``color``, ``marker``,
2
+ ``line_style``, ``size``).
3
+
4
+ An aesthetic holds **levels** when it is a pandas or polars categorical, a ``data=``
5
+ column of text or booleans, or a list or array holding a value the setting does not take
6
+ (a list of colours, markers… stays one value per item). A :class:`LevelAxis` holds the
7
+ levels of one column (``level_order`` first, then the categorical order or first
8
+ appearance; a missing value is the level ``"nan"``, or its rows are left out with
9
+ ``level_missing="drop"``) and maps each to a value of its aesthetics: colours from the
10
+ plot's ``qualitative_map`` (its next colours in call order, as ``group=`` members take
11
+ them), or from the series' own ``color_map`` (a map's colours for the levels, a dict by
12
+ level), markers from
13
+ ``markers``, line styles from ``line_styles``, sizes from ``size_map`` (even by area).
14
+ Calls of a plot mapping the same column, and plots grouped by ``share_color``, give a
15
+ level one colour.
16
+
17
+ A type paints by levels (``Series.LEVELS``) as ``"items"`` (scatter: each point by its
18
+ levels, one call) or ``"split"`` (one member call per level combination, drawn,
19
+ positioned and coloured as the members of ``group=``: their points in data order). The legend has a section per
20
+ axis, titled by its column (or ``name``), with an entry per level; with one axis the
21
+ section's title is the legend's.
22
+ """
23
+
24
+ import array
25
+ import functools
26
+ import math
27
+ from typing import Any
28
+
29
+ from . import kernels
30
+ from .columns import _length, _missing, _order
31
+ from .validators import typed
32
+
33
+ __all__ = ["AESTHETICS", "adopt", "LevelAxis", "Section", "detect", "make", "calls_of", "assign",
34
+ "level_color_count", "facet_orders", "level_order"]
35
+
36
+ AESTHETICS = ("color", "marker", "line_style", "size")
37
+
38
+
39
+ # -- settings ------------------------------------------------------------------------
40
+
41
+ @typed("Column | Mapping[Any, Any]")
42
+ def level_order(v: Any) -> Any:
43
+ """``level_order``, ``markers``, ``line_styles``: a list, or a dict of values by
44
+ column (by level for markers and line styles); the values are checked where used."""
45
+ if isinstance(v, dict):
46
+ return dict(v)
47
+ if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__") or not len(v := list(v)):
48
+ raise ValueError(f"expected a list or a dict, got {v!r}")
49
+ return v
50
+
51
+
52
+ def adopt(cls: Any, kind: str) -> None:
53
+ """Make ``cls`` draw levels as ``kind`` (``"items"``, ``"split"``), with the settings
54
+ of its level axes."""
55
+ from .layers.base import common
56
+ from .settings import table
57
+ names = ["level_order"] + [f"{a}s" for a in ("marker", "line_style") if a in cls.SETTINGS]
58
+ cls.LEVELS = kind
59
+ cls.SETTINGS = {**cls.SETTINGS, **{k: v for k, v in table(**common(*names)).items()
60
+ if k not in cls.SETTINGS}}
61
+
62
+
63
+ # -- detection -----------------------------------------------------------------------
64
+
65
+ def _items(v: Any) -> list[Any] | None:
66
+ if isinstance(v, (str, bytes, dict, tuple, int, float)) or v is None:
67
+ return None
68
+ if isinstance(v, (array.array, memoryview)) and getattr(v, "typecode", "") != "u":
69
+ return None # numbers
70
+ if hasattr(v, "to_numpy") and not hasattr(v, "__array_interface__"):
71
+ v = v.to_numpy()
72
+ kind = getattr(getattr(v, "dtype", None), "kind", None)
73
+ if kind is not None and kind not in "OUSb":
74
+ return None # numbers
75
+ if hasattr(v, "tolist") and not isinstance(v, list):
76
+ v = v.tolist()
77
+ return v if isinstance(v, list) else None
78
+
79
+
80
+ def _is_levels(cls: type, name: str, v: Any, titled: bool, order: Any) -> bool:
81
+ if order is not None:
82
+ return True
83
+ items = _items(v)
84
+ if not items:
85
+ return False
86
+ check = cls.SETTINGS[name].check # type: ignore[attr-defined]
87
+ first = next((x for x in items if x is not None), None)
88
+ if not isinstance(first, (str, bool)):
89
+ return False # numbers, colour tuples: per item
90
+ try:
91
+ unique = set(items)
92
+ except TypeError: # colour rows among the items
93
+ unique = {x for x in items if isinstance(x, (str, bool))}
94
+ for x in (x for x in unique if isinstance(x, (str, bool))):
95
+ if isinstance(x, bool) or titled:
96
+ return True
97
+ try:
98
+ check(x)
99
+ except (ValueError, TypeError):
100
+ return True
101
+ return False
102
+
103
+
104
+ def detect(cls: type, kw: dict[str, Any], titles: dict[str, Any],
105
+ orders: dict[str, Any]) -> list[tuple[str, Any, Any, Any]]:
106
+ """The aesthetics of a call holding levels, taken out of ``kw``: ``(name, values,
107
+ column title, categorical order)``. Types that map none refuse them."""
108
+ settings = getattr(cls, "SETTINGS", {})
109
+ kind = getattr(cls, "LEVELS", None)
110
+ found: list[tuple[str, Any, Any, Any]] = []
111
+ if kind is None:
112
+ return found
113
+ for name in (*AESTHETICS, "opacity"):
114
+ v = kw.get(name)
115
+ if v is None or name not in settings:
116
+ continue
117
+ order = orders.get(name)
118
+ if order is None and not isinstance(v, (list, tuple, str)):
119
+ order = _order(v) # a categorical given directly
120
+ if not _is_levels(cls, name, v, name in titles, order):
121
+ continue
122
+ if name == "opacity":
123
+ raise ValueError(f"{cls.__name__}: opacity takes numbers, not levels (use color, "
124
+ "marker, line_style or size)")
125
+ if kind not in ("items", "split"):
126
+ raise ValueError(f"{cls.__name__}: {name} takes no levels; use plt.facet")
127
+ found.append((name, kw.pop(name), titles.get(name), order))
128
+ return found
129
+
130
+
131
+ # -- axes ----------------------------------------------------------------------------
132
+
133
+ class LevelAxis:
134
+ """The levels of one column and the aesthetics mapped from it.
135
+
136
+ ``key``: the column title (``"#color"`` for an untitled list); ``levels`` in order;
137
+ ``codes``: each row's level index (``-1``: left out); ``present``: the levels with
138
+ rows, in order."""
139
+
140
+ __slots__ = ("key", "title", "aesthetics", "levels", "codes", "present")
141
+
142
+ def __init__(self, name: str, values: Any, title: Any, order: Any, given: Any,
143
+ missing: str) -> None:
144
+ self.key = title if title is not None else "#" + name
145
+ self.title = title
146
+ self.aesthetics = [name]
147
+ if isinstance(given, dict):
148
+ given = given.get(self.key, given.get(name))
149
+ if hasattr(values, "to_numpy") and not hasattr(values, "__array_interface__"):
150
+ values = values.to_numpy()
151
+ if hasattr(values, "tolist") and not isinstance(values, list):
152
+ values = values.tolist()
153
+ seen = dict.fromkeys(values) # first appearance
154
+ gone = [k for k in seen if _missing(k) or (isinstance(k, float) and math.isnan(k))]
155
+ found = [k for k in seen if not any(k is g for g in gone)]
156
+ first = list(given or [])
157
+ rest = [k for k in (order or []) if k in seen and k not in first]
158
+ levels = first + rest + [k for k in found if k not in first and k not in rest]
159
+ if gone and missing == "keep" and "nan" not in levels:
160
+ levels.append("nan")
161
+ index = {k: i for i, k in enumerate(levels)}
162
+ for k in gone:
163
+ index[k] = index["nan"] if missing == "keep" else -1
164
+ self.levels = levels
165
+ self.codes = array.array("q", map(index.__getitem__, values))
166
+ used = {index[k] for k in seen}
167
+ self.present = [lv for i, lv in enumerate(levels) if i in used]
168
+
169
+ def take(self, rows: Any) -> None:
170
+ self.codes = array.array("q", (int(c) for c in kernels.take(
171
+ array.array("d", self.codes), rows)))
172
+ used = set(self.codes)
173
+ self.present = [lv for i, lv in enumerate(self.levels) if i in used]
174
+
175
+ def value(self, series: Any, name: str, level: Any) -> Any:
176
+ """The value of aesthetic ``name`` (not colour) for ``level``."""
177
+ k = self.levels.index(level)
178
+ if name == "size":
179
+ from .sizemaps import SizeMap
180
+ sm = series.resolve("size_map") if "size_map" in series.SETTINGS else None
181
+ lo, hi = (sm or SizeMap()).diameters(None if isinstance(series, _Probe)
182
+ else series)
183
+ t = k / (len(self.levels) - 1) if len(self.levels) > 1 else 1.0
184
+ return math.sqrt(lo * lo + t * (hi * hi - lo * lo))
185
+ from .tables import LINE_STYLE_CYCLE, MARKER_CYCLE
186
+ given = series.resolve(f"{name}s") if f"{name}s" in series.SETTINGS else None
187
+ if isinstance(given, dict) and level in given:
188
+ return given[level]
189
+ cycle = given if isinstance(given, list) else (
190
+ MARKER_CYCLE if name == "marker" else LINE_STYLE_CYCLE)
191
+ return cycle[k % len(cycle)]
192
+
193
+ def __repr__(self) -> str:
194
+ return f"LevelAxis({self.key!r}, {self.levels!r})"
195
+
196
+
197
+ class Section:
198
+ """A legend section's header (a level axis' title)."""
199
+
200
+ header = True
201
+
202
+ def __init__(self, label: Any) -> None:
203
+ self.label = label
204
+
205
+
206
+ class Levels:
207
+ """The level axes of a series and the calls it is drawn as (``parts``)."""
208
+
209
+ def __init__(self, axes: list[LevelAxis], color: LevelAxis | None) -> None:
210
+ self.axes = axes
211
+ self.color = color
212
+ #: ``(series, colour spec, legend-only)`` of each call, in drawing order
213
+ self.parts: list[tuple[Any, Any, bool]] = []
214
+ self.headers: dict[int, Section] = {}
215
+
216
+ def item_colors(self, series: Any, ctx: Any) -> Any:
217
+ """Each point's colour (items types): its level's, through ``kernels.take``."""
218
+ from .color.values import rgba_of
219
+ from .layers.paint import memo
220
+ from .spec import auto_colors
221
+
222
+ def make() -> Any:
223
+ table = array.array("d")
224
+ for c in auto_colors(series) or []:
225
+ rgba = rgba_of(ctx.color(c)) or (0.0, 0.0, 0.0, 0.0)
226
+ table.extend(rgba if len(rgba) == 4 else (*rgba, 1.0))
227
+ rows = memoryview(table).cast("B").cast("d", (len(table) // 4, 4))
228
+ flat = kernels.take(rows, self.color.codes) # type: ignore[union-attr]
229
+ n = len(flat) // 4
230
+ return memoryview(flat).cast("B").cast("d", (n, 4)) if n else []
231
+ return memo(ctx, ("levels", id(series)), make)
232
+
233
+
234
+ def make(cls: Any, cols: Any, pos: dict[str, Any], kw: dict[str, Any],
235
+ found: list[tuple[str, Any, Any, Any]]) -> Any:
236
+ """The series of a call with levels (``found``, from :func:`detect`)."""
237
+ n = len(found[0][1])
238
+ if "group" in kw:
239
+ raise ValueError(f"{cls.__name__}: group= and levels in {found[0][0]}: use one")
240
+ given = level_order(kw["level_order"]) if kw.get("level_order") is not None else None
241
+ axes: dict[str, LevelAxis] = {}
242
+ for name, v, title, order in found:
243
+ if len(v) != n:
244
+ raise ValueError(f"{name} has {len(v)} levels for {n} in {found[0][0]}")
245
+ ax = LevelAxis(name, v, title, order, given, kw.get("level_missing", "keep"))
246
+ if ax.key in axes:
247
+ axes[ax.key].aesthetics.append(name)
248
+ else:
249
+ axes[ax.key] = ax
250
+ every = list(axes.values())
251
+ if any(-1 in ax.codes for ax in every): # level_missing="drop"
252
+ keep = array.array("q", (i for i in range(n) if all(ax.codes[i] >= 0 for ax in every)))
253
+ for ax in every:
254
+ ax.take(keep)
255
+ cols, pos, kw, n = (*_rows(cols, pos, kw, keep, n), len(keep))
256
+ color = next((ax for ax in every if "color" in ax.aesthetics), None)
257
+ title = kw.pop("name", None)
258
+ items = cls.LEVELS == "items"
259
+ for ax in every if items else (): # per item, but colours
260
+ for a in ax.aesthetics:
261
+ if a != "color":
262
+ values = [ax.value(_Probe(cls, kw), a, lv) for lv in ax.levels]
263
+ kw[a] = (kernels.take(array.array("d", values), ax.codes) if a == "size"
264
+ else [values[c] for c in ax.codes])
265
+ s = cls._make(cols, pos, kw)
266
+ lv = s._lv = Levels(every, color)
267
+ if title is None and len(every) == 1:
268
+ title = every[0].title
269
+ if title is not None:
270
+ s.plot._legend_title = title
271
+ if items:
272
+ lv.parts.append((s, ("items", color) if color is not None else None, False))
273
+ token = object() # members drawn in data order
274
+ for rows, combo in () if items else _combos(every, n):
275
+ m = _part(s, *_rows(cols, pos, kw, rows, n), dict(zip(every, combo)),
276
+ str(combo[0]) if len(every) == 1 else None)
277
+ m._combo = combo
278
+ m.__dict__["_order_rows"] = (token, rows)
279
+ lv.parts.append((m, None if s.is_set("color") else ("of", s) if color is None
280
+ else ("axis", color, combo[every.index(color)]), False))
281
+ for ax in every if len(every) > 1 or items else (): # legend sections
282
+ for k, level in enumerate(ax.present):
283
+ if items:
284
+ one = array.array("q", [ax.codes.index(ax.levels.index(level))])
285
+ got = _rows(cols, pos, kw, one, n)
286
+ else:
287
+ m = next(p for p, _, only in lv.parts
288
+ if not only and p._combo[every.index(ax)] == level)
289
+ got = (m.cols, m._pos, m._given)
290
+ m = _part(s, *got, {ax: level}, str(level))
291
+ lv.parts.append((m, ("axis", ax, level) if ax is color else ("text",)
292
+ if color is not None else None if s.is_set("color")
293
+ else ("of", s), True))
294
+ if k == 0 and len(every) > 1:
295
+ lv.headers[id(m)] = Section(ax.title if ax.title is not None
296
+ else ax.aesthetics[0])
297
+ return s
298
+
299
+
300
+ def _rows(cols: Any, pos: dict[str, Any], kw: dict[str, Any], rows: Any, n: int
301
+ ) -> tuple[Any, dict[str, Any], dict[str, Any]]:
302
+ """The columns, data arguments and per-point keywords of ``rows`` (of ``n``)."""
303
+ from .layers.base import SPLIT_KEYS
304
+ from .tables import take
305
+ ck = cols.take(rows, n)
306
+ pk = {k: ck.args[k] if k in ck.args else (take(v, rows) if _length(v) == n else v)
307
+ for k, v in pos.items()}
308
+ kk = {k: (take(v, rows) if k in SPLIT_KEYS and _length(v) == n and not (
309
+ isinstance(v, tuple) and len(v) in (3, 4)) else v) for k, v in kw.items()}
310
+ return ck, pk, kk
311
+
312
+
313
+ class _Probe:
314
+ """The settings of a call before its series exists (level values at intake)."""
315
+
316
+ def __init__(self, cls: Any, kw: dict[str, Any]) -> None:
317
+ self.SETTINGS = cls.SETTINGS
318
+ self.kw = kw
319
+
320
+ def resolve(self, name: str) -> Any:
321
+ v = self.kw.get(name)
322
+ return None if v is None else self.SETTINGS[name].check(v)
323
+
324
+
325
+ def _combos(axes: list[LevelAxis], n: int) -> list[tuple[Any, tuple[Any, ...]]]:
326
+ """``(rows, levels)`` of each level combination with rows, in level order (rows by
327
+ a stable argsort of the combined codes)."""
328
+ from bisect import bisect_left, bisect_right
329
+ sizes = [len(ax.levels) for ax in axes]
330
+ code = array.array("d", axes[-1].codes)
331
+ stride = sizes[-1]
332
+ for ax, size in reversed(list(zip(axes[:-1], sizes[:-1]))):
333
+ c = ax.codes
334
+ for i in range(n):
335
+ code[i] += c[i] * stride
336
+ stride *= size
337
+ rows = kernels.argsort(code)
338
+ ordered = kernels.take(code, rows) if n else array.array("d")
339
+ out = []
340
+ for k in sorted(set(code)):
341
+ lo = bisect_left(ordered, k)
342
+ combo, rest = [], int(k)
343
+ for size in reversed(sizes):
344
+ rest, j = divmod(rest, size)
345
+ combo.append(j)
346
+ out.append((rows[lo:bisect_right(ordered, k, lo)],
347
+ tuple(ax.levels[j] for ax, j in zip(axes, reversed(combo)))))
348
+ return out
349
+
350
+
351
+ def _upward(name: str) -> bool:
352
+ """Whether ``name`` is a setting of the plot or the figure (a call's keyword that
353
+ went to them)."""
354
+ from .figure import Figure
355
+ from .plot import Plot
356
+ from .settings import canonical
357
+ return (name in Plot.CALL_EXTRA or canonical(name, Plot.SETTINGS) in Plot.SETTINGS
358
+ or canonical(name, Figure.SETTINGS) in Figure.SETTINGS)
359
+
360
+
361
+ def _part(s: Any, ck: Any, pk: dict[str, Any], kw: dict[str, Any],
362
+ levels: dict[LevelAxis, Any], name: str | None) -> Any:
363
+ """A call of ``s`` drawn apart (not in ``plot.series``): a level member, or a legend
364
+ section's entry (the axes not in ``levels`` at their defaults)."""
365
+ from .settings import canonical
366
+ own = type(s).SETTINGS
367
+ kk = {k: v for k, v in kw.items() # the plot's and figure's went to them
368
+ if canonical(k, own) in own or not _upward(k)}
369
+ for ax in s._lv.axes:
370
+ for a in ax.aesthetics:
371
+ if a != "color":
372
+ kk.pop(a, None)
373
+ if ax in levels:
374
+ kk[a] = ax.value(s, a, levels[ax])
375
+ if name is not None:
376
+ kk["name"] = name
377
+ m = object.__new__(type(s))
378
+ m.cols, m._of, m._pos, m._given = ck, s, pk, kk
379
+ m.__init__(**pk, **{**kk, "plot": s.plot})
380
+ return m
381
+
382
+
383
+ # -- render ---------------------------------------------------------------------------
384
+
385
+ def calls_of(series: Any) -> list[Any]:
386
+ """The calls a series is drawn as: its own, or its level members' and legend
387
+ sections'."""
388
+ lv = getattr(series, "_lv", None)
389
+ if lv is None:
390
+ return [series.layers()]
391
+ out = []
392
+ for part, spec, legend_only in lv.parts:
393
+ c = part.layers()
394
+ if legend_only:
395
+ keep = _key_call()(part)
396
+ keep.entries = c.entries
397
+ c = keep
398
+ head = lv.headers.get(id(part))
399
+ if head is not None:
400
+ c.entries = [head, *c.entries]
401
+ c.level = spec
402
+ if spec is not None:
403
+ c.color_count = 0
404
+ out.append(c)
405
+ return out
406
+
407
+
408
+ @functools.cache
409
+ def _key_call() -> Any:
410
+ from .layers.core import Call
411
+
412
+ class Key(Call):
413
+ """A legend section's entry: a call on no axis."""
414
+
415
+ def has_axis(self, role: str) -> bool:
416
+ return False
417
+ return Key
418
+
419
+
420
+ def level_color_count(calls: list[Any]) -> int:
421
+ """The plot's colours the level axes of ``calls`` take at most."""
422
+ seen: set[Any] = set()
423
+ n = 0
424
+ for c in calls:
425
+ spec = getattr(c, "level", None)
426
+ if spec is None:
427
+ continue
428
+ if spec[0] in ("axis", "items") and spec[1].key not in seen:
429
+ seen.add(spec[1].key)
430
+ n += len(spec[1].levels)
431
+ elif spec[0] == "of" and id(spec[1]) not in seen and not any(
432
+ d.series is spec[1] for d in calls):
433
+ seen.add(id(spec[1]))
434
+ n += 1
435
+ return n
436
+
437
+
438
+ def assign(plot: Any, c: Any, cycle: Any, i: int, done: dict[Any, Any]) -> int:
439
+ """The colours of a call with a colour spec (``c.level``) into the render; ``cycle``:
440
+ the plot's colours (in the series' colour variant); ``i``: the plot's next colour
441
+ (returned, advanced by the colours taken); ``done``: the series colours given in this
442
+ pass."""
443
+ from .spec import current
444
+ r = current()
445
+ assert r is not None
446
+ spec = c.level
447
+ kind = spec[0]
448
+ if kind == "text":
449
+ lg = getattr(plot, "legend", None)
450
+ col = lg.resolve("color") if lg is not None else None
451
+ r.colors[id(c.series)] = [col if col is not None else plot.resolve("text_color")]
452
+ return i
453
+ if kind == "of":
454
+ s = spec[1]
455
+ got = done.get(id(s))
456
+ if got is None:
457
+ got = done[id(s)] = [cycle[i % len(cycle)]]
458
+ i += 1
459
+ r.colors[id(c.series)] = got
460
+ return i
461
+ ax = spec[1]
462
+ owner = c.series._of if "_of" in c.series.__dict__ else c.series
463
+ group = r.levels_group.get(id(plot), id(plot))
464
+ store = r.levels.get((group, ax.key))
465
+ if store is None or (store[0] == id(plot) and (group, ax.key) not in done):
466
+ store = r.levels[(group, ax.key)] = (id(plot), dict[Any, Any]())
467
+ done[(group, ax.key)] = True
468
+ colors = store[1]
469
+ from .layers.pipeline import own_map
470
+ cm = own_map(owner)
471
+ by_level = cm if isinstance(cm, dict) else {}
472
+ own = None if cm is None or isinstance(cm, dict) else cm.colors(len(ax.levels))
473
+ for k, lv in enumerate(ax.levels):
474
+ if lv in colors:
475
+ continue
476
+ if lv in by_level:
477
+ colors[lv] = by_level[lv]
478
+ elif own is not None:
479
+ colors[lv] = own[k % len(own)]
480
+ else:
481
+ colors[lv] = cycle[i % len(cycle)]
482
+ i += 1
483
+ if kind == "items":
484
+ r.colors[id(c.series)] = [colors[lv] for lv in ax.levels]
485
+ else:
486
+ r.colors[id(c.series)] = [colors[spec[2]]]
487
+ return i
488
+
489
+
490
+ def facet_orders(data: Any, kw: dict[str, Any]) -> None:
491
+ """The ``level_order`` of a facet call: the levels of each level column over the
492
+ whole table, so that a level has one colour, marker… in every panel."""
493
+ from .tables import column, has_column
494
+ given = kw.get("level_order")
495
+ if given is not None and not isinstance(given, dict):
496
+ return
497
+ out = dict(given or {})
498
+ for name in AESTHETICS:
499
+ v = kw.get(name)
500
+ if not isinstance(v, str) or v in out or not has_column(data, v):
501
+ continue
502
+ values, order = column(data, v)
503
+ items = _items(values)
504
+ if order is None and not (items and any(isinstance(x, (str, bool)) for x in items)):
505
+ continue
506
+ ax = LevelAxis(name, values, v, order, None, kw.get("level_missing", "keep"))
507
+ out[v] = ax.levels
508
+ if out:
509
+ kw["level_order"] = out
plot_io/limits.py ADDED
@@ -0,0 +1,124 @@
1
+ """Limits: ``"auto"``, ``"lo/hi"`` strings (``min``, ``max``, numbers, ``q:F``), ``"-m/m"``
2
+ or pairs."""
3
+
4
+ import math
5
+ from collections.abc import Callable, Sequence
6
+ from typing import Any
7
+
8
+ from .times import axis_value
9
+ from .validators import typed
10
+
11
+ __all__ = ["check_limits", "resolve_limits", "symmetric", "SYMMETRIC"]
12
+
13
+ #: ``"-m/m"``: limits symmetric about the axis' ``center`` (or 0), ``m`` the farthest value
14
+ SYMMETRIC = (("m", -1.0), ("m", 1.0))
15
+
16
+
17
+ def symmetric(spec: Any) -> bool:
18
+ """Whether the limits ``spec`` is :data:`SYMMETRIC`."""
19
+ return isinstance(spec, tuple) and len(spec) == 2 and all(
20
+ isinstance(s, tuple) and len(s) == 2 and isinstance(s[0], str) and s[0] == "m"
21
+ for s in spec)
22
+
23
+
24
+ def _side(token: str) -> Any:
25
+ """One side of a limits string: a keyword (any case), a number or an ISO date."""
26
+ t = token.strip().lower()
27
+ if t in ("min", "max", "auto", ""):
28
+ return "auto" if t in ("auto", "") else t
29
+ if t in ("m", "-m"):
30
+ raise ValueError(f"invalid limit {token!r}: `m` is only in \"-m/m\" (symmetric "
31
+ f"about the centre)")
32
+ if t.startswith("q:"):
33
+ try:
34
+ q = float(t[2:])
35
+ except ValueError:
36
+ q = math.nan
37
+ if not 0 <= q <= 1:
38
+ raise ValueError(f"invalid limit {token!r}: `q:` takes a quantile from 0 to 1")
39
+ return ("q", q)
40
+ try:
41
+ v = float(t)
42
+ except ValueError:
43
+ try:
44
+ v = axis_value(token.strip())
45
+ except ValueError:
46
+ raise ValueError(f"invalid limit {token!r}") from None
47
+ if not math.isfinite(v):
48
+ raise ValueError(f"invalid limit {token!r}")
49
+ return v
50
+
51
+
52
+ @typed("str | Sequence[Any]")
53
+ def check_limits(v: Any) -> Any:
54
+ """Normalize a limits value to ``"auto"``, :data:`SYMMETRIC` (``"-m/m"``) or a pair of
55
+ sides (``None``/``"auto"``, a number, ``"min"``, ``"max"``, ``("q", F)``, or in a pair a
56
+ category ``("name", N)``)."""
57
+ if isinstance(v, str):
58
+ s = v.strip()
59
+ if s.lower() == "auto":
60
+ return "auto"
61
+ if "".join(s.lower().split()) == "-m/m":
62
+ return SYMMETRIC
63
+ parts = s.split("/")
64
+ if len(parts) != 2:
65
+ raise ValueError(f"expected 'lo/hi', got {v!r}")
66
+ return (_side(parts[0]), _side(parts[1]))
67
+ if symmetric(v):
68
+ return SYMMETRIC
69
+ if isinstance(v, (tuple, list)) and len(v) == 2:
70
+ out: list[Any] = []
71
+ for side in v:
72
+ if side is None:
73
+ out.append("auto")
74
+ elif isinstance(side, str):
75
+ try:
76
+ out.append(_side(side))
77
+ except ValueError: # a category name, read by the axis
78
+ out.append(("name", side))
79
+ elif isinstance(side, tuple) and len(side) == 2 and side[0] == "q":
80
+ out.append(_side(f"q:{side[1]!r}")) # already checked
81
+ else:
82
+ try:
83
+ x = axis_value(side) # a number or a date
84
+ except ValueError:
85
+ x = math.nan
86
+ if not math.isfinite(x):
87
+ raise ValueError(f"invalid limit {side!r}")
88
+ out.append(x)
89
+ return tuple(out)
90
+ raise ValueError(f"expected 'auto', 'lo/hi' or (lo, hi), got {v!r}")
91
+
92
+
93
+ def resolve_limits(spec: Any, lo: float, hi: float,
94
+ quantile: Callable[[float], float] | None = None) -> tuple[Any, Any, bool, bool]:
95
+ """``(lo, hi, lo_auto, hi_auto)`` from a limits spec and the data range; ``*_auto``
96
+ tells whether that side follows the data (and takes padding)."""
97
+ if symmetric(spec):
98
+ return lo, hi, False, False
99
+ sides = ("auto", "auto") if spec == "auto" else spec
100
+ out: list[Any] = []
101
+ autos: list[bool] = []
102
+ for side, data in zip(sides, (lo, hi)):
103
+ if side == "auto":
104
+ out.append(data)
105
+ autos.append(True)
106
+ elif side == "min":
107
+ out.append(lo)
108
+ autos.append(False)
109
+ elif side == "max":
110
+ out.append(hi)
111
+ autos.append(False)
112
+ elif isinstance(side, tuple):
113
+ out.append(quantile(side[1]) if quantile is not None else data)
114
+ autos.append(False)
115
+ else:
116
+ out.append(float(side))
117
+ autos.append(False)
118
+ return out[0], out[1], autos[0], autos[1]
119
+
120
+
121
+ def needs_quantiles(spec: Any) -> Sequence[float]:
122
+ if spec == "auto":
123
+ return ()
124
+ return [s[1] for s in spec if isinstance(s, tuple) and s[0] == "q"]
plot_io/lines.py ADDED
@@ -0,0 +1,42 @@
1
+ """Line styles: named dash patterns or dash lengths."""
2
+
3
+ from typing import Any
4
+
5
+ from .validators import Custom, length, one_of, typed
6
+
7
+ __all__ = ["line_style", "dash_of"]
8
+
9
+
10
+ _DASH_LENGTH = length(lo=0)
11
+
12
+
13
+ _STYLES = one_of("solid", "dashed", "dotted", "dashdot")
14
+
15
+
16
+ @typed("Literal['solid', 'dashed', 'dotted', 'dashdot'] | list[SupportsFloat | str] | "
17
+ "tuple[SupportsFloat | str, ...]")
18
+ def _line_style(v: Any) -> Any:
19
+ if isinstance(v, (list, tuple)):
20
+ return [_DASH_LENGTH(x) for x in v]
21
+ return _STYLES(v)
22
+
23
+
24
+ #: A line style: `solid`, `dashed`, `dotted`, `dashdot`, or dash lengths (px or any length
25
+ #: unit), alternately on and off.
26
+ line_style = Custom(_line_style, inner=_DASH_LENGTH)
27
+
28
+ _DASHES = {"solid": None, "dashed": (3.7, 1.6), "dotted": (1.0, 1.65),
29
+ "dashdot": (6.4, 1.6, 1.0, 1.6)}
30
+
31
+
32
+ def dash_of(style: Any, width: float) -> list[float] | None:
33
+ """Dash lengths (px) of a line style, scaled by the line width."""
34
+ if style is None:
35
+ return None
36
+ if isinstance(style, (list, tuple)):
37
+ return [float(x) for x in style] or None
38
+ pattern = _DASHES[style]
39
+ if pattern is None:
40
+ return None
41
+ w = max(width, 0.75)
42
+ return [x * w for x in pattern]