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/mapped.py ADDED
@@ -0,0 +1,522 @@
1
+ """Color and size axes: the scales of values drawn as colors and as sizes.
2
+
3
+ Numbers given as colors (per point, per cell, per category) or as sizes go through an axis,
4
+ as positions do: its scale and limits take them to [0, 1], then a gradient (a
5
+ :class:`~plot_io.ColorMap`) or a range of sizes (a :class:`~plot_io.SizeMap`) gives the color
6
+ or the diameter. :class:`ColorAxis` and :class:`SizeAxis` are axes like ``plot.x_axis``
7
+ (scale, limits, ticks, number format, label); a colorbar and a size key are their guides.
8
+
9
+ * A series that maps values has axes of its own (``series.color_axis``,
10
+ ``series.size_axis``), configured by its ``color_*`` and ``size_*`` settings
11
+ (``color_limits`` is the axis' ``limits``, ``color_scale`` its ``scale``, ``color_map``
12
+ its gradient).
13
+ * A shared axis (``figure.color_axis(…)``, ``figure.size_axis(…)``) is given to series as
14
+ ``color_axis=`` or ``size_axis=``; it holds the gradient (or sizes), the limits and the
15
+ scale, and its automatic limits cover every series using it.
16
+ * The figure's ``share_color`` (``share_size``) trains the own color (size) axes of the
17
+ plots of a group together, as ``share_x`` does for x axes.
18
+
19
+ :func:`train` resolves every axis used in a layout (scale, then limits over its values)
20
+ into an :class:`~plot_io.axis.AxisState`, kept in the render (``Render.scales``), and
21
+ groups the series of each trained size axis into one size key (``Render.size_keys``).
22
+ """
23
+
24
+ import math
25
+ from array import array
26
+ from collections.abc import Callable
27
+ from typing import Any
28
+
29
+ from ._methods import ColorAxisMixin, SizeAxisMixin
30
+ from . import kernels
31
+ from .axis import Axis, AxisState
32
+ from .color.maps import MAP_WORDS, ColorMap, check_color_map, chosen_map, kind_settings
33
+ from .scales import NUMERIC_SCALES
34
+ from .settings import (DECLINE, Setting, Settings, cascade, given_rule, number, one_of,
35
+ table)
36
+ from .spec import current
37
+ from .values import check_size_map as _check_size_map
38
+ from .validators import typed
39
+
40
+ __all__ = ["ColorAxis", "SizeAxis", "color_axis_of", "size_axis_of", "shared_axis", "own_axis",
41
+ "group_axes", "train", "trained", "SizeGroup", "size_keys", "size_group",
42
+ "colors_of", "unit_of", "check_color_axis", "check_size_axis"]
43
+
44
+
45
+ def _scale_setting(what: str) -> Setting:
46
+ return Setting(one_of(*NUMERIC_SCALES),
47
+ doc=f"Scale of the {what}: `linear`, `log` (base 10), `ln` (base e), "
48
+ "`log2`, `log10`, `sqrt`, `exp`, `symlog` or `logit`; default "
49
+ "`linear`.")
50
+
51
+
52
+ def _from_series(name: str, key: str | None = None) -> Callable[[Any], Any]:
53
+ """Rule of an own axis' setting ``name``: the series' ``key`` (default ``color_<name>``,
54
+ ``size_<name>``), when it has one."""
55
+ @given_rule
56
+ def rule(axis: "_MappedAxis") -> Any:
57
+ s = axis.series
58
+ nonlocal key
59
+ if key is None:
60
+ key = f"{axis.PREFIX}_{name}"
61
+ if s is None or key not in s.SETTINGS:
62
+ return DECLINE
63
+ v = s._raw(key)
64
+ return DECLINE if v is None else v
65
+ return rule
66
+
67
+
68
+ class _MappedAxis(Axis):
69
+ """An axis of mapped values: a series' own (``series``) or shared (owned by a figure)."""
70
+
71
+ ROLE = "c"
72
+ PREFIX = "color"
73
+
74
+ def __init__(self, figure: Any = None, **settings: Any) -> None:
75
+ self.series: Any = None
76
+ super().__init__(figure, self.ROLE)
77
+ if settings:
78
+ self.set(**settings)
79
+
80
+ @classmethod
81
+ def own(cls, series: Any) -> Any:
82
+ """The own axis of ``series`` (its parent: the series' settings flow into it)."""
83
+ axis = cls.__new__(cls)
84
+ axis.series = series
85
+ Axis.__init__(axis, series, cls.ROLE)
86
+ return axis
87
+
88
+ def __repr__(self) -> str:
89
+ owner = "shared" if self.series is None else repr(self.series)
90
+ return f"{type(self).__name__}({owner})"
91
+
92
+ def _box(self, kind: str, name: str) -> float:
93
+ if kind in ("across", "along"): # of the guide drawing it (a colorbar)
94
+ r = current()
95
+ boxes = r.boxes.get(id(self)) if r is not None else None
96
+ vertical = bool(boxes and boxes.get("vertical"))
97
+ kind = "axes_width" if (kind == "across") == vertical else "axes_height"
98
+ return Settings._box(self, kind, name)
99
+
100
+
101
+ class ColorAxis(ColorAxisMixin, _MappedAxis):
102
+ """The axis of values drawn as colors: scale, limits, ticks and number format, and the
103
+ gradient (``color_map``). ``figure.color_axis(…)`` makes a shared one, given to series
104
+ as ``color_axis=``; a colorbar is its guide.
105
+
106
+ A series that maps values has its own color axis (``series.color_axis``): its ``scale``
107
+ is the series' ``color_scale``, its ``limits`` the series' ``color_limits``, its gradient
108
+ the series' ``color_map``; a ``scale`` or ``limits`` set on the axis itself wins. A
109
+ shared axis covers every series using it, and those take no ``color_map``,
110
+ ``color_limits`` or ``color_scale`` of their own (an error); one made alone
111
+ (``plt.ColorAxis()``) joins the figure of its first series. The members of a ``group=``
112
+ call share one. The figure's ``share_color`` makes the own axes of each group of plots
113
+ one (one ``color_limits`` or scale for the group; two different ones are an error),
114
+ each series keeping its gradient. Training: before the layout, limits come from the
115
+ values as for x and y axes (``limits``, quantiles, ``include_zero``), without padding
116
+ unless ``padding`` is set. Values past the limits take the gradient's ``under`` and
117
+ ``over`` colors, missing values and values outside the scale's domain its ``bad`` color
118
+ (the latter with a warning).
119
+ """
120
+
121
+ SETTINGS = table(**{**Axis.SETTINGS, "scale": _scale_setting("mapped values")},
122
+ color_map=Setting(check_color_map,
123
+ doc="The gradient: a `ColorMap`, a map name or "
124
+ "colors (alias `cmap`), `\"auto\"` "
125
+ "(`sequential_map`, or `diverging_map` about a "
126
+ "`center`) or a kind (that kind's map)."),
127
+ center=Setting(number(),
128
+ doc="The value at the map's middle; the limits "
129
+ "symmetric about it unless given; the colorbar "
130
+ "marks it."),
131
+ **kind_settings())
132
+ RULES = {"limits": _from_series("limits"), "scale": _from_series("scale"),
133
+ "color_map": _from_series("map"), "center": _from_series("center", "center")}
134
+
135
+ def color_map(self) -> ColorMap:
136
+ """The gradient values are drawn with (in the axis' color variant): with a
137
+ ``center``, its middle at the centre (a part of it when the limits are given
138
+ unevenly about the centre)."""
139
+ center = self.resolve("center")
140
+ v = self.resolve("color_map")
141
+ if not isinstance(v, ColorMap) and not (isinstance(v, str) and v in MAP_WORDS):
142
+ v = "auto" # a series' colors by level, or its one color
143
+ cm = chosen_map(self, v, "sequential" if center is None else "diverging")
144
+ if center is None:
145
+ return cm
146
+ r = current()
147
+ st = r.scales.get(id(self)) if r is not None else None
148
+ if st is None or st.scale is None:
149
+ return cm
150
+ fc = st.scale.f(center)
151
+ d = max(fc - st.flo, st.fhi - fc)
152
+ if not (math.isfinite(fc) and d > 0):
153
+ return cm
154
+ a, b = (st.flo - fc + d) / (2 * d), (st.fhi - fc + d) / (2 * d)
155
+ if a <= 1e-12 and b >= 1 - 1e-12:
156
+ return cm
157
+ return cm.truncated(max(a, 0.0), min(b, 1.0))
158
+
159
+
160
+
161
+
162
+ class SizeAxis(SizeAxisMixin, _MappedAxis):
163
+ """The axis of values drawn as marker sizes: scale, limits, the values and number
164
+ format of its size key (``ticks``, ``tick_format``), its title (``label``), and the
165
+ sizes (``size_map``). ``figure.size_axis(…)`` makes a shared one, given to series as
166
+ ``size_axis=``; a size key is its guide.
167
+
168
+ As :class:`ColorAxis`, for sizes: the series' own (``series.size_axis``: ``size_scale``,
169
+ ``size_limits``, ``size_map``), shared by ``figure.size_axis()``, or made one per group by
170
+ the figure's ``share_size``, which also gives the group one size key beside its plots.
171
+ """
172
+
173
+ ROLE = "s"
174
+ PREFIX = "size"
175
+
176
+ SETTINGS = table(**{**Axis.SETTINGS, "scale": _scale_setting("mapped values")},
177
+ size_map=Setting(_check_size_map,
178
+ doc="The sizes: a `SizeMap` or a pair of diameters; "
179
+ "default `SizeMap()`."))
180
+ RULES = {"limits": _from_series("limits"), "scale": _from_series("scale"),
181
+ "size_map": _from_series("map")}
182
+
183
+ def _levels(self, name: str) -> tuple[tuple[str, ...], ...]:
184
+ return (tuple(c for level in Settings._levels(self, name) for c in level),)
185
+
186
+ def size_map(self) -> Any:
187
+ """The sizes values are drawn with."""
188
+ from .sizemaps import DEFAULT
189
+ m = self.resolve("size_map")
190
+ return DEFAULT if m is None else m
191
+
192
+
193
+ @typed("ColorAxis")
194
+ def check_color_axis(v: Any) -> Any:
195
+ if isinstance(v, ColorAxis):
196
+ return v
197
+ raise ValueError(f"expected a ColorAxis (figure.color_axis(…)), got {v!r}")
198
+
199
+
200
+ @typed("SizeAxis")
201
+ def check_size_axis(v: Any) -> Any:
202
+ if isinstance(v, SizeAxis):
203
+ return v
204
+ raise ValueError(f"expected a SizeAxis (figure.size_axis(…)), got {v!r}")
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # The axis of a series
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def own_axis(series: Any, cls: type) -> Any:
212
+ """The own axis of class ``cls`` of ``series``, made on first use."""
213
+ key = f"_{cls.PREFIX}_axis" # type: ignore[attr-defined]
214
+ axis = series.__dict__.get(key)
215
+ if axis is None:
216
+ axis = cls.own(series) # type: ignore[attr-defined]
217
+ series.__dict__[key] = axis
218
+ return axis
219
+
220
+
221
+ def shared_axis(series: Any, what: str) -> Any:
222
+ """The shared axis given as ``color_axis=`` (``size_axis=``), or ``None``."""
223
+ name = f"{what}_axis"
224
+ if name not in series.SETTINGS:
225
+ return None
226
+ axis = series.resolve(name)
227
+ if axis is not None:
228
+ given = [n for n in (f"{what}_map", f"{what}_limits", f"{what}_scale")
229
+ if series.is_set(n)]
230
+ if given:
231
+ raise ValueError(f"{series.kind}: {', '.join(given)} belong to the shared "
232
+ f"{what} axis given as {name}= (set them there)")
233
+ return axis
234
+
235
+
236
+ def color_axis_of(series: Any) -> ColorAxis:
237
+ """The color axis numbers in the series' colors go through: the shared one given as
238
+ ``color_axis=``, else the series' own."""
239
+ axis = shared_axis(series, "color")
240
+ return axis if axis is not None else own_axis(series, ColorAxis)
241
+
242
+
243
+ def size_axis_of(series: Any, name: str = "size") -> SizeAxis | None:
244
+ """The size axis of a size setting holding values to map, or ``None`` (sizes in px):
245
+ a density always maps; numbers per point map with a ``size_map`` or a ``size_axis``."""
246
+ from .values import is_computed
247
+ if name not in series.SETTINGS or "size_map" not in series.SETTINGS:
248
+ return None
249
+ v = series.resolve(name)
250
+ computed = is_computed(v)
251
+ if not computed and isinstance(v, (int, float)):
252
+ return None
253
+ axis = shared_axis(series, "size")
254
+ if axis is not None:
255
+ return axis
256
+ if computed or series.resolve("size_map") is not None:
257
+ return own_axis(series, SizeAxis)
258
+ return None
259
+
260
+
261
+ def _maps_colors(v: Any) -> bool:
262
+ from .values import Values, is_computed, paint
263
+ if v is None:
264
+ return False
265
+ try:
266
+ out = paint(v)
267
+ except (TypeError, ValueError):
268
+ return False
269
+ return isinstance(out, Values) or is_computed(out)
270
+
271
+
272
+ #: Keywords of a call that configure its color or size axis, and the axis' names for them.
273
+ _AXIS_KEYS = {"color": {"color_map": "color_map", "color_limits": "limits",
274
+ "color_scale": "scale"},
275
+ "size": {"size_map": "size_map", "size_limits": "limits", "size_scale": "scale"}}
276
+
277
+
278
+ def group_axes(cls: type, kw: dict[str, Any]) -> None:
279
+ """The keywords of a ``group=`` call, given shared color and size axes where the call
280
+ maps values: its members are one call, on one axis each (the ``color_*``/``size_*``
281
+ keywords go to the axes)."""
282
+ settings = getattr(cls, "SETTINGS", {})
283
+ from .layers.paint import COLOR_SETTINGS
284
+ from .values import is_computed
285
+ for what, axis_cls in (("color", ColorAxis), ("size", SizeAxis)):
286
+ name = f"{what}_axis"
287
+ if name not in settings or kw.get(name) is not None:
288
+ continue
289
+ keys = _AXIS_KEYS[what]
290
+ if what == "color":
291
+ maps = any(_maps_colors(kw.get(k)) for k in COLOR_SETTINGS)
292
+ else:
293
+ v = kw.get("size")
294
+ density = is_computed(v) or (isinstance(v, str) and v.strip().lower() == "density")
295
+ maps = density or (kw.get("size_map") is not None and v is not None
296
+ and not isinstance(v, (int, float, str)))
297
+ if maps:
298
+ kw[name] = axis_cls(**{to: kw.pop(k) for k, to in keys.items()
299
+ if kw.get(k) is not None})
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Training
304
+ # ---------------------------------------------------------------------------
305
+
306
+ class _Values:
307
+ """Values on a mapped axis, read by its training as a call's values are by an x axis."""
308
+
309
+ __slots__ = ("data",)
310
+ SETTINGS: dict[str, Any] = {}
311
+
312
+ def __init__(self, data: Any) -> None:
313
+ self.data = data
314
+
315
+ def has_axis(self, role: str) -> bool:
316
+ return True
317
+
318
+ def categories(self, role: str) -> None:
319
+ return None
320
+
321
+ def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
322
+ return kernels.extent(self.data, scale.function)
323
+
324
+ def values(self, role: str) -> Any:
325
+ return array("d", [v for v in kernels.doubles(self.data) if math.isfinite(v)])
326
+
327
+ def sticky(self, role: str) -> list[float]:
328
+ return []
329
+
330
+ def zero_default(self, role: str) -> bool:
331
+ return False
332
+
333
+ def zero_padding(self, role: str) -> bool:
334
+ return False
335
+
336
+ def inverts(self, role: str) -> bool:
337
+ return False
338
+
339
+
340
+ def _explicit(axis: Any, name: str) -> bool:
341
+ """Whether an axis' setting is given (on it, or by its series' settings)."""
342
+ return cascade(axis, name)[2] in ("own", "rule")
343
+
344
+
345
+ def train(fig: Any, areas: list[Any]) -> None:
346
+ """Train every color and size axis of the figure's layout ``areas``: the axes of the
347
+ calls' mapped values, those of colorbars and size legends, the own color (size) axes of
348
+ plots sharing them (``share_color``, ``share_size``) together; into the render's
349
+ ``scales``. Then the size keys of the trained size axes (``size_keys``)."""
350
+ from .colorbar import Colorbar
351
+ from .layers.pipeline import plot_calls
352
+ from .legend import SizeLegend
353
+ from .plot import Plot
354
+ from . import sharing
355
+ r = current()
356
+ assert r is not None
357
+ r.scales.clear()
358
+ axes: dict[int, Any] = {}
359
+ values: dict[int, list[Any]] = {}
360
+ plot_of: dict[int, Any] = {} # own axes: the plot of their series
361
+
362
+ def use(axis: Any, v: Any = None, plot: Any = None) -> None:
363
+ axes.setdefault(id(axis), axis)
364
+ vs = values.setdefault(id(axis), [])
365
+ if callable(v): # values counted for the placed axes
366
+ v = v()
367
+ if v is not None:
368
+ vs.append(v)
369
+ if plot is not None and axis.series is not None:
370
+ plot_of[id(axis)] = plot
371
+
372
+ for a in areas:
373
+ if isinstance(a.obj, Plot):
374
+ for p in [a.obj, *a.obj.twins]:
375
+ for c in plot_calls(p):
376
+ for axis, v in c.color_sources:
377
+ use(axis, v, a.obj)
378
+ for axis, v in c.size_sources:
379
+ use(axis, v, a.obj)
380
+ for cb in a.obj.colorbars:
381
+ use(cb.axis)
382
+ elif isinstance(a.obj, Colorbar):
383
+ use(a.obj.axis)
384
+ elif isinstance(a.obj, SizeLegend):
385
+ use(a.obj.size_axis)
386
+ for cb in fig.colorbars:
387
+ use(cb.axis)
388
+ root = {k: k for k in axes}
389
+
390
+ def find(k: int) -> int:
391
+ while root[k] != k:
392
+ k = root[k]
393
+ return k
394
+ r.levels.clear()
395
+ r.levels_group.clear()
396
+ for role, cls in (("color", ColorAxis), ("size", SizeAxis)):
397
+ for members, _ in sharing.groups(fig, areas, role):
398
+ in_group = {id(m.obj) for m in members}
399
+ if role == "color":
400
+ r.levels_group.update((k, id(members[0].obj)) for k in in_group)
401
+ ids = [k for k, p in plot_of.items()
402
+ if id(p) in in_group and isinstance(axes[k], cls)]
403
+ for k in ids[1:]:
404
+ ra, rb = find(ids[0]), find(k)
405
+ if ra != rb:
406
+ root[rb] = ra
407
+ grouped: dict[int, list[int]] = {}
408
+ for k in axes:
409
+ grouped.setdefault(find(k), []).append(k)
410
+ for ids in grouped.values():
411
+ members = [axes[k] for k in ids]
412
+ lead = members[0]
413
+ if len(members) > 1:
414
+ role = "size" if isinstance(lead, SizeAxis) else "color"
415
+ lead = members[sharing.leader(members, role, _explicit)]
416
+ st = AxisState(lead)
417
+ st.resolve([_Values(v) for k in ids for v in values[k]], False)
418
+ for k in ids:
419
+ r.scales[k] = st
420
+ r.size_keys = size_keys(areas)
421
+
422
+
423
+ class SizeGroup:
424
+ """The series of one trained size axis and one size map, across a layout: they have
425
+ one size key. ``axis``: the first one's size axis; ``users``: every series; ``listed``:
426
+ those whose ``size_key`` is ``"legend"``; ``plot``: the plot whose legend shows the
427
+ key (``None``: a size legend area shows it, or no series is listed)."""
428
+
429
+ __slots__ = ("axis", "users", "listed", "plot")
430
+
431
+ def __init__(self, axis: Any) -> None:
432
+ self.axis = axis
433
+ self.users: list[Any] = []
434
+ self.listed: list[Any] = []
435
+ self.plot: Any = None
436
+
437
+
438
+ def _size_key(axis: Any) -> tuple[int, str] | None:
439
+ r = current()
440
+ st = r.scales.get(id(axis)) if r is not None else None
441
+ return None if st is None else (id(st), repr(axis.size_map()))
442
+
443
+
444
+ def size_keys(areas: list[Any]) -> dict[Any, SizeGroup]:
445
+ """The size keys of a layout, by trained axis and size map. Series of a shared size axis
446
+ (one axis, or own axes trained as one by ``share_size``) in several plots have one key,
447
+ in the legend of the rightmost of those plots (the topmost of those); a size legend
448
+ area showing the axis takes it out of the plot legends."""
449
+ from .legend import SizeLegend
450
+ from .plot import Plot
451
+ out: dict[Any, SizeGroup] = {}
452
+ place: dict[Any, tuple[int, int]] = {}
453
+ for a in areas:
454
+ if not isinstance(a.obj, Plot):
455
+ continue
456
+ for p in [a.obj, *a.obj.twins]:
457
+ for s in p.series:
458
+ axis = size_axis_of(s)
459
+ key = None if axis is None else _size_key(axis)
460
+ if key is None:
461
+ continue
462
+ g = out.get(key)
463
+ if g is None:
464
+ g = out[key] = SizeGroup(axis)
465
+ g.users.append(s)
466
+ if "size_key" not in s.SETTINGS or s.resolve("size_key") != "legend":
467
+ continue
468
+ g.listed.append(s)
469
+ rank = (-a.c1, a.r0)
470
+ if g.plot is None or rank < place[key]:
471
+ g.plot, place[key] = p, rank
472
+ for a in areas:
473
+ if isinstance(a.obj, SizeLegend):
474
+ g = out.get(_size_key(a.obj.size_axis))
475
+ if g is not None:
476
+ g.plot = None
477
+ return out
478
+
479
+
480
+ def trained(axis: Any) -> AxisState:
481
+ """The trained state of a color or size axis in the render in progress."""
482
+ r = current()
483
+ st = r.scales.get(id(axis)) if r is not None else None
484
+ if st is None:
485
+ raise ValueError(f"{axis!r} maps no values of the figure being drawn (values of "
486
+ "this setting are not mapped by its series)")
487
+ return st
488
+
489
+
490
+ def size_group(axis: Any) -> SizeGroup | None:
491
+ """The size key group of a trained size axis in the render in progress."""
492
+ r = current()
493
+ key = _size_key(axis)
494
+ return None if key is None or r is None else r.size_keys.get(key)
495
+
496
+
497
+ def unit_of(axis: Any) -> Callable[[float], float]:
498
+ """``v -> t``: a value's position on the trained axis (0 and 1 at the limits)."""
499
+ st = trained(axis)
500
+ assert st.scale is not None
501
+ f, flo = st.scale.f, st.flo
502
+ span = (st.fhi - st.flo) or 1.0
503
+ return lambda v: (f(v) - flo) / span
504
+
505
+
506
+ def colors_of(axis: Any, values: Any) -> Any:
507
+ """Colors of many values through a trained color axis (an ``(n, 4)`` buffer); values
508
+ outside the scale's domain (0 on a log scale) take the map's ``bad`` color, as missing
509
+ ones, with a warning naming their count."""
510
+ st = trained(axis)
511
+ assert st.scale is not None
512
+ if st.scale.function is not None:
513
+ outside = kernels.extent(values)[0] - kernels.extent(values, st.scale.function)[0]
514
+ if outside:
515
+ import warnings
516
+ warnings.warn(f"{outside} color value{'s' if outside > 1 else ''} outside the "
517
+ f"{st.scale.name} scale's domain: drawn in the color map's bad "
518
+ "color", stacklevel=2)
519
+ span = st.fhi - st.flo
520
+ k = 1.0 / span if span else 0.0
521
+ ts = kernels.scaled(values, k, -st.flo * k, st.scale.function, **st.scale.options)
522
+ return axis.color_map().colors_at(ts)
plot_io/markers.py ADDED
@@ -0,0 +1,110 @@
1
+ """Marker shapes: unit paths centred on the origin (y down) and their areas.
2
+
3
+ vec_io draws markers from paths; the names live here. The paths of ``square``,
4
+ ``diamond``, ``triangle_up``, ``triangle_down``, ``plus`` and ``cross`` are those vec_io
5
+ 0.3 bundled.
6
+ """
7
+
8
+ import math
9
+ import re
10
+ from typing import Any
11
+
12
+ from vec_io import Path
13
+ from vec_io.geometry import ellipse_path, parse_path_data
14
+
15
+ from .settings import MARKER_ALIASES
16
+
17
+ __all__ = ["MARKERS", "add_marker", "resolve", "marker_area"]
18
+
19
+ _S3 = math.sqrt(3.0)
20
+ _T = f"{_S3 / 3!r}"
21
+ _H = f"{_S3 / 6!r}"
22
+
23
+ _DATA = {
24
+ "square": "M-0.5 -0.5 H0.5 V0.5 H-0.5 Z",
25
+ "diamond": "M0 -0.5 L0.5 0 L0 0.5 L-0.5 0 Z",
26
+ "triangle_up": f"M0 -{_T} L0.5 {_H} L-0.5 {_H} Z",
27
+ "triangle_down": f"M0 {_T} L0.5 -{_H} L-0.5 -{_H} Z",
28
+ "triangle_left": f"M-{_T} 0 L{_H} -0.5 L{_H} 0.5 Z",
29
+ "triangle_right": f"M{_T} 0 L-{_H} 0.5 L-{_H} -0.5 Z",
30
+ "plus": "M-0.5 -0.15 H-0.15 V-0.5 H0.15 V-0.15 H0.5 V0.15 H0.15 V0.5 H-0.15 V0.15 H-0.5 Z",
31
+ "cross": "M-0.5 -0.29 L-0.29 -0.5 L0 -0.21 L0.29 -0.5 L0.5 -0.29 L0.21 0 L0.5 0.29 "
32
+ "L0.29 0.5 L0 0.21 L-0.29 0.5 L-0.5 0.29 L-0.21 0 Z",
33
+ }
34
+
35
+ #: Exact areas of the unit shapes (fractions of the unit box).
36
+ _AREAS = {
37
+ "circle": math.pi / 4,
38
+ "square": 1.0,
39
+ "diamond": 0.5,
40
+ "triangle_up": _S3 / 4,
41
+ "triangle_down": _S3 / 4,
42
+ "triangle_left": _S3 / 4,
43
+ "triangle_right": _S3 / 4,
44
+ "plus": 0.51,
45
+ "cross": 0.5754,
46
+ }
47
+
48
+ #: Registered shapes: name → (path, area).
49
+ MARKERS: dict[str, tuple[Path, float]] = {"circle": (ellipse_path(0, 0, 0.5, 0.5), math.pi / 4)}
50
+ MARKERS.update((name, (parse_path_data(d), _AREAS[name])) for name, d in _DATA.items())
51
+
52
+ _ALIASES = MARKER_ALIASES
53
+
54
+
55
+ _PATH_DATA = re.compile(r"^\s*[Mm]\s*[-+.\d]")
56
+
57
+
58
+ def _is_path_data(value: Any) -> bool:
59
+ return isinstance(value, str) and _PATH_DATA.match(value) is not None
60
+
61
+
62
+ def _name(name: str) -> str:
63
+ key = name.strip().lower().replace("-", "_")
64
+ return _ALIASES.get(key, key)
65
+
66
+
67
+ def add_marker(name: str, path: Any) -> None:
68
+ """Add a marker shape: a ``vec_io.Path`` or SVG path data in a unit box centred on the
69
+ origin. Its area is measured from the outline."""
70
+ p = _as_path(path)
71
+ MARKERS[_name(name)] = (p, float(p.area()))
72
+
73
+
74
+ def _as_path(path: Any) -> Path:
75
+ if isinstance(path, Path):
76
+ return path
77
+ if _is_path_data(path):
78
+ return parse_path_data(path)
79
+ raise ValueError(f"expected a vec_io Path or path data, got {path!r}")
80
+
81
+
82
+ def resolve(marker: Any) -> tuple[Path, float]:
83
+ """``(path, area)`` of a marker name, ``Path`` or path data."""
84
+ if isinstance(marker, str) and not _is_path_data(marker):
85
+ try:
86
+ return MARKERS[_name(marker)]
87
+ except KeyError:
88
+ names = ", ".join(sorted(MARKERS))
89
+ raise ValueError(f"unknown marker {marker!r} (use {names}, a Path or path data)") \
90
+ from None
91
+ p = _as_path(marker)
92
+ return p, float(p.area())
93
+
94
+
95
+ def check_marker(marker: Any) -> Any:
96
+ resolve(marker)
97
+ if isinstance(marker, str) and not _is_path_data(marker):
98
+ return _name(marker)
99
+ return marker
100
+
101
+
102
+ def marker_area(marker: Any) -> float:
103
+ """Area of a marker's unit shape (fraction of its unit box)."""
104
+ return resolve(marker)[1]
105
+
106
+
107
+ def area_factor(marker: Any) -> float:
108
+ """Size factor that gives ``marker`` the area of a circle of the same size."""
109
+ a = marker_area(marker)
110
+ return math.sqrt((math.pi / 4) / a) if a > 0 else 1.0