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/render.py ADDED
@@ -0,0 +1,1122 @@
1
+ """Layout and drawing of figures.
2
+
3
+ Drawing a figure resolves every plot (scales, limits, map limits), measures the
4
+ decorations of each area for its axes size, solves the grid (gutters for
5
+ ``align="axes"``, boxes for ``align="borders"``), repeats until the axes sizes settle,
6
+ then draws. All lengths here are points.
7
+ """
8
+
9
+ import math
10
+ from collections.abc import Sequence
11
+ from typing import Any
12
+
13
+ import pdf_io
14
+
15
+ from . import _backend
16
+ from .axis import AxisState
17
+ from .limits import needs_quantiles, resolve_limits
18
+ from .scales import get_scale
19
+ from .series import DrawContext
20
+ from .style import resolve_color
21
+ from .text import Font, box, draw_text, measure, superscript
22
+
23
+ __all__ = ["draw", "save", "to_bytes", "size", "Layout"]
24
+
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Arrangement
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _z(item: Any) -> float:
33
+ """Drawing order key: ``z``, unset (call order) counting as 0 (sorting is stable)."""
34
+ z = item.get("z")
35
+ return 0.0 if z is None else z
36
+
37
+ class Area:
38
+ """A plot or colorbar placed on cells ``[r0, r1) × [c0, c1)``."""
39
+
40
+ def __init__(self, obj: Any, r0: int, c0: int, r1: int, c1: int) -> None:
41
+ self.obj = obj
42
+ self.r0, self.c0, self.r1, self.c1 = r0, c0, r1, c1
43
+ self.state: Any = None
44
+ self.margins = (0.0, 0.0, 0.0, 0.0) # left, right, top, bottom
45
+ self.axes = (0.0, 0.0, 0.0, 0.0) # x0, y0, x1, y1
46
+ self.box = (0.0, 0.0, 0.0, 0.0)
47
+
48
+ @property
49
+ def is_bar(self) -> bool:
50
+ """A colorbar or a size legend: an area of fixed thickness."""
51
+ from .colorbar import Colorbar
52
+ from .legend import SizeLegend
53
+ return isinstance(self.obj, (Colorbar, SizeLegend))
54
+
55
+
56
+ def arrange(fig: Any, only: Any = None) -> tuple[list[Area], int, int]:
57
+ """The areas of the figure and the grid size."""
58
+ if only is not None:
59
+ return [Area(only, 0, 0, 1, 1)], 1, 1
60
+ items = list(fig._areas.items())
61
+ grid = fig.get("grid")
62
+ if grid is not None:
63
+ areas = []
64
+ for k, obj in items:
65
+ cells = [(i, j) for i, r in enumerate(grid) for j, v in enumerate(r) if v == k]
66
+ if not cells:
67
+ raise ValueError(f"id {k!r} is not in the grid")
68
+ r0 = min(c[0] for c in cells)
69
+ c0 = min(c[1] for c in cells)
70
+ areas.append(Area(obj, r0, c0, max(c[0] for c in cells) + 1,
71
+ max(c[1] for c in cells) + 1))
72
+ return areas, len(grid), len(grid[0])
73
+ n = len(items)
74
+ rows, cols, order = fig.get("rows"), fig.get("cols"), fig.get("order")
75
+ if rows and cols:
76
+ nr, nc = rows, cols
77
+ by_row = order != "col"
78
+ elif cols:
79
+ nc = cols
80
+ nr = max(1, math.ceil(n / cols))
81
+ by_row = order != "col"
82
+ elif rows:
83
+ nr = rows
84
+ nc = max(1, math.ceil(n / rows))
85
+ by_row = order == "row"
86
+ else:
87
+ nc = max(1, math.ceil(math.sqrt(n)))
88
+ nr = max(1, math.ceil(n / nc))
89
+ by_row = order != "col"
90
+ areas = []
91
+ for i, (_, obj) in enumerate(items):
92
+ r, c = (i // nc, i % nc) if by_row else (i % nr, i // nr)
93
+ areas.append(Area(obj, r, c, r + 1, c + 1))
94
+ return areas, nr, nc
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Color and size map limits
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def resolve_maps(series: Sequence[Any], extra: Sequence[Any] = ()) -> dict[int, tuple]:
102
+ """Limits of every color and size map used, after the map's scale function:
103
+ ``id(map) → (flo, fhi, data_lo, data_hi)``."""
104
+ users: dict[int, tuple[Any, list[Any]]] = {}
105
+ for s in series:
106
+ for m, values in list(s.color_sources()) + list(s.size_sources()):
107
+ users.setdefault(id(m), (m, []))[1].append(values)
108
+ for m in extra:
109
+ users.setdefault(id(m), (m, []))
110
+ out: dict[int, tuple] = {}
111
+ for key, (m, value_list) in users.items():
112
+ scale = get_scale(m.scale)
113
+ count, lo, hi = 0, math.inf, -math.inf
114
+ for v in value_list:
115
+ n, a, b = _backend.extent(v, scale.function)
116
+ if n:
117
+ count += n
118
+ lo, hi = min(lo, a), max(hi, b)
119
+ if not count:
120
+ lo, hi = (1.0, 10.0) if scale.name.startswith("log") else (0.0, 1.0)
121
+ qs = needs_quantiles(m.limits)
122
+ qmap: dict[float, float] = {}
123
+ if qs:
124
+ from array import array
125
+ allv = array("d")
126
+ for v in value_list:
127
+ allv.extend(_backend.doubles(v))
128
+ allv = array("d", [x for x in allv if scale.contains(x)])
129
+ qmap = dict(zip(qs, _backend.quantiles(allv, qs)))
130
+ a, b, _, _ = resolve_limits(m.limits, lo, hi, lambda q: qmap[q])
131
+ fa, fb = scale.f(a), scale.f(b)
132
+ if not (math.isfinite(fa) and math.isfinite(fb)):
133
+ raise ValueError(f"map limits {a!r}/{b!r} are outside the {scale.name} scale's domain")
134
+ if fa == fb:
135
+ fa, fb = fa - 0.5, fb + 0.5
136
+ out[key] = (fa, fb, lo, hi)
137
+ return out
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Plot state: resolution, measurement, drawing
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _label_content(state: AxisState) -> Any:
145
+ """The axis title: its label, or the label it takes from a data column."""
146
+ label = state.axis.get("label")
147
+ return state.auto_label if label is None else label
148
+
149
+
150
+ def _offset_content(state: AxisState) -> Any:
151
+ """What is written at the end of the axis: the common ×10ᵏ factor of the tick labels,
152
+ or the common date part of a time axis; ``None`` when there is none."""
153
+ if state.offset:
154
+ return state.offset
155
+ if not state.factor:
156
+ return None
157
+ from .numbers import NumberStyle
158
+ style = NumberStyle.of(state.axis)
159
+ return ["\u00d710", superscript(style.exponent(state.factor))]
160
+
161
+
162
+ class PlotState:
163
+ """A plot while its figure is drawn."""
164
+
165
+ def __init__(self, plot: Any, maps: dict[int, tuple], palette: Any = None) -> None:
166
+ self.plot = plot
167
+ self.maps = maps
168
+ self.x = AxisState(plot.x_axis)
169
+ self.y = AxisState(plot.y_axis)
170
+ series = list(plot.series)
171
+ self.series = sorted(series, key=_z)
172
+ from .palettes import palette_colors
173
+ twins = list(getattr(plot, "twins", []))
174
+ colored = series + [s for t in twins for s in t.series] # palette continues
175
+ n_auto = sum(s.palette_count() for s in colored
176
+ if s.USES_PALETTE and not s.has_own_color())
177
+ refs = [int(v[1:]) + 1 for s in colored for v in s.settings().values()
178
+ if isinstance(v, str) and v[:1] == "C" and v[1:].isdigit()]
179
+ if palette is None:
180
+ palette = palette_colors(plot.get("palette"), max([n_auto] + refs))
181
+ self.palette = palette
182
+ i = 0
183
+ for s in (colored if not getattr(plot, "twin_of", None) else []):
184
+ if s.USES_PALETTE and not s.has_own_color():
185
+ k = s.palette_count()
186
+ s._auto_colors = [palette[(i + j) % len(palette)] for j in range(k)]
187
+ s._auto_color = s._auto_colors[0]
188
+ i += k
189
+ else:
190
+ s._auto_color = None
191
+ s._auto_colors = None
192
+ kinds = {s.AXES for s in series}
193
+ if len(kinds) > 1:
194
+ raise ValueError(f"{plot!r} mixes series drawn without axes (pie) and with axes")
195
+ self.axes_on = plot.get("axes") and kinds != {False}
196
+ decos = list(plot.decorations)
197
+ self.decorations = decos
198
+ limit_items = series + [d for d in decos if d.get("extend_limits")]
199
+ self.limit_items, self.padded = limit_items, self.axes_on
200
+ for role, st in (("x", self.x), ("y", self.y)):
201
+ st.auto_label = next((t for t in (s.axis_title(role) for s in series)
202
+ if t is not None), None)
203
+ self.twins = [PlotState(t, maps, palette) for t in twins]
204
+ self.role_items = {"x": list(limit_items), "y": list(limit_items)}
205
+ for t in self.twins: # a twin shares the other axis
206
+ shared = "x" if t.plot.twin_of[1] == "y" else "y"
207
+ self.role_items[shared] += t.limit_items
208
+ self.x.resolve(self.role_items["x"], self.padded)
209
+ self.y.resolve(self.role_items["y"], self.padded)
210
+ for t in self.twins:
211
+ shared = "x" if t.plot.twin_of[1] == "y" else "y"
212
+ mine, theirs = getattr(self, shared), getattr(t, shared)
213
+ theirs.copy_limits(mine)
214
+ theirs.shared = mine
215
+ from .colorbar import BarState
216
+ from .legend import LegendState
217
+ self.bars = [BarState(cb, maps, plot) for cb in plot.colorbars]
218
+ self.legend = LegendState(self)
219
+ self.ticks_for: tuple[float, float] | None = None
220
+
221
+ # -- measurement ----------------------------------------------------------------
222
+
223
+ def measure(self, canvas: Any, w: float, h: float) -> tuple[float, float, float, float]:
224
+ """Margins (left, right, top, bottom) around axes ``w`` × ``h``."""
225
+ plot = self.plot
226
+ x, y = self.x, self.y
227
+ w, h = max(w, 1.0), max(h, 1.0)
228
+ x.compute_ticks(canvas, w, True)
229
+ y.compute_ticks(canvas, h, False)
230
+ self.ticks_for = (w, h)
231
+ left = right = top = bottom = 0.0
232
+ over_l = over_r = over_t = over_b = 0.0
233
+ y_offset = False # a ×10ᵏ factor or date part above the y axis
234
+ xa, ya = plot.x_axis, plot.y_axis
235
+ if self.axes_on and xa.get("visible"):
236
+ out, _ = x.tick_out()
237
+ font = x.tick_font()
238
+ labels = [(t, t.label) for t in x.ticks if t.major and t.label is not None
239
+ and not x.hide_labels]
240
+ xh = out + xa.get("spine_offset")
241
+ mirror = (out + xa.get("spine_offset")) if xa.get("mirror") and x.ticks else 0.0
242
+ if labels:
243
+ xh += xa.get("tick_pad") + max(box(canvas, lab, font, x.rotation)[1]
244
+ for _, lab in labels)
245
+ mapper = x.mapper(0.0, w)
246
+ for t, lab in labels:
247
+ bw = box(canvas, lab, font, x.rotation)[0]
248
+ p = mapper(t.value)
249
+ if x.rotation:
250
+ over_l = max(over_l, bw - p)
251
+ else:
252
+ over_l = max(over_l, bw / 2 - p)
253
+ over_r = max(over_r, p + bw / 2 - w)
254
+ content = None if x.hide_labels else _label_content(x)
255
+ offset = None if x.hide_labels else _offset_content(x)
256
+ row = 0.0
257
+ if content is not None:
258
+ _, a, d = measure(canvas, content, x.label_font())
259
+ row = a + d
260
+ if offset is not None:
261
+ row = max(row, box(canvas, offset, font, 0.0)[1])
262
+ if row:
263
+ xh += xa.get("label_pad") + row
264
+ if x.on_far_side():
265
+ top, bottom = xh, mirror
266
+ else:
267
+ top, bottom = mirror, xh
268
+ if self.axes_on and ya.get("visible"):
269
+ out, _ = y.tick_out()
270
+ font = y.tick_font()
271
+ labels = [(t, t.label) for t in y.ticks if t.major and t.label is not None
272
+ and not y.hide_labels]
273
+ yw = out + ya.get("spine_offset")
274
+ mirror = (out + ya.get("spine_offset")) if ya.get("mirror") and y.ticks else 0.0
275
+ if labels:
276
+ yw += ya.get("tick_pad") + max(box(canvas, lab, font, y.rotation)[0]
277
+ for _, lab in labels)
278
+ mapper = y.mapper(h, 0.0)
279
+ for t, lab in labels:
280
+ bh = box(canvas, lab, font, y.rotation)[1]
281
+ p = mapper(t.value)
282
+ over_t = max(over_t, bh / 2 - p)
283
+ over_b = max(over_b, p + bh / 2 - h)
284
+ content = None if y.hide_labels else _label_content(y)
285
+ if content is not None:
286
+ _, a, d = measure(canvas, content, y.label_font())
287
+ yw += ya.get("label_pad") + a + d
288
+ if y.on_far_side():
289
+ left, right = mirror, yw
290
+ else:
291
+ left, right = yw, mirror
292
+ offset = None if y.hide_labels else _offset_content(y)
293
+ if offset is not None:
294
+ ow, oh = box(canvas, offset, font, 0.0)
295
+ top += ya.get("tick_pad") + oh
296
+ y_offset = True
297
+ if y.on_far_side():
298
+ over_r = max(over_r, ow - yw)
299
+ else:
300
+ over_l = max(over_l, ow - yw)
301
+ self._twin_extent = [0.0, 0.0, 0.0, 0.0]
302
+ for tw in self.twins:
303
+ tl, tr, tt, tb = tw.measure(canvas, w, h)
304
+ if tw.plot.twin_of[1] == "y":
305
+ left, right = left + tl, right + tr
306
+ over_t, over_b = max(over_t, tt), max(over_b, tb)
307
+ else:
308
+ top, bottom = top + tt, bottom + tb
309
+ over_l, over_r = max(over_l, tl), max(over_r, tr)
310
+ for k, v in enumerate((tl, tr, tt, tb)):
311
+ self._twin_extent[k] += v
312
+ title = plot.title
313
+ text = title.get("text")
314
+ if text is not None:
315
+ _, a, d = measure(canvas, text, self.title_font())
316
+ stacked = x.on_far_side() or y_offset
317
+ top = (top if stacked else 0.0) + max(
318
+ title.get("pad") + a + d, 0.0 if stacked else top)
319
+ lw, lh = self.legend.measure(canvas)
320
+ pos = self.legend.position()
321
+ if lw and pos == "outside right":
322
+ right += plot.legend.get("pad") + lw
323
+ elif lh and pos == "outside top":
324
+ top += plot.legend.get("pad") + lh
325
+ elif lh and pos == "outside bottom":
326
+ bottom += plot.legend.get("pad") + lh
327
+ for bar in self.bars:
328
+ side = bar.position
329
+ extent = bar.measure_attached(canvas, w if side in ("top", "bottom") else h)
330
+ if side == "right":
331
+ right += extent
332
+ elif side == "left":
333
+ left += extent
334
+ elif side == "top":
335
+ top += extent
336
+ else:
337
+ bottom += extent
338
+ return (max(left, over_l), max(right, over_r), max(top, over_t), max(bottom, over_b))
339
+
340
+ def title_font(self) -> Font:
341
+ t = self.plot.title
342
+ return Font(t.get("font_size"), t.get("font_family"), t.get("font_weight"),
343
+ color=t.get("color"))
344
+
345
+ def aspect_rect(self, rect: tuple[float, float, float, float]) -> tuple:
346
+ aspect = self.plot.get("aspect") or (None if self.axes_on else 1.0)
347
+ if not aspect:
348
+ return rect
349
+ x0, y0, x1, y1 = rect
350
+ w, h = x1 - x0, y1 - y0
351
+ dx = abs(self.x.fhi - self.x.flo) or 1.0
352
+ dy = abs(self.y.fhi - self.y.flo) or 1.0
353
+ want = aspect * dy / dx # h / w
354
+ if h > w * want:
355
+ nh = w * want
356
+ y0 += (h - nh) / 2
357
+ y1 = y0 + nh
358
+ else:
359
+ nw = h / want
360
+ x0 += (w - nw) / 2
361
+ x1 = x0 + nw
362
+ return (x0, y0, x1, y1)
363
+
364
+ # -- drawing --------------------------------------------------------------------
365
+
366
+ def draw(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
367
+ plot = self.plot
368
+ x0, y0, x1, y1 = rect
369
+ w, h = x1 - x0, y1 - y0
370
+ if self.ticks_for is None or abs(self.ticks_for[0] - w) > 0.01 \
371
+ or abs(self.ticks_for[1] - h) > 0.01:
372
+ self.x.compute_ticks(canvas, w, True)
373
+ self.y.compute_ticks(canvas, h, False)
374
+ palette = self.palette
375
+ ctx = DrawContext(canvas, plot, rect, self.x, self.y, self.maps, palette)
376
+ ctx.figure_size = getattr(self, "figure_size", (x1, y1))
377
+ bg = plot.get("background")
378
+ if bg is not None:
379
+ canvas.save()
380
+ canvas.fill_style = resolve_color(bg, palette)
381
+ canvas.fill_rect(x0, y0, w, h)
382
+ canvas.restore()
383
+ if self.axes_on:
384
+ self._grid(canvas, ctx)
385
+ items = sorted(list(self.series) + self.decorations, key=_z)
386
+ clipped = None
387
+ try:
388
+ for s in items:
389
+ want = bool(plot.get("clip")) and (s.get("clip") if "clip" in s.SETTINGS
390
+ else True)
391
+ if want != clipped:
392
+ if clipped is not None:
393
+ canvas.restore()
394
+ canvas.save()
395
+ if want:
396
+ canvas.begin_path()
397
+ canvas.rect(x0, y0, w, h)
398
+ canvas.clip()
399
+ canvas.begin_path()
400
+ clipped = want
401
+ r = s.get("rasterize") if "rasterize" in s.SETTINGS else None
402
+ if r:
403
+ dpi = None if r is True else float(r)
404
+ with canvas.group(rasterize=True, dpi=dpi):
405
+ s.draw(ctx)
406
+ else:
407
+ s.draw(ctx)
408
+ finally:
409
+ if clipped is not None:
410
+ canvas.restore()
411
+ tctxs = []
412
+ for t in self.twins:
413
+ if t.ticks_for is None or abs(t.ticks_for[0] - w) > 0.01 \
414
+ or abs(t.ticks_for[1] - h) > 0.01:
415
+ t.x.compute_ticks(canvas, w, True)
416
+ t.y.compute_ticks(canvas, h, False)
417
+ tctx = DrawContext(canvas, t.plot, rect, t.x, t.y, self.maps, palette)
418
+ tctx.figure_size = ctx.figure_size
419
+ tctxs.append((t, tctx))
420
+ t._grid(canvas, tctx)
421
+ canvas.save()
422
+ if plot.get("clip"):
423
+ canvas.begin_path()
424
+ canvas.rect(x0, y0, w, h)
425
+ canvas.clip()
426
+ canvas.begin_path()
427
+ for s in sorted(t.series, key=_z):
428
+ s.draw(tctx)
429
+ canvas.restore()
430
+ for s in self.series:
431
+ s.draw_overlay(ctx)
432
+ border = plot.get("border")
433
+ if border is not None and plot.get("border_width"):
434
+ canvas.save()
435
+ canvas.stroke_style = resolve_color(border, palette)
436
+ canvas.line_width = plot.get("border_width")
437
+ canvas.begin_path()
438
+ canvas.rect(x0, y0, w, h)
439
+ canvas.stroke()
440
+ canvas.restore()
441
+ if self.axes_on:
442
+ self._spines(canvas, rect)
443
+ self._axis_x(canvas, ctx)
444
+ self._axis_y(canvas, ctx)
445
+ for t, tctx in tctxs:
446
+ t._spines(canvas, rect)
447
+ t._axis_x(canvas, tctx)
448
+ t._axis_y(canvas, tctx)
449
+ if t.legend.active():
450
+ t.legend.draw(canvas, tctx)
451
+ if self.twins: # attached items start beyond the twin axes
452
+ self._left_used += self._twin_extent[0]
453
+ self._right_used += self._twin_extent[1]
454
+ self._top_used += self._twin_extent[2]
455
+ self._bottom_used += self._twin_extent[3]
456
+ self._top_bars = sum(b.measure_attached(canvas, w) for b in self.bars
457
+ if b.position == "top")
458
+ self._title(canvas, rect)
459
+ self.legend.draw(canvas, ctx)
460
+ offsets = {"left": 0.0, "right": 0.0, "top": 0.0, "bottom": 0.0}
461
+ for bar in self.bars:
462
+ offsets[bar.position] += bar.draw_attached(canvas, rect, offsets[bar.position],
463
+ self.attached_base(bar.position))
464
+
465
+ def attached_base(self, side: str) -> float:
466
+ """Distance from the axes to where attached colorbars start on ``side``."""
467
+ if side == "right" and self.legend.outside() == "outside right":
468
+ return self._right_used + self.plot.legend.get("pad") + self.legend.size[0]
469
+ if side == "right":
470
+ return self._right_used
471
+ if side == "top" and self._top_used:
472
+ return self._top_used
473
+ if side == "bottom" and self.legend.outside() == "outside bottom":
474
+ return self._bottom_used + self.plot.legend.get("pad") + self.legend.size[1]
475
+ if side == "bottom":
476
+ return self._bottom_used
477
+ if side == "left":
478
+ return self._left_used
479
+ if side == "top" and self.legend.outside() == "outside top":
480
+ return self.plot.legend.get("pad") + self.legend.size[1]
481
+ return 0.0
482
+
483
+ _bottom_used = 0.0
484
+ _left_used = 0.0
485
+ _top_used = 0.0
486
+ _right_used = 0.0
487
+ _top_bars = 0.0
488
+
489
+ def _grid(self, canvas: Any, ctx: DrawContext) -> None:
490
+ for state, horizontal in ((self.x, False), (self.y, True)):
491
+ axis = state.axis
492
+ if axis.get("minor_grid"):
493
+ self._grid_lines(canvas, ctx, state.minor_grid_positions(), horizontal,
494
+ axis.get("minor_grid_color"), axis.get("minor_grid_width"),
495
+ axis.get("minor_grid_style"))
496
+ if axis.get("grid"):
497
+ self._grid_lines(canvas, ctx, [t.value for t in state.ticks if t.major],
498
+ horizontal, axis.get("grid_color"), axis.get("grid_width"),
499
+ axis.get("grid_style"))
500
+ for state, horizontal in ((self.x, False), (self.y, True)):
501
+ axis = state.axis
502
+ if (axis.get("zero_line") and state.scale is not None
503
+ and min(state.lo, state.hi) <= 0.0 <= max(state.lo, state.hi)
504
+ and math.isfinite(state.f(0.0))):
505
+ self._grid_lines(canvas, ctx, [0.0], horizontal, axis.get("zero_line_color"),
506
+ axis.get("zero_line_width"), "solid")
507
+
508
+ def _grid_lines(self, canvas: Any, ctx: DrawContext, values: list[float],
509
+ horizontal: bool, color: Any, width: float, style: Any) -> None:
510
+ from .style import dash_of
511
+ x0, y0, x1, y1 = ctx.rect
512
+ canvas.save()
513
+ canvas.stroke_style = resolve_color(color, ctx.palette)
514
+ canvas.line_width = width
515
+ canvas.line_dash = dash_of(style, width)
516
+ canvas.begin_path()
517
+ mapper = ctx.ymap if horizontal else ctx.xmap
518
+ for v in values:
519
+ p = mapper(v)
520
+ if horizontal:
521
+ canvas.move_to(x0, p)
522
+ canvas.line_to(x1, p)
523
+ else:
524
+ canvas.move_to(p, y0)
525
+ canvas.line_to(p, y1)
526
+ canvas.stroke()
527
+ canvas.restore()
528
+
529
+ def _spines(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
530
+ x0, y0, x1, y1 = rect
531
+ spines = set(self.plot.get("spines"))
532
+ swap = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}
533
+ if self.x.on_far_side():
534
+ spines = {swap[s] if s in ("top", "bottom") else s for s in spines}
535
+ if self.y.on_far_side():
536
+ spines = {swap[s] if s in ("left", "right") else s for s in spines}
537
+ if not self.plot.is_set("spines"):
538
+ if self.x.bare:
539
+ spines -= {"bottom", "top"}
540
+ if self.y.bare:
541
+ spines -= {"left", "right"}
542
+ xa, ya = self.plot.x_axis, self.plot.y_axis
543
+ ox, oy = xa.get("spine_offset"), ya.get("spine_offset")
544
+ xs0, xs1 = self._trim(self.x, xa, x0, x1, True)
545
+ ys0, ys1 = self._trim(self.y, ya, y0, y1, False)
546
+ lines = {"left": (ya, x0 - oy, ys0, x0 - oy, ys1),
547
+ "right": (ya, x1 + oy, ys0, x1 + oy, ys1),
548
+ "bottom": (xa, xs0, y1 + ox, xs1, y1 + ox),
549
+ "top": (xa, xs0, y0 - ox, xs1, y0 - ox)}
550
+ canvas.save()
551
+ canvas.line_cap = "square"
552
+ for side in ("left", "right", "bottom", "top"):
553
+ if side not in spines:
554
+ continue
555
+ axis, a, b, c, d = lines[side]
556
+ lw = axis.get("line_width")
557
+ if not lw:
558
+ continue
559
+ canvas.stroke_style = resolve_color(axis.get("line_color"), self.palette)
560
+ canvas.line_width = lw
561
+ canvas.begin_path()
562
+ canvas.move_to(a, b)
563
+ canvas.line_to(c, d)
564
+ canvas.stroke()
565
+ canvas.restore()
566
+
567
+ def _trim(self, state: AxisState, axis: Any, p0: float, p1: float,
568
+ horizontal: bool) -> tuple[float, float]:
569
+ """Extent of an axis line: the axes side, or (``spine_trim``) first to last major
570
+ tick."""
571
+ if not axis.get("spine_trim"):
572
+ return p0, p1
573
+ majors = [t.value for t in state.ticks if t.major]
574
+ if not majors:
575
+ return p0, p1
576
+ m = state.mapper(p0, p1) if horizontal else state.mapper(p1, p0)
577
+ ps = [m(v) for v in majors]
578
+ return min(ps), max(ps)
579
+
580
+ def _ticks(self, canvas: Any, state: AxisState, mapper: Any, horizontal: bool,
581
+ base: float, flip: bool = False) -> None:
582
+ """Tick marks; ``base`` is the axis line coordinate (y for x axes, x for y axes);
583
+ ``flip``: on the opposite side of the axes (``mirror``)."""
584
+ axis = state.axis
585
+ out, inside = state.tick_out()
586
+ if not state.ticks or not (out or inside):
587
+ return
588
+ if flip:
589
+ out, inside = -out, -inside
590
+ canvas.save()
591
+ canvas.stroke_style = resolve_color(axis.get("tick_color"), self.palette)
592
+ canvas.line_width = axis.get("tick_width")
593
+ ml = axis.get("minor_tick_length") * (-1.0 if flip else 1.0)
594
+ m_out, m_in = (ml if out else 0.0), (ml if inside else 0.0)
595
+ canvas.begin_path()
596
+ for t in state.ticks:
597
+ p = mapper(t.value)
598
+ o, i = (out, inside) if t.major else (m_out, m_in)
599
+ if horizontal:
600
+ canvas.move_to(p, base - i)
601
+ canvas.line_to(p, base + o)
602
+ else:
603
+ canvas.move_to(base + i, p)
604
+ canvas.line_to(base - o, p)
605
+ canvas.stroke()
606
+ canvas.restore()
607
+
608
+ def _axis_x(self, canvas: Any, ctx: DrawContext) -> None:
609
+ axis = self.plot.x_axis
610
+ x0, y0, x1, y1 = ctx.rect
611
+ self._bottom_used = self._top_used = 0.0
612
+ if not axis.get("visible"):
613
+ return
614
+ state = self.x
615
+ top = state.on_far_side()
616
+ sign = -1.0 if top else 1.0 # away from the axes
617
+ off = axis.get("spine_offset")
618
+ edge, other = (y0, y1) if top else (y1, y0)
619
+ base = edge + sign * off
620
+ self._ticks(canvas, state, ctx.xmap, True, base, flip=top)
621
+ if axis.get("mirror"):
622
+ self._ticks(canvas, state, ctx.xmap, True, other - sign * off, flip=not top)
623
+ out, _ = state.tick_out()
624
+ y = base + sign * out
625
+ va = "bottom" if top else "top"
626
+ font = state.tick_font()
627
+ labels = [t for t in state.ticks if t.major and t.label is not None
628
+ and not state.hide_labels]
629
+ if labels:
630
+ y += sign * axis.get("tick_pad")
631
+ rows = 0.0
632
+ for t in labels:
633
+ p = ctx.xmap(t.value)
634
+ if state.rotation:
635
+ draw_text(canvas, t.label, p, y, font, "left" if top else "right", va,
636
+ state.rotation)
637
+ else:
638
+ draw_text(canvas, t.label, p, y, font, "center", va)
639
+ rows = max(rows, box(canvas, t.label, font, state.rotation)[1])
640
+ y += sign * rows
641
+ content = None if state.hide_labels else _label_content(state)
642
+ offset = None if state.hide_labels else _offset_content(state)
643
+ if content is not None or offset is not None:
644
+ y += sign * axis.get("label_pad")
645
+ row = 0.0
646
+ if content is not None:
647
+ lf = state.label_font()
648
+ draw_text(canvas, content, (x0 + x1) / 2, y, lf, "center", va)
649
+ _, a, d = measure(canvas, content, lf)
650
+ row = a + d
651
+ if offset is not None:
652
+ draw_text(canvas, offset, x1, y, font, "right", va)
653
+ row = max(row, box(canvas, offset, font, 0.0)[1])
654
+ y += sign * row
655
+ if top:
656
+ self._top_used = y0 - y
657
+ else:
658
+ self._bottom_used = y - y1
659
+
660
+ def _axis_y(self, canvas: Any, ctx: DrawContext) -> None:
661
+ axis = self.plot.y_axis
662
+ x0, y0, x1, y1 = ctx.rect
663
+ self._left_used = self._right_used = 0.0
664
+ if not axis.get("visible"):
665
+ return
666
+ state = self.y
667
+ right = state.on_far_side()
668
+ sign = 1.0 if right else -1.0 # away from the axes
669
+ off = axis.get("spine_offset")
670
+ edge, other = (x1, x0) if right else (x0, x1)
671
+ base = edge + sign * off
672
+ self._ticks(canvas, state, ctx.ymap, False, base, flip=right)
673
+ if axis.get("mirror"):
674
+ self._ticks(canvas, state, ctx.ymap, False, other - sign * off, flip=not right)
675
+ out, _ = state.tick_out()
676
+ x = base + sign * out
677
+ font = state.tick_font()
678
+ labels = [t for t in state.ticks if t.major and t.label is not None
679
+ and not state.hide_labels]
680
+ if labels:
681
+ x += sign * axis.get("tick_pad")
682
+ widest = 0.0
683
+ for t in labels:
684
+ p = ctx.ymap(t.value)
685
+ draw_text(canvas, t.label, x, p, font, "left" if right else "right", "center",
686
+ state.rotation)
687
+ widest = max(widest, box(canvas, t.label, font, state.rotation)[0])
688
+ x += sign * widest
689
+ content = None if state.hide_labels else _label_content(state)
690
+ if content is not None:
691
+ x += sign * axis.get("label_pad")
692
+ lf = state.label_font()
693
+ draw_text(canvas, content, x, (y0 + y1) / 2, lf, "center",
694
+ "top" if right else "bottom", 90.0)
695
+ _, a, d = measure(canvas, content, lf)
696
+ x += sign * (a + d)
697
+ if right:
698
+ self._right_used = x - x1
699
+ else:
700
+ self._left_used = x0 - x
701
+ offset = None if state.hide_labels else _offset_content(state)
702
+ if offset is not None:
703
+ above = self._top_used or (self.x.tick_out()[0] if self.plot.x_axis.get("mirror")
704
+ else 0.0)
705
+ yb = y0 - above - axis.get("tick_pad")
706
+ draw_text(canvas, offset, base, yb, font, "right" if right else "left", "bottom")
707
+ self._top_used = above + axis.get("tick_pad") + box(canvas, offset, font, 0.0)[1]
708
+
709
+ def _title(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
710
+ t = self.plot.title
711
+ text = t.get("text")
712
+ if text is None:
713
+ return
714
+ x0, y0, x1, _ = rect
715
+ y = y0 - t.get("pad") - self._top_bars - self._top_used
716
+ if self.legend.outside() == "outside top":
717
+ y -= self.plot.legend.get("pad") + self.legend.size[1]
718
+ if t.get("over") == "plot":
719
+ x0 -= self._left_used
720
+ align = t.get("align")
721
+ x = {"center": (x0 + x1) / 2, "left": x0, "right": x1}[align]
722
+ ha = {"center": "center", "left": "left", "right": "right"}[align]
723
+ draw_text(canvas, text, x, y, self.title_font(), ha, "bottom")
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # Layout
728
+ # ---------------------------------------------------------------------------
729
+
730
+ class Layout:
731
+ """Solved geometry: figure size and the rectangles of every area (points)."""
732
+
733
+ def __init__(self, areas: list[Area], width: float, height: float) -> None:
734
+ self.areas = areas
735
+ self.width = width
736
+ self.height = height
737
+ self.title: tuple[float, float, float] | None = None # x, y (top), width
738
+ self.rule: tuple[float, float, float, float] | None = None # x, y, width, height
739
+
740
+
741
+ def _split(n: int, ratios: list[float] | None, what: str) -> list[float]:
742
+ if ratios is None:
743
+ return [1.0] * n
744
+ if len(ratios) != n:
745
+ raise ValueError(f"{what} has {len(ratios)} values for {n} {'columns' if what == 'widths' else 'rows'}")
746
+ return list(ratios)
747
+
748
+
749
+ def _solve_axis(areas: list[Area], n: int, ratios: list[float], fixed_total: float | None,
750
+ cell: float, spacing: float, margin: float, align: str, horizontal: bool,
751
+ fixed_cells: dict[int, float]) -> tuple[list[float], list[float], float]:
752
+ """Positions and sizes of the cells along one direction, and the total length.
753
+ ``fixed_cells``: cells whose size is given (colorbar-only columns)."""
754
+ if horizontal:
755
+ def span(a): return a.c0, a.c1
756
+ def need(a): return a.margins[0], a.margins[1]
757
+ else:
758
+ def span(a): return a.r0, a.r1
759
+ def need(a): return a.margins[2], a.margins[3]
760
+ free = [i for i in range(n) if i not in fixed_cells]
761
+ rsum = sum(ratios[i] for i in free) or 1.0
762
+ if align == "axes":
763
+ lo_need = [0.0] * (n + 1)
764
+ hi_need = [0.0] * (n + 1)
765
+ for a in areas:
766
+ s0, s1 = span(a)
767
+ m0, m1 = need(a)
768
+ lo_need[s0] = max(lo_need[s0], m0)
769
+ hi_need[s1] = max(hi_need[s1], m1)
770
+ gut = [hi_need[i] + lo_need[i] + (margin if i in (0, n) else spacing)
771
+ for i in range(n + 1)]
772
+ if fixed_total is not None:
773
+ avail = fixed_total - sum(gut) - sum(fixed_cells.values())
774
+ sizes = [fixed_cells[i] if i in fixed_cells else max(1.0, avail * ratios[i] / rsum)
775
+ for i in range(n)]
776
+ else:
777
+ sizes = [fixed_cells.get(i, cell * ratios[i]) for i in range(n)]
778
+ pos = []
779
+ p = gut[0]
780
+ for i in range(n):
781
+ pos.append(p)
782
+ p += sizes[i] + gut[i + 1]
783
+ return pos, sizes, p
784
+ if fixed_total is not None:
785
+ avail = fixed_total - 2 * margin - spacing * (n - 1) - sum(fixed_cells.values())
786
+ sizes = [fixed_cells[i] if i in fixed_cells else max(1.0, avail * ratios[i] / rsum)
787
+ for i in range(n)]
788
+ else:
789
+ sizes = [fixed_cells.get(i, cell * ratios[i]) for i in range(n)]
790
+ pos = []
791
+ p = margin
792
+ for i in range(n):
793
+ pos.append(p)
794
+ p += sizes[i] + (spacing if i < n - 1 else 0.0)
795
+ return pos, sizes, p + margin
796
+
797
+
798
+ def _bar_cells(areas: list[Area], n: int, horizontal: bool, align: str) -> dict[int, float]:
799
+ """Cells covered only by colorbars running across this direction: their size is the
800
+ bar's (``axes``) or the bar's box (``borders``)."""
801
+ out: dict[int, float] = {}
802
+ for i in range(n):
803
+ covering = [a for a in areas
804
+ if (a.c0 <= i < a.c1 if horizontal else a.r0 <= i < a.r1)]
805
+ if not covering or not all(a.is_bar for a in covering):
806
+ continue
807
+ sizes = []
808
+ for a in covering:
809
+ bar = a.state
810
+ if bar.vertical != horizontal:
811
+ break
812
+ t = bar.thickness
813
+ if align == "borders":
814
+ m = a.margins
815
+ t += (m[0] + m[1]) if horizontal else (m[2] + m[3])
816
+ sizes.append(t)
817
+ else:
818
+ out[i] = max(sizes)
819
+ return out
820
+
821
+
822
+ def solve(fig: Any, areas: list[Area], nr: int, nc: int, canvas: Any,
823
+ fixed: tuple[float, float] | None = None, height: float | None = None) -> Layout:
824
+ """Measure and place every area (iterating until the axes sizes settle)."""
825
+ k = fig.points_per_unit
826
+ align = fig.get("align")
827
+ spacing = fig.get("spacing") * k
828
+ margin = fig.get("margin") * k
829
+ if fixed is not None:
830
+ cw, ch = fixed
831
+ fw = fh = None
832
+ else:
833
+ fw = fig.get("width") * k if fig.is_set("width") else None
834
+ fh = fig.get("height") * k if fig.is_set("height") else None
835
+ if height is not None:
836
+ fh = height
837
+ cw = fig.get("plot_width") * k
838
+ ch = fig.get("plot_height") * k
839
+ wr = _split(nc, fig.get("widths") if fixed is None else None, "widths")
840
+ hr = _split(nr, fig.get("heights") if fixed is None else None, "heights")
841
+ sizes = {id(a): (cw, ch) for a in areas}
842
+ xs: list[float] = []
843
+ ys: list[float] = []
844
+ W = H = 0.0
845
+ for it in range(5):
846
+ for a in areas:
847
+ aw, ah = sizes[id(a)]
848
+ a.margins = a.state.measure(canvas, aw, ah)
849
+ xb = {} if (fig.is_set("widths") and fixed is None) else _bar_cells(areas, nc, True, align)
850
+ yb = {} if (fig.is_set("heights") and fixed is None) else _bar_cells(areas, nr, False, align)
851
+ xs, xw, W = _solve_axis(areas, nc, wr, fw, cw, spacing, margin, align, True, xb)
852
+ ys, yh, H = _solve_axis(areas, nr, hr, fh, ch, spacing, margin, align, False, yb)
853
+ changed = False
854
+ for a in areas:
855
+ x0 = xs[a.c0]
856
+ x1 = xs[a.c1 - 1] + xw[a.c1 - 1]
857
+ y0 = ys[a.r0]
858
+ y1 = ys[a.r1 - 1] + yh[a.r1 - 1]
859
+ l, r, t, b = a.margins
860
+ if align == "axes":
861
+ a.axes = (x0, y0, x1, y1)
862
+ a.box = (x0 - l, y0 - t, x1 + r, y1 + b)
863
+ else:
864
+ a.box = (x0, y0, x1, y1)
865
+ a.axes = (x0 + l, y0 + t, max(x0 + l + 1, x1 - r), max(y0 + t + 1, y1 - b))
866
+ if a.is_bar:
867
+ a.axes = a.state.bar_rect(a.axes)
868
+ new = (a.axes[2] - a.axes[0], a.axes[3] - a.axes[1])
869
+ old = sizes[id(a)]
870
+ if abs(new[0] - old[0]) > 0.25 or abs(new[1] - old[1]) > 0.25:
871
+ changed = True
872
+ sizes[id(a)] = new
873
+ if not changed:
874
+ break
875
+ for a in areas:
876
+ if not a.is_bar:
877
+ a.axes = a.state.aspect_rect(a.axes)
878
+ return Layout(areas, W, H)
879
+
880
+
881
+ def _states(fig: Any, areas: list[Area]) -> None:
882
+ from .colorbar import BarState
883
+ from .plot import Plot
884
+
885
+ from .legend import SizeLegend, SizeLegendState
886
+ plots = [a.obj for a in areas if isinstance(a.obj, Plot)]
887
+ series = [s for p in plots for s in p.series]
888
+ extra = []
889
+ bars = [a.obj for a in areas if not isinstance(a.obj, (Plot, SizeLegend))]
890
+ for p in plots:
891
+ bars.extend(p.colorbars)
892
+ for cb in bars:
893
+ m = cb.color_map()
894
+ if m is not None:
895
+ extra.append(m)
896
+ maps = resolve_maps(series, extra)
897
+ for a in areas:
898
+ if isinstance(a.obj, Plot):
899
+ a.state = PlotState(a.obj, maps)
900
+ elif isinstance(a.obj, SizeLegend):
901
+ a.state = SizeLegendState(a.obj, maps)
902
+ else:
903
+ a.state = BarState(a.obj, maps, None)
904
+ from .sharing import apply
905
+ apply(fig, areas)
906
+
907
+
908
+ def _title_layout(fig: Any, canvas: Any, width: float) -> Any:
909
+ t = fig.title
910
+ font = Font(t.get("font_size"), t.get("font_family"), t.get("font_weight"),
911
+ color=t.get("color"))
912
+ font.apply(canvas)
913
+ align = {"center": "center", "left": "start", "right": "end"}[t.get("align")]
914
+ return canvas.layout_text(t.get("text"), max(width, 1.0), align=align)
915
+
916
+
917
+ def _title_box(fig: Any, lay: Layout) -> tuple[float, float]:
918
+ """Left edge and width the figure title is aligned within."""
919
+ from .plot import Plot
920
+ margin = fig.get("margin") * fig.points_per_unit
921
+ plots = [a for a in lay.areas if isinstance(a.obj, Plot)]
922
+ if fig.title.get("over") == "axes" and plots:
923
+ x0 = min(a.axes[0] for a in plots)
924
+ x1 = max(a.axes[2] for a in plots)
925
+ return x0, x1 - x0
926
+ return margin, lay.width - 2 * margin
927
+
928
+
929
+ def _with_title(fig: Any, lay: Layout, areas: list[Area], nr: int, nc: int,
930
+ canvas: Any) -> Layout:
931
+ """The layout with the figure title's band at the top."""
932
+ if fig.title.get("text") is None:
933
+ return lay
934
+ x0, width = _title_box(fig, lay)
935
+ band = _title_layout(fig, canvas, width).height + fig.title.get("pad")
936
+ rule = 0.0
937
+ if fig.get("title_rule") is not None and fig.get("title_rule_width"):
938
+ rule = fig.get("title_rule_width") + 0.5 * fig.title.get("font_size")
939
+ band += rule
940
+ fixed = fig.is_set("height")
941
+ if fixed:
942
+ lay = solve(fig, areas, nr, nc, canvas, height=lay.height - band)
943
+ x0, width = _title_box(fig, lay)
944
+ for a in lay.areas:
945
+ a.axes = (a.axes[0], a.axes[1] + band, a.axes[2], a.axes[3] + band)
946
+ a.box = (a.box[0], a.box[1] + band, a.box[2], a.box[3] + band)
947
+ lay.height += band
948
+ top = fig.get("margin") * fig.points_per_unit
949
+ lay.title = (x0, top + rule, width)
950
+ if rule:
951
+ lay.rule = (x0, top, width, fig.get("title_rule_width"))
952
+ return lay
953
+
954
+
955
+ def _context(a: Area) -> Any:
956
+ """A drawing context for area ``a`` once placed (no canvas: for layout decisions)."""
957
+ from .series import DrawContext
958
+ st = a.state
959
+ return DrawContext(None, a.obj, a.axes, st.x, st.y, st.maps, st.palette)
960
+
961
+
962
+ def _refine(fig: Any, areas: list[Area], nr: int, nc: int, canvas: Any,
963
+ lay: Layout) -> Layout:
964
+ """Decisions that need the placed axes: hexagon lattices in page units (which change the
965
+ counts, hence the color maps), then legend positions for ``"auto"`` (which may move a
966
+ legend outside, changing the margins)."""
967
+ from .plot import Plot
968
+ for _ in range(3):
969
+ changed = False
970
+ for a in areas:
971
+ if isinstance(a.obj, Plot):
972
+ ctx = _context(a)
973
+ for s in a.obj.series:
974
+ frame = getattr(s, "set_frame", None)
975
+ if frame is not None and frame(ctx):
976
+ changed = True
977
+ if not changed:
978
+ break
979
+ _states(fig, areas)
980
+ lay = solve(fig, areas, nr, nc, canvas)
981
+ moved = False
982
+ for a in areas:
983
+ if isinstance(a.obj, Plot):
984
+ leg = a.state.legend
985
+ if leg.active() and a.obj.legend.get("position") == "auto":
986
+ leg.choice = leg.choose(_context(a))
987
+ moved = moved or leg.choice.startswith("outside")
988
+ if moved:
989
+ lay = solve(fig, areas, nr, nc, canvas)
990
+ return lay
991
+
992
+
993
+ def layout(fig: Any, canvas: Any, only: Any = None) -> Layout:
994
+ areas, nr, nc = arrange(fig, None)
995
+ _states(fig, areas)
996
+ lay = solve(fig, areas, nr, nc, canvas)
997
+ lay = _refine(fig, areas, nr, nc, canvas, lay)
998
+ if only is None:
999
+ return _with_title(fig, lay, areas, nr, nc, canvas)
1000
+ target = next(a for a in areas if a.obj is only)
1001
+ if fig.get("align") == "axes":
1002
+ rect = target.axes
1003
+ else:
1004
+ rect = target.box
1005
+ single = [Area(only, 0, 0, 1, 1)]
1006
+ single[0].state = target.state
1007
+ target.state.x.hide_labels = target.state.y.hide_labels = False # alone: outer
1008
+ return solve(fig, single, 1, 1, canvas, fixed=(rect[2] - rect[0], rect[3] - rect[1]))
1009
+
1010
+
1011
+ # ---------------------------------------------------------------------------
1012
+ # Output
1013
+ # ---------------------------------------------------------------------------
1014
+
1015
+ def _draw_layout(fig: Any, lay: Layout, canvas: Any) -> None:
1016
+ bg = fig.get("background")
1017
+ if bg is not None:
1018
+ canvas.save()
1019
+ from .palettes import palette_colors
1020
+ canvas.fill_style = resolve_color(bg, palette_colors(fig.get("palette")))
1021
+ canvas.fill_rect(0, 0, lay.width, lay.height)
1022
+ canvas.restore()
1023
+ for a in lay.areas:
1024
+ a.state.figure_size = (lay.width, lay.height)
1025
+ if a.is_bar:
1026
+ a.state.draw_area(canvas, a.axes)
1027
+ else:
1028
+ a.state.draw(canvas, a.axes)
1029
+ if lay.rule is not None:
1030
+ rx, ry, rw, rh = lay.rule
1031
+ canvas.save()
1032
+ canvas.fill_style = resolve_color(fig.get("title_rule"), ())
1033
+ canvas.fill_rect(rx, ry, rw, rh)
1034
+ canvas.restore()
1035
+ if lay.title is not None:
1036
+ x, y, width = lay.title
1037
+ canvas.save()
1038
+ _title_layout(fig, canvas, width).draw(x, y)
1039
+ canvas.restore()
1040
+
1041
+
1042
+ def draw(fig: Any, canvas: Any, x: float = 0.0, y: float = 0.0,
1043
+ only: Any = None) -> tuple[float, float]:
1044
+ """Draw on a pdf_io canvas at ``(x, y)`` (page units); returns the size in points."""
1045
+ canvas.save()
1046
+ try:
1047
+ scale = 1.0 / canvas.unit_scale
1048
+ canvas.translate(x, y)
1049
+ if scale != 1.0:
1050
+ canvas.scale(scale)
1051
+ lay = layout(fig, canvas, only)
1052
+ _draw_layout(fig, lay, canvas)
1053
+ finally:
1054
+ canvas.restore()
1055
+ return lay.width, lay.height
1056
+
1057
+
1058
+ _DOC_KEYS = ("title", "author", "subject", "keywords", "creator", "compress", "compression",
1059
+ "font_dirs", "system_fonts", "base_dir", "color_mode", "ink_limit",
1060
+ "icc_profile", "pdf_a", "creation_date", "simplify")
1061
+
1062
+
1063
+ def _render(fig: Any, only: Any, options: dict[str, Any],
1064
+ pdf: bool) -> tuple[Any, Any, dict[str, Any]]:
1065
+ # quality: of rasterized content in a PDF, of the image otherwise
1066
+ keys = _DOC_KEYS + ("quality",) if pdf else _DOC_KEYS
1067
+ doc_opts = {k: v for k, v in options.items() if k in keys}
1068
+ if pdf and "quality" not in doc_opts and fig.get("quality") is not None:
1069
+ doc_opts["quality"] = fig.get("quality")
1070
+ rest = {k: v for k, v in options.items() if k not in keys}
1071
+ unknown = set(rest) - {"dpi", "background", "quality"}
1072
+ if unknown:
1073
+ raise ValueError(f"unknown output option {sorted(unknown)[0]!r}")
1074
+ doc = pdf_io.Document(**doc_opts)
1075
+ scratch = pdf_io.Document(font_db=doc.font_db, pdf_a=doc_opts.get("pdf_a"))
1076
+ probe = scratch.add_page(100, 100)
1077
+ lay = layout(fig, probe, only)
1078
+ page = doc.add_page(max(lay.width, 1.0), max(lay.height, 1.0), unit="pt")
1079
+ _draw_layout(fig, lay, page)
1080
+ return doc, page, rest
1081
+
1082
+
1083
+ def size(fig: Any, only: Any = None) -> tuple[float, float]:
1084
+ probe = pdf_io.Document().add_page(100, 100)
1085
+ lay = layout(fig, probe, only)
1086
+ return lay.width, lay.height
1087
+
1088
+
1089
+ def to_bytes(fig: Any, format: str = "pdf", only: Any = None, **options: Any) -> bytes:
1090
+ fmt = format.lower().lstrip(".")
1091
+ if fmt == "pdf":
1092
+ doc, page, rest = _render(fig, only, options, True)
1093
+ else:
1094
+ # An image: nothing is culled (see _backend.simplify_arg), so the page is not
1095
+ # given the document-level simplify either.
1096
+ with _backend.image_output():
1097
+ doc, page, rest = _render(fig, only, {k: v for k, v in options.items()
1098
+ if k != "simplify"}, False)
1099
+ dpi = rest.get("dpi", fig.get("dpi"))
1100
+ bg = fig.get("background")
1101
+ bg = rest.get("background", fig.get("image_background") if bg is None else bg)
1102
+ if fmt == "pdf":
1103
+ if set(rest) - {"background"}:
1104
+ raise ValueError("dpi applies to PNG, JPEG and JPEG XL output")
1105
+ return doc.to_bytes()
1106
+ if fmt == "png":
1107
+ return page.to_png(dpi=dpi, background=bg)
1108
+ quality = rest.get("quality", fig.get("quality"))
1109
+ q = {} if quality is None else {"quality": quality}
1110
+ if fmt in ("jpg", "jpeg"):
1111
+ return page.to_jpeg(dpi=dpi, background=bg, **q)
1112
+ if fmt == "jxl":
1113
+ return page.to_jxl(dpi=dpi, background=bg, **q)
1114
+ raise ValueError(f"unknown format {format!r} (use pdf, png, jpeg or jxl)")
1115
+
1116
+
1117
+ def save(fig: Any, path: Any, only: Any = None, **options: Any) -> None:
1118
+ import os
1119
+ ext = os.path.splitext(os.fspath(path))[1].lower().lstrip(".") or "pdf"
1120
+ data = to_bytes(fig, ext, only, **options)
1121
+ with open(path, "wb") as f:
1122
+ f.write(data)