plot-io 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/labels.py ADDED
@@ -0,0 +1,63 @@
1
+ """Value labels on bars, histogram bins and pie wedges."""
2
+
3
+ from typing import Any
4
+
5
+ from .settings import one_of
6
+ from .text import Font, draw_text, measure
7
+
8
+ __all__ = ["check_labels", "ink_for", "bar_labels"]
9
+
10
+
11
+ def check_labels(v: Any) -> Any:
12
+ if v is True:
13
+ return "outside"
14
+ if v is False or v is None:
15
+ return False
16
+ return one_of("inside", "outside", "center")(v)
17
+
18
+
19
+ def luminance(c: Any) -> float:
20
+ if c is None:
21
+ return 1.0
22
+ from pdf_io.colors import to_rgba
23
+ rgba = to_rgba(c)
24
+ if rgba is None:
25
+ return 1.0
26
+ r, g, b = (float(x) for x in rgba[:3])
27
+ a = float(rgba[3]) if len(rgba) > 3 else 1.0
28
+ lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
29
+ return a * lum + (1.0 - a) # over a white background
30
+
31
+
32
+ def ink_for(fill: Any) -> str:
33
+ """Black or white, whichever contrasts more with ``fill``."""
34
+ return "black" if luminance(fill) > 0.45 else "white"
35
+
36
+
37
+ def bar_labels(canvas: Any, rects: list[tuple[float, float, float, float, bool]],
38
+ texts: list[Any], fills: list[Any], where: str, horizontal: bool,
39
+ font: Font, pad: float = 2.0) -> None:
40
+ """Labels of bars ``(x0, y0, x1, y1, negative)`` (page units): ``outside`` beyond the
41
+ bar's end, ``inside`` just within it, ``center`` in its middle."""
42
+ for (x0, y0, x1, y1, negative), text, fill in zip(rects, texts, fills):
43
+ if text is None or text == "":
44
+ continue
45
+ inner = where in ("inside", "center")
46
+ f = Font(font.size, font.family, font.weight, font.style,
47
+ ink_for(fill) if inner else font.color)
48
+ if where == "center":
49
+ draw_text(canvas, text, (x0 + x1) / 2, (y0 + y1) / 2, f, "center", "center")
50
+ continue
51
+ if horizontal:
52
+ end = x0 if negative else x1
53
+ out = (where == "outside") != negative
54
+ ha = "left" if out else "right"
55
+ draw_text(canvas, text, end + (pad if out else -pad), (y0 + y1) / 2, f, ha, "center")
56
+ else:
57
+ end = y1 if negative else y0
58
+ up = (where == "outside") != negative
59
+ if up:
60
+ draw_text(canvas, text, (x0 + x1) / 2, end - pad, f, "center", "bottom")
61
+ else:
62
+ w, a, d = measure(canvas, text, f)
63
+ draw_text(canvas, text, (x0 + x1) / 2, end + pad, f, "center", "top")
plot_io/legend.py ADDED
@@ -0,0 +1,498 @@
1
+ """Legend layout and drawing: one entry per named series."""
2
+
3
+ import math
4
+ from typing import Any
5
+
6
+ from .style import resolve_color, with_alpha
7
+ from .text import Font, draw_text, measure
8
+
9
+ __all__ = ["LegendState", "SizeKey", "size_key_values"]
10
+
11
+ #: Inside candidates of ``position="auto"``, in order of preference (ties go to the first).
12
+ CANDIDATES = ("top right", "top left", "bottom left", "bottom right", "top", "bottom",
13
+ "center right", "center left")
14
+ #: ``"auto"`` puts the legend outside right when every inside position covers more than
15
+ #: this fraction of drawn cells.
16
+ OUTSIDE_THRESHOLD = 0.02
17
+
18
+
19
+ def size_key_values(smap: Any, flo: float, fhi: float, given: Any = None) -> list[float]:
20
+ """Values shown in a size key: ``given``, else 3–5 nice numbers inside the map's
21
+ limits (``flo``/``fhi`` after its scale function)."""
22
+ from .scales import get_scale
23
+ from .ticks import numeric_ticks
24
+ if isinstance(given, (list, tuple)):
25
+ return [float(v) for v in given]
26
+ scale = get_scale(smap.scale)
27
+ lo, hi = sorted((scale.inverse(flo), scale.inverse(fhi)))
28
+ eps = (hi - lo) * 1e-9
29
+ vals: list[float] = []
30
+ for most in (4, 5, 6, 8):
31
+ ticks, _ = numeric_ticks(scale, lo, hi, most, minor=False, length=100.0)
32
+ vals = [t.value for t in ticks if t.major and lo - eps <= t.value <= hi + eps
33
+ and (t.value > 0 or scale.name != "linear" or lo <= 0)]
34
+ if len(vals) >= 3:
35
+ break
36
+ return vals if len(vals) >= 2 else [lo, hi]
37
+
38
+
39
+ class SizeKey:
40
+ """Entries of a size key: a header (the map's label) and sample markers."""
41
+
42
+ header = False
43
+
44
+ def __init__(self, smap: Any, series: list[Any], values: list[float], labels: list[Any],
45
+ title: Any) -> None:
46
+ self.smap = smap
47
+ self.series = series
48
+ self.values = values
49
+ self.labels = labels
50
+ self.title = title
51
+
52
+ def sizes(self, ctx: Any) -> list[float]:
53
+ from . import _backend
54
+ from .markers import area_factor
55
+ from .scales import get_scale
56
+ flo, fhi = ctx.maps[id(self.smap)][:2]
57
+ d = self.smap.map(_backend.doubles(self.values), flo, fhi,
58
+ get_scale(self.smap.scale).function)
59
+ s = self.series[0]
60
+ marker = s.get("marker") if "marker" in s.SETTINGS else "circle"
61
+ marker = marker[0] if isinstance(marker, list) else marker
62
+ factor = area_factor(marker) if s.get("size_basis") == "area" else 1.0
63
+ return [v * factor if v == v else 0.0 for v in d]
64
+
65
+ def glyph(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
66
+ from .markers import resolve
67
+ s = self.series[0]
68
+ size = self.sizes(ctx)[i]
69
+ if not size:
70
+ return
71
+ marker = s.get("marker") if "marker" in s.SETTINGS else "circle"
72
+ marker = marker[0] if isinstance(marker, list) else marker
73
+ from .values import Values, is_computed
74
+ mapped = any(isinstance(s.get(n), Values) or is_computed(s.get(n))
75
+ for n in ("color", "line_color") if n in s.SETTINGS)
76
+ if len(self.series) == 1 and not mapped:
77
+ from .series.points import MarkerStyle
78
+ st = MarkerStyle(s, ctx, "", s.base_color(ctx), 1, True)
79
+ fill, stroke, lw = st.first_color("fill"), st.first_color("stroke"), st.line_width
80
+ if fill is None and stroke is None:
81
+ fill = s.base_color(ctx)
82
+ else:
83
+ fill, stroke, lw = "#888888", None, None
84
+ ctx.canvas.draw_markers([x + w / 2], [y + h / 2], marker=resolve(marker)[0],
85
+ size=size, fill=fill, stroke=stroke, line_width=lw)
86
+
87
+
88
+ class _Header:
89
+ header = True
90
+
91
+
92
+ class LegendState:
93
+ """A plot's legend while drawing."""
94
+
95
+ def __init__(self, plot_state: Any) -> None:
96
+ self.ps = plot_state
97
+ self.legend = plot_state.plot.legend
98
+ plot = plot_state.plot
99
+ own = list(plot.series)
100
+ if getattr(plot, "twin_of", None) and not plot.legend.is_set("position"):
101
+ own = [] # listed in the main plot's legend
102
+ for t in getattr(plot, "twins", []):
103
+ if not t.legend.is_set("position"):
104
+ own += list(t.series)
105
+ self.entries = [(s, label, i) for s in own + list(plot.decorations)
106
+ for i, label in enumerate(s.legend_labels())]
107
+ self.keys = self._size_keys(plot_state)
108
+ for key in self.keys:
109
+ if key.title is not None:
110
+ self.entries.append((_Header(), key.title, 0))
111
+ self.entries.extend((key, lab, i) for i, lab in enumerate(key.labels))
112
+ self.size = (0.0, 0.0)
113
+ self._cols: list[float] = []
114
+ self._rows = 0
115
+ self._heights: list[float] = []
116
+ self._title_h = 0.0
117
+ #: The position chosen for ``"auto"`` once the axes are known (see :meth:`choose`).
118
+ self.choice: str | None = None
119
+ self.free: tuple[float, float] = (0.0, 0.0)
120
+
121
+ def _size_keys(self, ps: Any) -> list[SizeKey]:
122
+ """Size keys of the plot's series with a ``size_map`` (one per map)."""
123
+ from .numbers import format_numbers
124
+ by_map: dict[int, list[Any]] = {}
125
+ maps: dict[int, Any] = {}
126
+ for s in ps.plot.series:
127
+ m = s.size_map_obj() if hasattr(s, "size_map_obj") else None
128
+ if m is None or ("size_legend" in s.SETTINGS and s.get("size_legend") is False):
129
+ continue
130
+ by_map.setdefault(id(m), []).append(s)
131
+ maps[id(m)] = m
132
+ keys = []
133
+ for k, members in by_map.items():
134
+ m = maps[k]
135
+ if k not in ps.maps:
136
+ continue
137
+ flo, fhi = ps.maps[k][:2]
138
+ given = members[0].get("size_legend") if "size_legend" in members[0].SETTINGS \
139
+ else None
140
+ values = size_key_values(m, flo, fhi, given)
141
+ labels = format_numbers(values, "auto", ps.plot)
142
+ title = m.label
143
+ if title is None:
144
+ title = next((s.columns["size"] for s in members
145
+ if "size" in getattr(s, "columns", {})), None)
146
+ keys.append(SizeKey(m, members, values, labels, title))
147
+ return keys
148
+
149
+ def font(self) -> Font:
150
+ g = self.legend
151
+ return Font(g.get("font_size"), g.get("font_family"), color=g.get("color"))
152
+
153
+ def active(self) -> bool:
154
+ return bool(self.entries) and self.legend.get("position") != "none"
155
+
156
+ def position(self) -> str:
157
+ """The position setting, with ``"auto"`` replaced by its choice once made."""
158
+ pos = self.legend.get("position")
159
+ return self.choice if pos == "auto" and self.choice else pos
160
+
161
+ def horizontal(self) -> bool:
162
+ o = self.legend.get("orientation")
163
+ if o == "auto":
164
+ # The setting, not an ``"auto"`` choice: the box was measured before choosing.
165
+ return self.legend.get("position") in ("top", "bottom", "outside top",
166
+ "outside bottom")
167
+ return o == "horizontal"
168
+
169
+ def outside(self) -> str | None:
170
+ pos = self.position()
171
+ return pos if self.active() and pos.startswith("outside") else None
172
+
173
+ def _metrics(self) -> tuple[float, float, float, float]:
174
+ fs = self.font().size
175
+ g = self.legend
176
+ return (g.get("glyph_width") * fs, 0.5 * fs, g.get("row_height") * fs,
177
+ (0.5 * fs if g.get("frame") else 0.15 * fs))
178
+
179
+ def _ctx(self) -> Any:
180
+ """A context for size computations (the maps)."""
181
+ class _C:
182
+ maps = self.ps.maps
183
+ return _C()
184
+
185
+ def measure(self, canvas: Any) -> tuple[float, float]:
186
+ if not self.active():
187
+ self.size = (0.0, 0.0)
188
+ return self.size
189
+ font = self.font()
190
+ glyph, gap, row, inner = self._metrics()
191
+ n = len(self.entries)
192
+ cols = min(self.legend.get("columns"), n)
193
+ if self.horizontal() and not self.legend.is_set("columns"):
194
+ cols = n
195
+ rows = math.ceil(n / cols)
196
+ widths = [0.0] * cols
197
+ self._heights = [row] * rows
198
+ for i, (s, label, k) in enumerate(self.entries):
199
+ c, r = divmod(i, rows)
200
+ lw = measure(canvas, label, font)[0]
201
+ if getattr(s, "header", False):
202
+ lw -= glyph + gap
203
+ widths[c] = max(widths[c], lw)
204
+ if isinstance(s, SizeKey):
205
+ self._heights[r] = max(self._heights[r], 1.15 * s.sizes(self._ctx())[k])
206
+ self._cols = [glyph + gap + w for w in widths]
207
+ self._rows = rows
208
+ w = sum(self._cols) + font.size * (cols - 1)
209
+ title = self.legend.get("title")
210
+ self._title_h = 0.0
211
+ if title is not None:
212
+ tw, a, d = measure(canvas, title, font)
213
+ w = max(w, tw)
214
+ self._title_h = a + d + 0.3 * font.size
215
+ h = self._title_h + sum(self._heights)
216
+ self.size = (w + 2 * inner, h + 2 * inner)
217
+ return self.size
218
+
219
+ def _place(self, ctx: Any) -> tuple[float, float]:
220
+ """Top-left corner of the legend box."""
221
+ x0, y0, x1, y1 = ctx.rect
222
+ w, h = self.size
223
+ pad = self.legend.get("pad")
224
+ pos = self.position()
225
+ if pos == "outside right":
226
+ return x1 + self.ps._right_used + pad, y0
227
+ if pos == "outside top":
228
+ return (x0 + x1 - w) / 2 if self.horizontal() else x0, y0 - pad - h
229
+ if pos == "outside bottom":
230
+ return ((x0 + x1 - w) / 2 if self.horizontal() else x0,
231
+ y1 + self.ps._bottom_used + pad)
232
+ if pos == "auto":
233
+ pos = self.choose(ctx, outside=False)
234
+ return self._box(ctx, pos)
235
+
236
+ def _box(self, ctx: Any, pos: str) -> tuple[float, float]:
237
+ """Top-left corner of the legend box at an inside position."""
238
+ x0, y0, x1, y1 = ctx.rect
239
+ w, h = self.size
240
+ pad = self.legend.get("pad")
241
+ if pos == "inside":
242
+ fx, fy = self.free
243
+ return (x0 + pad + fx * max(0.0, x1 - x0 - 2 * pad - w),
244
+ y0 + pad + fy * max(0.0, y1 - y0 - 2 * pad - h))
245
+ vertical, _, horizontal = pos.partition(" ")
246
+ if pos in ("top", "bottom"):
247
+ vertical, horizontal = pos, "center"
248
+ x = {"left": x0 + pad, "right": x1 - pad - w, "center": (x0 + x1 - w) / 2}[horizontal]
249
+ y = {"top": y0 + pad, "bottom": y1 - pad - h, "center": (y0 + y1 - h) / 2}[vertical]
250
+ return x, y
251
+
252
+ def choose(self, ctx: Any, outside: bool = True) -> str:
253
+ """The position for ``"auto"``, from the occupancy grid (every series marks what it
254
+ draws: points, lines, bars, areas, …):
255
+
256
+ 1. the first candidate (``CANDIDATES``) whose box covers no drawn cell;
257
+ 2. else the free box (one grid cell apart, anywhere inside) nearest to a corner of
258
+ the axes (``"inside"``, its place kept as fractions of the free room);
259
+ 3. else ``"outside right"`` (when ``outside``) if every candidate covers more than
260
+ ``OUTSIDE_THRESHOLD``; else the candidate covering the fewest cells."""
261
+ from .occupancy import Occupancy
262
+
263
+ occ = Occupancy(ctx.rect)
264
+ for s in self.ps.series:
265
+ s.occupy(ctx, occ)
266
+ from .series import DrawContext
267
+ for t in getattr(self.ps, "twins", []):
268
+ tctx = DrawContext(ctx.canvas, t.plot, ctx.rect, t.x, t.y, t.maps, t.palette)
269
+ for s in t.series:
270
+ s.occupy(tctx, occ)
271
+ w, h = self.size
272
+ best, best_f = CANDIDATES[0], math.inf
273
+ for pos in CANDIDATES:
274
+ x, y = self._box(ctx, pos)
275
+ f = occ.covered(x, y, w, h)
276
+ if f == 0.0:
277
+ return pos
278
+ if f < best_f - 1e-12:
279
+ best, best_f = pos, f
280
+ x0, y0, x1, y1 = ctx.rect
281
+ pad = self.legend.get("pad")
282
+ free = occ.free_boxes(w, h, pad)
283
+ if free:
284
+ def corner(p: tuple[float, float]) -> float:
285
+ x, y = p
286
+ return min(x - x0, x1 - x - w) + min(y - y0, y1 - y - h)
287
+ x, y = min(free, key=corner)
288
+ room_x, room_y = x1 - x0 - 2 * pad - w, y1 - y0 - 2 * pad - h
289
+ self.free = ((x - x0 - pad) / room_x if room_x > 0 else 0.0,
290
+ (y - y0 - pad) / room_y if room_y > 0 else 0.0)
291
+ return "inside"
292
+ if outside and best_f > OUTSIDE_THRESHOLD:
293
+ return "outside right"
294
+ return best
295
+
296
+ def draw(self, canvas: Any, ctx: Any) -> None:
297
+ if not self.active() or not self.size[0]:
298
+ return
299
+ x, y = self._place(ctx)
300
+ w, h = self.size
301
+ font = self.font()
302
+ glyph, gap, row, inner = self._metrics()
303
+ g = self.legend
304
+ if g.get("frame"):
305
+ alpha = g.get("frame_alpha")
306
+ bg = with_alpha(resolve_color(g.get("background"), ctx.palette), alpha)
307
+ fc = with_alpha(resolve_color(g.get("frame_color"), ctx.palette), alpha)
308
+ canvas.save()
309
+ canvas.begin_path()
310
+ r = min(g.get("frame_radius"), w / 2, h / 2)
311
+ if r:
312
+ canvas.rounded_rect(x, y, w, h, r)
313
+ else:
314
+ canvas.rect(x, y, w, h)
315
+ if bg is not None:
316
+ canvas.fill(bg)
317
+ if fc is not None and g.get("frame_width"):
318
+ canvas.line_width = g.get("frame_width")
319
+ canvas.stroke(fc)
320
+ canvas.restore()
321
+ cx, cy = x + inner, y + inner
322
+ title = self.legend.get("title")
323
+ if title is not None:
324
+ draw_text(canvas, title, cx, cy, font, "left", "top")
325
+ cy += self._title_h
326
+ for i, (s, label, k) in enumerate(self.entries):
327
+ c, r = divmod(i, self._rows)
328
+ ex = cx + sum(self._cols[:c]) + font.size * c
329
+ ey = cy + sum(self._heights[:r])
330
+ rh = self._heights[r]
331
+ if getattr(s, "header", False):
332
+ draw_text(canvas, label, ex, ey + rh / 2, font, "left", "center")
333
+ continue
334
+ if isinstance(s, SizeKey):
335
+ canvas.save()
336
+ try:
337
+ s.glyph(ctx, k, ex, ey, glyph, rh)
338
+ finally:
339
+ canvas.restore()
340
+ draw_text(canvas, label, ex + glyph + gap, ey + rh / 2, font, "left", "center")
341
+ continue
342
+ ey += (rh - row) / 2
343
+ canvas.save()
344
+ try:
345
+ kind = g.get("glyph")
346
+ if kind == "series":
347
+ s.legend_glyph_at(ctx, k, ex, ey + 0.1 * row, glyph, 0.8 * row)
348
+ else:
349
+ _simple_glyph(ctx, s, k, kind, ex, ey + 0.1 * row, glyph, 0.8 * row)
350
+ finally:
351
+ canvas.restore()
352
+ draw_text(canvas, label, ex + glyph + gap, ey + row / 2, font, "left", "center")
353
+
354
+
355
+ def _opaque(c: Any) -> Any:
356
+ if c is None:
357
+ return None
358
+ from pdf_io.colors import to_rgba
359
+ rgba = to_rgba(c)
360
+ return None if rgba is None else (rgba[0], rgba[1], rgba[2], 1.0)
361
+
362
+
363
+ def _simple_glyph(ctx: Any, series: Any, k: int, kind: str, x: float, y: float, w: float,
364
+ h: float) -> None:
365
+ """A legend symbol in the series' colors: a filled box, a circle or a line."""
366
+ canvas = ctx.canvas
367
+ fill, stroke = series.legend_colors_at(ctx, k)
368
+ alpha = ctx.plot.legend.get("glyph_alpha")
369
+ if alpha is not None and kind != "line":
370
+ base = _opaque(fill if fill is not None else stroke)
371
+ if base is not None:
372
+ fill, stroke = with_alpha(base, alpha), (stroke if stroke is not None else base)
373
+ if kind == "line":
374
+ c = stroke if stroke is not None else fill
375
+ if c is not None:
376
+ canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c,
377
+ width=min(1.5, h / 3))
378
+ return
379
+ if kind == "circle":
380
+ d = 0.75 * h
381
+ canvas.draw_markers([x + w / 2], [y + h / 2], size=d, fill=fill, stroke=stroke,
382
+ line_width=1.0 if stroke is not None else None)
383
+ return
384
+ canvas.begin_path()
385
+ canvas.rect(x, y + 0.1 * h, w, 0.8 * h)
386
+ if fill is not None:
387
+ canvas.fill(fill)
388
+ if stroke is not None:
389
+ canvas.line_width = 1.0
390
+ canvas.stroke(stroke)
391
+
392
+
393
+ class SizeLegend:
394
+ """A size key in its own grid area (``figure.size_legend(size_map, id=…)``)."""
395
+
396
+ def __init__(self, size_map: Any, figure: Any, values: Any = None, font_size: Any = None,
397
+ title: Any = None) -> None:
398
+ from .maps import SizeMap
399
+ if not isinstance(size_map, SizeMap):
400
+ raise ValueError("a size legend needs a SizeMap")
401
+ self.size_map = size_map
402
+ self.figure = figure
403
+ self.values = None if values is None else [float(v) for v in values]
404
+ self.font_size = font_size
405
+ self.title = title
406
+ self.id: Any = None
407
+
408
+ def __repr__(self) -> str:
409
+ return f"SizeLegend({self.size_map!r})"
410
+
411
+
412
+ class SizeLegendState:
413
+ """A size legend area while drawing (the colorbar-area interface of the layout)."""
414
+
415
+ vertical = True
416
+
417
+ def __init__(self, sl: SizeLegend, maps: dict[int, tuple]) -> None:
418
+ from .numbers import format_numbers
419
+ from .plot import Plot
420
+ self.sl = sl
421
+ fig = sl.figure
422
+ self.maps = maps
423
+ m = sl.size_map
424
+ users = [s for p in fig.plots for s in p.series
425
+ if hasattr(s, "size_map_obj") and s.size_map_obj() is m]
426
+ self.key = None
427
+ self.thickness = 1.0
428
+ if id(m) in maps and users:
429
+ flo, fhi = maps[id(m)][:2]
430
+ values = size_key_values(m, flo, fhi, sl.values)
431
+ labels = format_numbers(values, "auto", users[0].plot if isinstance(
432
+ users[0].plot, Plot) else None)
433
+ title = sl.title if sl.title is not None else m.label
434
+ self.key = SizeKey(m, users, values, labels, title)
435
+ size = sl.font_size or fig.get("font_size")
436
+ self.font = Font(size, fig.get("font_family"), color=fig.get("text_color"))
437
+ self._layout: list[tuple[str, Any, float]] = []
438
+
439
+ def _ctx(self) -> Any:
440
+ class _C:
441
+ maps = self.maps
442
+ return _C()
443
+
444
+ def measure(self, canvas: Any, w: float, h: float) -> tuple[float, float, float, float]:
445
+ if self.key is None:
446
+ return (0.0, 0.0, 0.0, 0.0)
447
+ fs = self.font.size
448
+ glyph, gap, row = 2.0 * fs, 0.5 * fs, 1.35 * fs
449
+ sizes = self.key.sizes(self._ctx())
450
+ width = 0.0
451
+ rows: list[tuple[str, Any, float]] = []
452
+ if self.key.title is not None:
453
+ width = measure(canvas, self.key.title, self.font)[0]
454
+ rows.append(("title", self.key.title, row))
455
+ g = max([glyph] + [1.1 * s for s in sizes])
456
+ for lab, s in zip(self.key.labels, sizes):
457
+ width = max(width, g + gap + measure(canvas, lab, self.font)[0])
458
+ rows.append(("entry", lab, max(row, 1.15 * s)))
459
+ self._layout = rows
460
+ self._glyph = g
461
+ self.thickness = width
462
+ return (0.0, 0.0, 0.0, 0.0)
463
+
464
+ def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
465
+ x0, y0, x1, y1 = rect
466
+ return (x0, y0, min(x1, x0 + self.thickness), y1)
467
+
468
+ def draw_area(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
469
+ if self.key is None:
470
+ return
471
+ from .palettes import palette_colors
472
+ from .style import resolve_color
473
+ x0, y = rect[0], rect[1]
474
+ fs = self.font.size
475
+ plot = self.key.series[0].plot
476
+ palette = palette_colors(plot.get("palette"), 10)
477
+
478
+ class _Ctx:
479
+ maps = self.maps
480
+
481
+ def color(self, value: Any) -> Any:
482
+ return resolve_color(value, palette)
483
+ ctx: Any = _Ctx()
484
+ ctx.canvas, ctx.palette, ctx.plot = canvas, palette, plot
485
+ i = 0
486
+ for kind, lab, h in self._layout:
487
+ if kind == "title":
488
+ draw_text(canvas, lab, x0, y + h / 2, self.font, "left", "center")
489
+ else:
490
+ canvas.save()
491
+ try:
492
+ self.key.glyph(ctx, i, x0, y, self._glyph, h)
493
+ finally:
494
+ canvas.restore()
495
+ draw_text(canvas, lab, x0 + self._glyph + 0.5 * fs, y + h / 2, self.font,
496
+ "left", "center")
497
+ i += 1
498
+ y += h
plot_io/limits.py ADDED
@@ -0,0 +1,92 @@
1
+ """Limits: ``"auto"``, ``"lo/hi"`` strings (``min``, ``max``, numbers, ``q:F``) or pairs."""
2
+
3
+ import math
4
+ from collections.abc import Callable, Sequence
5
+ from typing import Any
6
+
7
+ __all__ = ["check_limits", "resolve_limits"]
8
+
9
+
10
+ def _side(token: str) -> Any:
11
+ t = token.strip().lower()
12
+ if t in ("min", "max", "auto", ""):
13
+ return "auto" if t in ("auto", "") else t
14
+ if t.startswith("q:"):
15
+ q = float(t[2:])
16
+ if not 0 <= q <= 1:
17
+ raise ValueError(f"quantile {q} outside [0, 1]")
18
+ return ("q", q)
19
+ try:
20
+ v = float(t)
21
+ except ValueError:
22
+ import datetime as dt
23
+ from .times import seconds
24
+ try:
25
+ v = seconds(dt.datetime.fromisoformat(token.strip()))
26
+ except ValueError:
27
+ raise ValueError(f"invalid limit {token!r}") from None
28
+ if not math.isfinite(v):
29
+ raise ValueError(f"invalid limit {token!r}")
30
+ return v
31
+
32
+
33
+ def check_limits(v: Any) -> Any:
34
+ """Normalize a limits value to ``"auto"`` or a pair of sides (``None``/``"auto"``, a
35
+ number, ``"min"``, ``"max"``, ``("q", F)``)."""
36
+ if isinstance(v, str):
37
+ s = v.strip().lower()
38
+ if s == "auto":
39
+ return "auto"
40
+ parts = s.split("/")
41
+ if len(parts) != 2:
42
+ raise ValueError(f"expected 'lo/hi', got {v!r}")
43
+ return (_side(parts[0]), _side(parts[1]))
44
+ if isinstance(v, (tuple, list)) and len(v) == 2:
45
+ out: list[Any] = []
46
+ for side in v:
47
+ if side is None:
48
+ out.append("auto")
49
+ elif isinstance(side, str):
50
+ out.append(_side(side))
51
+ elif isinstance(side, (int, float)) and not isinstance(side, bool) \
52
+ and math.isfinite(side):
53
+ out.append(float(side))
54
+ elif hasattr(side, "year"): # a date or datetime
55
+ from .times import seconds
56
+ out.append(seconds(side))
57
+ else:
58
+ raise ValueError(f"invalid limit {side!r}")
59
+ return tuple(out)
60
+ raise ValueError(f"expected 'auto', 'lo/hi' or (lo, hi), got {v!r}")
61
+
62
+
63
+ def resolve_limits(spec: Any, lo: float, hi: float,
64
+ quantile: Callable[[float], float] | None = None) -> tuple[Any, Any, bool, bool]:
65
+ """``(lo, hi, lo_auto, hi_auto)`` from a limits spec and the data range; ``*_auto``
66
+ tells whether that side follows the data (and takes padding)."""
67
+ sides = ("auto", "auto") if spec == "auto" else spec
68
+ out: list[Any] = []
69
+ autos: list[bool] = []
70
+ for side, data in zip(sides, (lo, hi)):
71
+ if side == "auto":
72
+ out.append(data)
73
+ autos.append(True)
74
+ elif side == "min":
75
+ out.append(lo)
76
+ autos.append(False)
77
+ elif side == "max":
78
+ out.append(hi)
79
+ autos.append(False)
80
+ elif isinstance(side, tuple):
81
+ out.append(quantile(side[1]) if quantile is not None else data)
82
+ autos.append(False)
83
+ else:
84
+ out.append(float(side))
85
+ autos.append(False)
86
+ return out[0], out[1], autos[0], autos[1]
87
+
88
+
89
+ def needs_quantiles(spec: Any) -> Sequence[float]:
90
+ if spec == "auto":
91
+ return ()
92
+ return [s[1] for s in spec if isinstance(s, tuple)]