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/legend.py ADDED
@@ -0,0 +1,605 @@
1
+ """Legend layout and drawing: one entry per named series."""
2
+
3
+ import math
4
+ from typing import Any
5
+
6
+ from vec_io import Path
7
+ from vec_io.arrays import doubles
8
+
9
+ from .canvas import Canvas
10
+ from ._methods import SizeLegendMixin
11
+ from .color.values import resolve_color, with_alpha
12
+ from .settings import Setting, Settings, table, text
13
+ from .validators import length
14
+ from .shapes import swatch
15
+ from .text import Font, draw_run, font_of, measure, run
16
+
17
+ __all__ = ["Entries", "SizeRows", "SizeKey", "size_key_values"]
18
+
19
+ def size_key_values(axis: Any, given: Any = None) -> list[float]:
20
+ """Values shown in the size key of a trained size axis: ``given``, else the axis'
21
+ ``ticks``, else 3–5 nice numbers inside its limits."""
22
+ from .mapped import trained
23
+ from .ticks import numeric_ticks
24
+ if isinstance(given, (list, tuple)):
25
+ return [float(v) for v in given]
26
+ ticks = axis.resolve("ticks")
27
+ if isinstance(ticks, list):
28
+ return [float(v) for v in ticks]
29
+ st = trained(axis)
30
+ scale = st.scale
31
+ assert scale is not None
32
+ lo, hi = sorted((st.lo, st.hi))
33
+ eps = (hi - lo) * 1e-9
34
+ vals: list[float] = []
35
+ for most in (4, 5, 6, 8):
36
+ tk, _ = numeric_ticks(scale, lo, hi, most, minor=False, length=133.0)
37
+ vals = [t.value for t in tk if t.major and lo - eps <= t.value <= hi + eps
38
+ and (t.value > 0 or scale.name != "linear" or lo <= 0)]
39
+ if len(vals) >= 3:
40
+ break
41
+ return vals if len(vals) >= 2 else [lo, hi]
42
+
43
+
44
+ def size_key_labels(axis: Any, values: list[float], plot: Any) -> list[Any]:
45
+ """The labels of a size key's values: the axis' ``tick_format`` and number format."""
46
+ from .numbers import NumberStyle, format_numbers
47
+ fmt = axis.resolve("tick_format")
48
+ if fmt == "auto" and plot is not None:
49
+ return format_numbers(values, "auto", plot)
50
+ return format_numbers(values, fmt, NumberStyle.of(axis), "tick_format")
51
+
52
+
53
+ def _named(label: Any) -> bool:
54
+ """Whether a legend label has text (``name=""`` makes no entry)."""
55
+ return not (label is None or (isinstance(label, (str, list, tuple)) and not label))
56
+
57
+
58
+ class SizeKey:
59
+ """Entries of a size key: a header (the size axis' label) and sample markers."""
60
+
61
+ header = False
62
+
63
+ def __init__(self, axis: Any, series: list[Any], values: list[float], labels: list[Any],
64
+ title: Any) -> None:
65
+ self.axis = axis
66
+ self.series = series
67
+ self.values = values
68
+ self.labels = labels
69
+ self.title = title
70
+
71
+ def sizes(self) -> list[float]:
72
+ from .markers import area_factor
73
+ from .mapped import trained
74
+ st = trained(self.axis)
75
+ assert st.scale is not None
76
+ s = self.series[0]
77
+ d = self.axis.size_map().map(doubles(self.values), st.flo, st.fhi,
78
+ st.scale.function, s)
79
+ marker = s.resolve("marker") if "marker" in s.SETTINGS else "circle"
80
+ marker = marker[0] if isinstance(marker, list) else marker
81
+ factor = area_factor(marker) if s.resolve("size_basis") == "area" else 1.0
82
+ return [v * factor if v == v else 0.0 for v in d]
83
+
84
+ def glyph(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
85
+ from .markers import resolve
86
+ s = self.series[0]
87
+ size = self.sizes()[i]
88
+ if not size:
89
+ return
90
+ marker = s.resolve("marker") if "marker" in s.SETTINGS else "circle"
91
+ marker = marker[0] if isinstance(marker, list) else marker
92
+ from .values import Values, is_computed
93
+ mapped = any(isinstance(s.resolve(n), Values) or is_computed(s.resolve(n))
94
+ for n in ("color", "edge_color") if n in s.SETTINGS)
95
+ if len(self.series) == 1 and not mapped:
96
+ from .layers.paint import MarkerStyle, base_color
97
+ st = MarkerStyle(s, ctx, "", base_color(s, ctx), 1, True)
98
+ fill, stroke, lw = st.first_color("fill"), st.first_color("stroke"), st.line_width
99
+ if fill is None and stroke is None:
100
+ fill = base_color(s, ctx)
101
+ else:
102
+ fill, stroke, lw = "#888888", None, None
103
+ ctx.canvas.draw_markers([x + w / 2], [y + h / 2], marker=resolve(marker)[0],
104
+ size=size, fill=fill, stroke=stroke, stroke_width=lw)
105
+
106
+
107
+ def group_key(g: Any, plot: Any) -> SizeKey:
108
+ """The size key of size group ``g`` (:class:`plot_io.mapped.SizeGroup`), its labels
109
+ formatted as ``plot``'s numbers."""
110
+ members, axis = g.listed, g.axis
111
+ values = size_key_values(axis)
112
+ labels = size_key_labels(axis, values, plot)
113
+ title = axis.resolve("label")
114
+ if title is None:
115
+ title = next((s.columns["size"] for s in members
116
+ if "size" in getattr(s, "columns", {})), None)
117
+ return SizeKey(axis, members, values, labels, title)
118
+
119
+
120
+ class _Header:
121
+ header = True
122
+
123
+
124
+ class Entries:
125
+ """A legend's content (the guide's, :class:`~plot_io.guides.GuideState`): its entries,
126
+ those of a plot and its twins (a twin's: none) or, merged, those of a figure legend's
127
+ plots, then its size keys; laid out in rows and columns."""
128
+
129
+ form = "entries"
130
+ guide: Any = None
131
+
132
+ def __init__(self, legend: Any, ps: Any = None) -> None:
133
+ self.ps = ps
134
+ self.legend = legend
135
+ self.twin = False
136
+ #: the entries of the series and decorations (without size keys)
137
+ self.listed: list[tuple[Any, Any, int]] = []
138
+ #: whether a figure legend shows the listed entries instead
139
+ self.shared = False
140
+ self.keys: list[SizeKey] = []
141
+ #: a figure legend: ``id(source)`` → the plot state whose context draws its symbol
142
+ self.owners: dict[int, Any] = {}
143
+ self.entries: list[tuple[Any, Any, int]] = []
144
+ self.size = (0.0, 0.0)
145
+ self._cols: list[float] = []
146
+ self._rows = 0
147
+ self._ncols = 0
148
+ self._heights: list[float] = []
149
+ self._title_h = 0.0
150
+
151
+ @classmethod
152
+ def of_plot(cls, plot_state: Any) -> "Entries":
153
+ """A plot's legend: the entries of the plot and its twins (a twin's: none)."""
154
+ plot = plot_state.plot
155
+ self = cls(plot.legend, plot_state)
156
+ from .layers.pipeline import plot_calls
157
+ #: a twin shows its entries in its main plot's legend (one legend per axes)
158
+ self.twin = getattr(plot, "twin_of", None) is not None
159
+ plots = [] if self.twin else [plot, *getattr(plot, "twins", [])]
160
+ own = [c for q in plots for c in plot_calls(q)]
161
+ self.listed = [(e, e.label, 0) for c in own for e in c.entries if _named(e.label)]
162
+ self.listed += [(d, label, i) for q in plots for d in q.decorations
163
+ for i, label in enumerate(d.legend_labels()) if _named(label)]
164
+ self.keys = self._size_keys(plot_state)
165
+ self.compose()
166
+ return self
167
+
168
+ @classmethod
169
+ def merged(cls, legend: Any, states: list[Any]) -> "Entries":
170
+ """A figure legend (:class:`~plot_io.plot.FigureLegend`): the entries of its plots
171
+ (``states``), merged (the same label and symbol once)."""
172
+ self = cls(legend)
173
+ by_label: dict[str, list[tuple[Any, int]]] = {}
174
+ from .plot import _figure_legend_title
175
+ headers = _figure_legend_title(legend) is None # plot titles head their entries
176
+ headed: list[str] = []
177
+ for ps in states:
178
+ header = ps.plot.legend.resolve("title") if headers else None
179
+ for src, label, k in ps.legend.listed:
180
+ if getattr(src, "header", False): # a level section's title
181
+ if _label_key(label) not in headed:
182
+ headed.append(_label_key(label))
183
+ self.listed.append((src, label, k))
184
+ continue
185
+ self.owners[id(src)] = ps
186
+ same = by_label.setdefault(_label_key(label), [])
187
+ if any(self._same(src, k, other, j) for other, j in same):
188
+ continue
189
+ same.append((src, k))
190
+ if header is not None and _label_key(header) not in headed:
191
+ headed.append(_label_key(header))
192
+ self.listed.append((_Header(), header, 0))
193
+ header = None
194
+ self.listed.append((src, label, k))
195
+ self.compose()
196
+ return self
197
+
198
+ def compose(self) -> None:
199
+ """The entries shown: the listed ones (unless ``shared``), then the size keys."""
200
+ self.entries = [] if self.shared else list(self.listed)
201
+ for key in self.keys:
202
+ if key.title is not None:
203
+ self.entries.append((_Header(), key.title, 0))
204
+ self.entries.extend((key, lab, i) for i, lab in enumerate(key.labels))
205
+
206
+ def _size_keys(self, ps: Any) -> list[SizeKey]:
207
+ """The size keys the plot's legend shows (one per shared size axis across the
208
+ figure: :func:`plot_io.mapped.size_keys`)."""
209
+ from .spec import current
210
+ r = current()
211
+ if self.twin:
212
+ return []
213
+ plots = {id(ps.plot), *(id(t) for t in getattr(ps.plot, "twins", []))}
214
+ return [group_key(g, g.plot) for g in (r.size_keys.values() if r is not None else ())
215
+ if id(g.plot) in plots]
216
+
217
+ def font(self) -> Font:
218
+ g = self.legend
219
+ return font_of(g, g.resolve("font_size"), color=g.resolve("color"))
220
+
221
+ def _metrics(self) -> tuple[float, float, float, float]:
222
+ g = self.legend
223
+ return (g.resolve("glyph_length"), g.resolve("glyph_gap"), g.resolve("row_height"),
224
+ g.resolve("inner_pad"))
225
+
226
+ def _columns(self, labels: list[float], heights: list[float], room: float | None) -> int:
227
+ """The number of columns of the entries (``labels``: their label widths,
228
+ ``heights``: their row heights): ``columns`` when set; else within ``room`` px along
229
+ the legend's side (its width when entries go in rows, its height when in columns;
230
+ ``None``: unlimited), the most columns whose rows fit, or the fewest columns whose
231
+ height fits (at least one column, one row)."""
232
+ n = len(labels)
233
+ cols = min(self.legend.resolve("columns"), n)
234
+ if self.legend.is_set("columns"):
235
+ return cols
236
+ row_major = self.guide.row_major
237
+ if room is None:
238
+ return n if row_major else cols
239
+ glyph, gap, _, inner = self._metrics()
240
+ if row_major:
241
+ col_gap = self.legend.resolve("column_gap")
242
+ for cols in range(n, 1, -1):
243
+ cells = [0.0] * cols
244
+ for i, lw in enumerate(labels):
245
+ cells[i % cols] = max(cells[i % cols], lw)
246
+ if sum(glyph + gap + c for c in cells) + col_gap * (cols - 1) + 2 * inner <= room:
247
+ return cols
248
+ return 1
249
+ for cols in range(1, n):
250
+ rows = math.ceil(n / cols)
251
+ tall = [0.0] * rows
252
+ for i, rh in enumerate(heights):
253
+ tall[i % rows] = max(tall[i % rows], rh)
254
+ if self._title_h + sum(tall) + 2 * inner <= room:
255
+ return cols
256
+ return max(n, 1)
257
+
258
+ def _pos(self, i: int) -> tuple[int, int]:
259
+ """Column and row of entry ``i``."""
260
+ if self.guide.row_major:
261
+ r, c = divmod(i, self._ncols)
262
+ return c, r
263
+ return divmod(i, self._rows)
264
+
265
+ def measure(self, fonts: Any, room: float | None = None) -> tuple[float, float]:
266
+ """The legend's size; ``room``: the length along its side it wraps within (its
267
+ width when entries go in rows, its height when in columns; see :meth:`_columns`)."""
268
+ if not self.guide.active():
269
+ self.size = (0.0, 0.0)
270
+ return self.size
271
+ font = self.font()
272
+ glyph, gap, row, inner = self._metrics()
273
+ n = len(self.entries)
274
+ labels = []
275
+ heights = []
276
+ for s, label, k in self.entries:
277
+ lw = measure(fonts, label, font)[0]
278
+ if getattr(s, "header", False):
279
+ lw -= glyph + gap
280
+ labels.append(lw)
281
+ heights.append(max(row, 1.15 * s.sizes()[k]) if isinstance(s, SizeKey) else row)
282
+ title = self.legend.resolve("title")
283
+ self._title_h = 0.0
284
+ tw = 0.0
285
+ if title is not None:
286
+ tw, a, d = measure(fonts, title, font)
287
+ self._title_h = a + d + self.legend.resolve("title_gap")
288
+ cols = self._columns(labels, heights, room)
289
+ rows = math.ceil(n / cols)
290
+ self._rows, self._ncols = rows, cols
291
+ widths = [0.0] * cols
292
+ self._heights = [row] * rows
293
+ for i in range(n):
294
+ c, r = self._pos(i)
295
+ widths[c] = max(widths[c], labels[i])
296
+ self._heights[r] = max(self._heights[r], heights[i])
297
+ self._cols = [glyph + gap + w for w in widths]
298
+ w = max(sum(self._cols) + self.legend.resolve("column_gap") * (cols - 1), tw)
299
+ h = self._title_h + sum(self._heights)
300
+ self.size = (w + 2 * inner, h + 2 * inner)
301
+ return self.size
302
+
303
+ def place_at(self, fonts: Any, x: float, y: float) -> Any:
304
+ """The legend's geometry with its box's top-left corner at ``(x, y)``."""
305
+ from .geometry import LegendGeometry, LegendItem
306
+ w, h = self.size
307
+ font = self.font()
308
+ glyph, gap, row, inner = self._metrics()
309
+ cx, cy = x + inner, y + inner
310
+ title = self.legend.resolve("title")
311
+ head = None
312
+ if title is not None:
313
+ head = run(fonts, title, cx, cy, font, "left", "top")
314
+ cy += self._title_h
315
+ items = []
316
+ col_gap = self.legend.resolve("column_gap")
317
+ for i, (s, label, k) in enumerate(self.entries):
318
+ c, r = self._pos(i)
319
+ ex = cx + sum(self._cols[:c]) + col_gap * c
320
+ ey = cy + sum(self._heights[:r])
321
+ rh = self._heights[r]
322
+ if getattr(s, "header", False):
323
+ items.append(LegendItem("header", s, k, (ex, ey, 0.0, rh),
324
+ run(fonts, label, ex, ey + rh / 2, font, "left",
325
+ "center")))
326
+ continue
327
+ if isinstance(s, SizeKey):
328
+ items.append(LegendItem("size", s, k, (ex, ey, glyph, rh),
329
+ run(fonts, label, ex + glyph + gap, ey + rh / 2, font,
330
+ "left", "center")))
331
+ continue
332
+ ey += (rh - row) / 2
333
+ items.append(LegendItem("entry", s, k, (ex, ey + 0.1 * row, glyph, 0.8 * row),
334
+ run(fonts, label, ex + glyph + gap, ey + row / 2, font,
335
+ "left", "center")))
336
+ return LegendGeometry((x, y, w, h), head, tuple(items))
337
+
338
+ def draw(self, canvas: Canvas, ctx: Any, g: Any) -> None:
339
+ """Draw the legend placed as ``g`` (see :meth:`~plot_io.guides.GuideState.place`)."""
340
+ self._draw(canvas, g, ctx.cycle, lambda item: ctx)
341
+
342
+ def _draw(self, canvas: Canvas, g: Any, cycle: Any, ctx_of: Any) -> None:
343
+ """Draw the legend placed as ``g``: its frame with ``cycle`` (the plot's colors), each item's symbol
344
+ in the drawing context ``ctx_of(item)`` gives."""
345
+ if g is None:
346
+ return
347
+ x, y, w, h = g.box
348
+ lg = self.legend
349
+ if lg.resolve("frame"):
350
+ opacity = lg.resolve("frame_opacity")
351
+ bg = with_alpha(resolve_color(lg.resolve("background"), cycle), opacity)
352
+ fc = with_alpha(resolve_color(lg.resolve("frame_color"), cycle), opacity)
353
+ with canvas.state():
354
+ r = min(lg.resolve("frame_radius"), w / 2, h / 2)
355
+ path = Path().rect(x, y, w, h, rx=r)
356
+ if bg is not None:
357
+ canvas.draw_path(path, fill=bg, stroke=None)
358
+ if fc is not None and lg.resolve("frame_width"):
359
+ canvas.stroke_width = lg.resolve("frame_width")
360
+ canvas.draw_path(path, fill=None, stroke=fc)
361
+ draw_run(canvas, g.title)
362
+ for item in g.items:
363
+ if item.kind != "header":
364
+ with canvas.state():
365
+ self.glyph(ctx_of(item), item.kind, item.source, item.index,
366
+ *item.glyph)
367
+ draw_run(canvas, item.label)
368
+
369
+ def glyph(self, ctx: Any, kind: str, source: Any, index: int, x: float, y: float,
370
+ w: float, h: float) -> None:
371
+ """The symbol of an item (``kind``: ``"size"`` or ``"entry"``) in box ``x, y, w,
372
+ h``."""
373
+ if kind == "size":
374
+ source.glyph(ctx, index, x, y, w, h)
375
+ return
376
+ style = self.legend.resolve("glyph")
377
+ if style == "series":
378
+ source.legend_glyph_at(ctx, index, x, y, w, h)
379
+ else:
380
+ _simple_glyph(ctx, source, index, style, self.legend.resolve("glyph_opacity"),
381
+ x, y, w, h)
382
+
383
+ def _same(self, a: Any, i: int, b: Any, j: int) -> bool:
384
+ """Whether two entries draw the same symbol: equal symbols and values (simple
385
+ symbols: equal colors)."""
386
+ try:
387
+ ca, cb = self._context(a), self._context(b)
388
+ if self.legend.resolve("glyph") == "series":
389
+ return a.legend_key_at(ca, i) == b.legend_key_at(cb, j)
390
+ return repr(a.legend_colors_at(ca, i)) == repr(b.legend_colors_at(cb, j))
391
+ except Exception:
392
+ return False
393
+
394
+ def _context(self, src: Any) -> Any:
395
+ """A drawing context of the plot of an entry, for its values."""
396
+ from .layers.context import DrawContext
397
+ ps = self.owners[id(src)]
398
+ return DrawContext(None, ps.plot, (0.0, 0.0, 100.0, 100.0), ps.x, ps.y, ps.cycle)
399
+
400
+ def draw_figure(self, canvas: Canvas, g: Any, contexts: dict[int, Any]) -> None:
401
+ """Draw the legend placed as ``g``, each symbol in the context of its plot
402
+ (``contexts``: ``id(plot)`` → its drawing context)."""
403
+ def ctx_of(item: Any) -> Any:
404
+ if isinstance(item.source, SizeKey):
405
+ plot = item.source.series[0].plot
406
+ plot = plot.twin_of[0] if getattr(plot, "twin_of", None) else plot
407
+ return contexts[id(plot)]
408
+ return contexts[id(self.owners[id(item.source)].plot)]
409
+ first = next(iter(contexts.values()), None)
410
+ self._draw(canvas, g, first.cycle if first is not None else (), ctx_of)
411
+
412
+
413
+ def _label_key(label: Any) -> str:
414
+ """A legend label compared as written (strings and rich content)."""
415
+ return label if isinstance(label, str) else repr(label)
416
+
417
+
418
+ def _opaque(c: Any) -> Any:
419
+ if c is None:
420
+ return None
421
+ from .color.values import rgba_of
422
+ rgba = rgba_of(c)
423
+ return None if rgba is None else (rgba[0], rgba[1], rgba[2], 1.0)
424
+
425
+
426
+ def _simple_glyph(ctx: Any, series: Any, k: int, kind: str, opacity: Any, x: float, y: float,
427
+ w: float, h: float) -> None:
428
+ """A legend symbol in the series' colors: a filled box, a circle or a line (``opacity``:
429
+ the legend's ``glyph_opacity``)."""
430
+ canvas = ctx.canvas
431
+ fill, stroke = series.legend_colors_at(ctx, k)
432
+ if opacity is not None and kind != "line":
433
+ base = _opaque(fill if fill is not None else stroke)
434
+ if base is not None:
435
+ fill, stroke = with_alpha(base, opacity), (stroke if stroke is not None else base)
436
+ if kind == "line":
437
+ c = stroke if stroke is not None else fill
438
+ if c is not None:
439
+ canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], stroke=c,
440
+ stroke_width=min(2.0, h / 3))
441
+ return
442
+ if kind == "circle":
443
+ d = 0.75 * h
444
+ canvas.draw_markers([x + w / 2], [y + h / 2], size=d, fill=fill, stroke=stroke,
445
+ stroke_width=1.25 if stroke is not None else None)
446
+ return
447
+ path = Path().rect(*swatch(x, y, w, h))
448
+ if fill is not None:
449
+ canvas.draw_path(path, fill=fill, stroke=None)
450
+ if stroke is not None:
451
+ canvas.stroke_width = 1.25
452
+ canvas.draw_path(path, fill=None, stroke=stroke)
453
+
454
+
455
+ def _values(v: Any) -> tuple[float, ...]:
456
+ if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__"):
457
+ raise ValueError(f"expected a list of numbers, got {v!r}")
458
+ return tuple(float(x) for x in v)
459
+
460
+
461
+ class SizeLegend(SizeLegendMixin, Settings):
462
+ """A size key in its own grid area (``figure.size_legend(source, id=…)``): the guide of
463
+ a size axis (``source``: a series mapping sizes, or a size axis).
464
+
465
+ A series whose ``size`` goes through a size axis has a size key: by default a key like a
466
+ colorbar's (:class:`Colorbar`); with ``size_key="legend"``, rows in its plot's legend
467
+ below the series entries: the axis' ``label`` (default the ``size`` column) as a header,
468
+ then sample markers at their real size with their values (the axis' ``ticks``, else 3–5
469
+ nice numbers). Series sharing a size axis share one key, in grey (one series: its
470
+ color), in the legend of the rightmost, then topmost, of their plots. A size legend
471
+ area shows every series of its axis, and no plot legend repeats it.
472
+ """
473
+
474
+ SETTINGS = table(
475
+ values=Setting(_values, expr=False,
476
+ doc="The sizes shown, one marker each; default: the axis' `ticks`, "
477
+ "else 3–5 nice numbers inside its limits."),
478
+ title=Setting(text, doc="Title; default: the size axis' `label`."),
479
+ font_size=Setting(length(positive=True, percent="em"),
480
+ doc="Font size; default: the legends' (`Legend.font_size`)."),
481
+ )
482
+
483
+ def __init__(self, source: Any, figure: Any, **settings: Any) -> None:
484
+ from .mapped import SizeAxis, size_axis_of
485
+ axis = source if isinstance(source, SizeAxis) else \
486
+ size_axis_of(source) if hasattr(source, "SETTINGS") else None
487
+ if axis is None:
488
+ raise ValueError("a size legend shows a size axis: give a series mapping sizes, "
489
+ "or a shared figure.size_axis(…)")
490
+ self.size_axis = axis
491
+ self.figure = figure
492
+ self.id: Any = None
493
+ super().__init__(**settings)
494
+
495
+ def _parent(self) -> Settings:
496
+ return self.figure
497
+
498
+ def __repr__(self) -> str:
499
+ return f"SizeLegend({self.size_axis!r})"
500
+
501
+
502
+ class SizeRows:
503
+ """A size legend's content (the guide's, :class:`~plot_io.guides.GuideState`, in its
504
+ own grid area): a size key's rows, its title first."""
505
+
506
+ form = "rows"
507
+ guide: Any = None
508
+ vertical = True
509
+
510
+ def __init__(self, sl: SizeLegend) -> None:
511
+ from .mapped import size_group
512
+ from .plot import Legend, Plot
513
+ self.sl = sl
514
+ fig = sl.figure
515
+ axis = sl.size_axis
516
+ group = size_group(axis)
517
+ users = group.users if group is not None else []
518
+ self.key = None
519
+ self.thickness = 1.0
520
+ if users:
521
+ given = sl.resolve("values")
522
+ values = size_key_values(axis, None if given is None else list(given))
523
+ labels = size_key_labels(axis, values, users[0].plot if isinstance(
524
+ users[0].plot, Plot) else None)
525
+ title = sl.resolve("title")
526
+ if title is None:
527
+ title = axis.resolve("label")
528
+ self.key = SizeKey(axis, users, values, labels, title)
529
+ self.legend = Legend(fig) # the legend settings of the figure's style
530
+ if sl.resolve("font_size") is not None:
531
+ self.legend.set(font_size=sl.resolve("font_size"))
532
+ size = self.legend.resolve("font_size")
533
+ self.font = font_of(fig, size, color=fig.resolve("text_color"))
534
+ self._layout: list[tuple[str, Any, float]] = []
535
+
536
+ @property
537
+ def cell_size(self) -> float:
538
+ """Its width, as a cell of the grid of its own."""
539
+ return self.thickness
540
+
541
+ def measure_area(self, fonts: Any, w: float, h: float) -> tuple[float, float, float, float]:
542
+ """Margins of the size legend in its area ``w`` × ``h`` (none: its width is its
543
+ cell's)."""
544
+ if self.key is None:
545
+ return (0.0, 0.0, 0.0, 0.0)
546
+ g = self.legend
547
+ glyph, gap, row = g.resolve("glyph_length"), g.resolve("glyph_gap"), g.resolve("row_height")
548
+ sizes = self.key.sizes()
549
+ width = 0.0
550
+ rows: list[tuple[str, Any, float]] = []
551
+ if self.key.title is not None:
552
+ width = measure(fonts, self.key.title, self.font)[0]
553
+ rows.append(("title", self.key.title, row))
554
+ g = max([glyph] + [1.1 * s for s in sizes])
555
+ for lab, s in zip(self.key.labels, sizes):
556
+ width = max(width, g + gap + measure(fonts, lab, self.font)[0])
557
+ rows.append(("entry", lab, max(row, 1.15 * s)))
558
+ self._layout = rows
559
+ self._glyph = g
560
+ self.thickness = width
561
+ return (0.0, 0.0, 0.0, 0.0)
562
+
563
+ def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
564
+ x0, y0, x1, y1 = rect
565
+ return (x0, y0, min(x1, x0 + self.thickness), y1)
566
+
567
+ def place_area(self, fonts: Any, rect: tuple[float, float, float, float]) -> Any:
568
+ """The key's rows placed from the top-left corner of ``rect``."""
569
+ from .geometry import KeyGeometry
570
+ if self.key is None:
571
+ return KeyGeometry(self, ())
572
+ x0, y = rect[0], rect[1]
573
+ rows = []
574
+ gap = self.legend.resolve("glyph_gap")
575
+ for kind, lab, h in self._layout:
576
+ if kind == "title":
577
+ rows.append((kind, (x0, y, 0.0, h),
578
+ run(fonts, lab, x0, y + h / 2, self.font, "left", "center")))
579
+ else:
580
+ rows.append((kind, (x0, y, self._glyph, h),
581
+ run(fonts, lab, x0 + self._glyph + gap, y + h / 2, self.font,
582
+ "left", "center")))
583
+ y += h
584
+ return KeyGeometry(self, tuple(rows))
585
+
586
+ def draw(self, canvas: Canvas, g: Any) -> None:
587
+ if self.key is None:
588
+ return
589
+ from .color.maps import cycle_of, map_of
590
+ from .color.values import resolve_color
591
+ plot = self.key.series[0].plot
592
+ cycle = cycle_of(map_of(plot, "qualitative"), 10)
593
+
594
+ class _Ctx:
595
+ def color(self, value: Any) -> Any:
596
+ return resolve_color(value, cycle)
597
+ ctx: Any = _Ctx()
598
+ ctx.canvas, ctx.cycle, ctx.plot = canvas, cycle, plot
599
+ i = 0
600
+ for kind, glyph, label in g.rows:
601
+ if kind != "title":
602
+ with canvas.state():
603
+ self.key.glyph(ctx, i, *glyph)
604
+ i += 1
605
+ draw_run(canvas, label)