plot3 0.4.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.
plot3/static.py ADDED
@@ -0,0 +1,3760 @@
1
+ """Offline PNG, SVG, and PDF export.
2
+
3
+ PNG, SVG, and PDF replay one list of drawing commands. SVG is text.
4
+ PNG and PDF are that same SVG rendered by cairosvg when the optional
5
+ ``plot3[export]`` extra is installed, so the three files share one
6
+ drawing and one font. That extra needs the Cairo C library as well as
7
+ the Python package (``libcairo2``, or the GTK runtime on Windows).
8
+ Without cairosvg, PNG falls back to a zlib RGB file and a built-in
9
+ 5×7 font, and PDF raises with an install hint. ``.svg`` needs nothing
10
+ extra.
11
+ The geometry is the static channel already stored on each layer (the
12
+ last frame of a transition, the low end of a slider).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import contextvars
19
+ import gzip
20
+ import io
21
+ import math
22
+ import re
23
+ import struct
24
+ import zlib
25
+ from pathlib import Path
26
+ from xml.sax.saxutils import escape, quoteattr
27
+
28
+ import numpy as np
29
+
30
+ from plot3.__version__ import __version__
31
+
32
+ _MAX_PX = 8192
33
+ _CSS_DPI = 96.0
34
+ _PAD = 0.03 # same normalized camera pad as the 2D viewer
35
+ _DEFAULT_FAMILY = "Helvetica, Arial, sans-serif"
36
+ _UNIT_INCH = {
37
+ "in": 1.0,
38
+ "inch": 1.0,
39
+ "inches": 1.0,
40
+ "cm": 1.0 / 2.54,
41
+ "mm": 1.0 / 25.4,
42
+ }
43
+ _EXPORT_HINT = (
44
+ "ggsave() needs the Cairo C library to write PDF with a journal font. "
45
+ "Install the Python extra with: pip install 'plot3[export]'. "
46
+ "Windows and minimal Linux images also need the Cairo library itself "
47
+ "(for example the libcairo2 package, or the GTK runtime). "
48
+ "ggsave('fig.svg', plot) writes the same drawing with no extra dependencies."
49
+ )
50
+
51
+ # Column bitmasks, LSB = top row. 5x7, authored for axis labels and titles.
52
+ def _glyph(*rows: str) -> tuple[int, ...]:
53
+ cols = [0, 0, 0, 0, 0]
54
+ for r, row in enumerate(rows):
55
+ for c, ch in enumerate(row[:5]):
56
+ if ch == "#":
57
+ cols[c] |= 1 << r
58
+ return tuple(cols)
59
+
60
+
61
+ _FONT: dict[str, tuple[int, ...]] = {
62
+ " ": (0, 0, 0, 0, 0),
63
+ "!": _glyph("..#..", "..#..", "..#..", "..#..", ".....", "..#..", "..#.."),
64
+ '"': _glyph(".#.#.", ".#.#.", ".....", ".....", ".....", ".....", "....."),
65
+ "#": _glyph(".#.#.", ".#.#.", "#####", ".#.#.", "#####", ".#.#.", ".#.#."),
66
+ "$": _glyph("..#..", ".####", "#.#..", ".###.", "..#.#", "####.", "..#.."),
67
+ "%": _glyph("#...#", "#..#.", "...#.", "..#..", ".#...", ".#..#", "#...#"),
68
+ "&": _glyph(".##..", "#..#.", ".#...", ".##..", "#.#.#", "#..#.", ".##.#"),
69
+ "'": _glyph("..#..", "..#..", ".....", ".....", ".....", ".....", "....."),
70
+ "(": _glyph("...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#."),
71
+ ")": _glyph(".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#..."),
72
+ "*": _glyph(".....", ".#.#.", "..#..", "#####", "..#..", ".#.#.", "....."),
73
+ "+": _glyph(".....", "..#..", "..#..", "#####", "..#..", "..#..", "....."),
74
+ ",": _glyph(".....", ".....", ".....", ".....", "..#..", "..#..", ".#..."),
75
+ "-": _glyph(".....", ".....", ".....", "#####", ".....", ".....", "....."),
76
+ ".": _glyph(".....", ".....", ".....", ".....", ".....", "..#..", "..#.."),
77
+ "/": _glyph("....#", "...#.", "...#.", "..#..", ".#...", ".#...", "#...."),
78
+ "0": _glyph(".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."),
79
+ "1": _glyph("..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."),
80
+ "2": _glyph(".###.", "#...#", "....#", "..##.", ".#...", "#....", "#####"),
81
+ "3": _glyph(".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."),
82
+ "4": _glyph("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."),
83
+ "5": _glyph("#####", "#....", "####.", "....#", "....#", "#...#", ".###."),
84
+ "6": _glyph(".##..", "#....", "#....", "####.", "#...#", "#...#", ".###."),
85
+ "7": _glyph("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."),
86
+ "8": _glyph(".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."),
87
+ "9": _glyph(".###.", "#...#", "#...#", ".####", "....#", "....#", "..##."),
88
+ ":": _glyph(".....", "..#..", "..#..", ".....", "..#..", "..#..", "....."),
89
+ ";": _glyph(".....", "..#..", "..#..", ".....", "..#..", "..#..", ".#..."),
90
+ "<": _glyph("...#.", "..#..", ".#...", "#....", ".#...", "..#..", "...#."),
91
+ "=": _glyph(".....", ".....", "#####", ".....", "#####", ".....", "....."),
92
+ ">": _glyph(".#...", "..#..", "...#.", "....#", "...#.", "..#..", ".#..."),
93
+ "?": _glyph(".###.", "#...#", "....#", "..##.", "..#..", ".....", "..#.."),
94
+ "@": _glyph(".###.", "#...#", "#.###", "#.#.#", "#.###", "#....", ".###."),
95
+ "A": _glyph(".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
96
+ "B": _glyph("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."),
97
+ "C": _glyph(".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
98
+ "D": _glyph("####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."),
99
+ "E": _glyph("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
100
+ "F": _glyph("#####", "#....", "#....", "####.", "#....", "#....", "#...."),
101
+ "G": _glyph(".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."),
102
+ "H": _glyph("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
103
+ "I": _glyph(".###.", "..#..", "..#..", "..#..", "..#..", "..#..", ".###."),
104
+ "J": _glyph("..###", "...#.", "...#.", "...#.", "#..#.", "#..#.", ".##.."),
105
+ "K": _glyph("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"),
106
+ "L": _glyph("#....", "#....", "#....", "#....", "#....", "#....", "#####"),
107
+ "M": _glyph("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"),
108
+ "N": _glyph("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"),
109
+ "O": _glyph(".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
110
+ "P": _glyph("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
111
+ "Q": _glyph(".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"),
112
+ "R": _glyph("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
113
+ "S": _glyph(".####", "#....", "#....", ".###.", "....#", "....#", "####."),
114
+ "T": _glyph("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
115
+ "U": _glyph("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
116
+ "V": _glyph("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
117
+ "W": _glyph("#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"),
118
+ "X": _glyph("#...#", ".#.#.", "..#..", "..#..", "..#..", ".#.#.", "#...#"),
119
+ "Y": _glyph("#...#", ".#.#.", "..#..", "..#..", "..#..", "..#..", "..#.."),
120
+ "Z": _glyph("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"),
121
+ "[": _glyph(".###.", ".#...", ".#...", ".#...", ".#...", ".#...", ".###."),
122
+ "\\": _glyph("#....", ".#...", ".#...", "..#..", "...#.", "...#.", "....#"),
123
+ "]": _glyph(".###.", "...#.", "...#.", "...#.", "...#.", "...#.", ".###."),
124
+ "^": _glyph("..#..", ".#.#.", "#...#", ".....", ".....", ".....", "....."),
125
+ "_": _glyph(".....", ".....", ".....", ".....", ".....", ".....", "#####"),
126
+ "`": _glyph(".#...", "..#..", ".....", ".....", ".....", ".....", "....."),
127
+ "a": _glyph(".....", ".....", ".###.", "....#", ".####", "#...#", ".####"),
128
+ "b": _glyph("#....", "#....", "####.", "#...#", "#...#", "#...#", "####."),
129
+ "c": _glyph(".....", ".....", ".###.", "#....", "#....", "#....", ".###."),
130
+ "d": _glyph("....#", "....#", ".####", "#...#", "#...#", "#...#", ".####"),
131
+ "e": _glyph(".....", ".....", ".###.", "#...#", "#####", "#....", ".###."),
132
+ "f": _glyph("..##.", ".#...", ".#...", "####.", ".#...", ".#...", ".#..."),
133
+ "g": _glyph(".....", ".....", ".####", "#...#", ".####", "....#", ".###."),
134
+ "h": _glyph("#....", "#....", "####.", "#...#", "#...#", "#...#", "#...#"),
135
+ "i": _glyph("..#..", ".....", "..#..", "..#..", "..#..", "..#..", ".###."),
136
+ "j": _glyph("...#.", ".....", "...#.", "...#.", "...#.", "#..#.", ".##.."),
137
+ "k": _glyph("#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#."),
138
+ "l": _glyph("..#..", "..#..", "..#..", "..#..", "..#..", "..#..", ".###."),
139
+ "m": _glyph(".....", ".....", "##.#.", "#.#.#", "#.#.#", "#...#", "#...#"),
140
+ "n": _glyph(".....", ".....", "####.", "#...#", "#...#", "#...#", "#...#"),
141
+ "o": _glyph(".....", ".....", ".###.", "#...#", "#...#", "#...#", ".###."),
142
+ "p": _glyph(".....", ".....", "####.", "#...#", "####.", "#....", "#...."),
143
+ "q": _glyph(".....", ".....", ".####", "#...#", ".####", "....#", "....#"),
144
+ "r": _glyph(".....", ".....", "#.##.", "##...", "#....", "#....", "#...."),
145
+ "s": _glyph(".....", ".....", ".####", "#....", ".###.", "....#", "####."),
146
+ "t": _glyph("..#..", "..#..", "####.", "..#..", "..#..", "..#..", "...##"),
147
+ "u": _glyph(".....", ".....", "#...#", "#...#", "#...#", "#...#", ".####"),
148
+ "v": _glyph(".....", ".....", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
149
+ "w": _glyph(".....", ".....", "#...#", "#...#", "#.#.#", "##.##", "#...#"),
150
+ "x": _glyph(".....", ".....", "#...#", ".#.#.", "..#..", ".#.#.", "#...#"),
151
+ "y": _glyph(".....", ".....", "#...#", "#...#", ".####", "....#", ".###."),
152
+ "z": _glyph(".....", ".....", "#####", "...#.", "..#..", ".#...", "#####"),
153
+ "{": _glyph("...#.", "..#..", "..#..", "#....", "..#..", "..#..", "...#."),
154
+ "|": _glyph("..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
155
+ "}": _glyph(".#...", "..#..", "..#..", "....#", "..#..", "..#..", ".#..."),
156
+ "~": _glyph(".....", ".....", ".#.#.", "#.#..", ".....", ".....", "....."),
157
+ "π": _glyph(".....", "#####", ".#.#.", ".#.#.", ".#.#.", ".#.#.", ".#.#."),
158
+ "α": _glyph(".....", ".....", ".###.", "#...#", ".###.", "#..#.", ".##.#"),
159
+ "β": _glyph("###..", "#..#.", "#..#.", "###..", "#..#.", "#..#.", "###.."),
160
+ "θ": _glyph("..#..", ".#.#.", "#...#", "#####", "#...#", ".#.#.", "..#.."),
161
+ "μ": _glyph(".....", ".....", "#...#", "#...#", "#...#", "##..#", "#.##."),
162
+ "σ": _glyph(".....", ".....", ".####", "#....", "#...#", "#...#", ".###."),
163
+ "∫": _glyph("..##.", ".#...", ".#...", "..#..", "...#.", "...#.", ".##.."),
164
+ }
165
+
166
+ _FOLD = str.maketrans({
167
+ "−": "-", "–": "-", "—": "-",
168
+ "×": "x", "·": ".",
169
+ "’": "'", "‘": "'",
170
+ "“": '"', "”": '"',
171
+ "\u00a0": " ",
172
+ })
173
+
174
+ _KINDS = frozenset({"point", "line", "col", "box", "area", "poly", "surface", "isosurface"})
175
+
176
+
177
+ def save_static(
178
+ fig,
179
+ path,
180
+ *,
181
+ width=None,
182
+ height=None,
183
+ units: str = "px",
184
+ dpi: float | None = None,
185
+ family: str | None = None,
186
+ fontsize: float | None = None,
187
+ notes: bool = False,
188
+ ) -> str:
189
+ """Write ``fig`` to a ``.png``, ``.svg``, or ``.pdf`` file.
190
+
191
+ Notes such as ``y clipped to [...]`` are for whoever makes the plot,
192
+ not for readers of a paper. They are printed, not drawn, unless
193
+ ``notes=True``.
194
+
195
+ Bare ``width`` and ``height`` are pixels. ``units="in"`` (also
196
+ ``"cm"`` and ``"mm"``) with ``dpi`` (default 300) sets a physical
197
+ page. Layout stays in CSS pixels (96 per inch) so type keeps its
198
+ size, and cairosvg rasterizes that SVG at ``dpi``. PDF and a
199
+ journal-font PNG need the Cairo C library (``pip install
200
+ 'plot3[export]'``). ``.svg`` needs nothing extra.
201
+ """
202
+ path = Path(path)
203
+ suffix = path.suffix.lower()
204
+ if suffix not in {".png", ".svg", ".pdf"}:
205
+ raise ValueError("save_static() writes .png, .svg, or .pdf")
206
+ fig = _print_theme(fig)
207
+ size = _figure_size(fig, width, height, units, dpi)
208
+ layout_w, layout_h = size["layout"]
209
+ base_pt = _resolve_base_pt(fig, fontsize)
210
+ family_name = _resolve_family(fig, family)
211
+ dropped: list[str] = []
212
+ # Measure text the way it will be drawn. SVG and PDF always use real
213
+ # fonts; a PNG does too when Cairo is available, else the bitmap font.
214
+ real = suffix in {".svg", ".pdf"} or _load_cairosvg() is not None
215
+ token = _REAL_FONT.set(real)
216
+ try:
217
+ commands = _figure_commands(
218
+ fig, layout_w, layout_h, base_pt, notes=notes, dropped=dropped
219
+ )
220
+ finally:
221
+ _REAL_FONT.reset(token)
222
+ # Browsers fall back per character; Cairo (PNG, PDF) needs one font
223
+ # that has the glyphs, chosen from what this machine has installed.
224
+ browser_wide = f"{family_name}, {_WIDE_FALLBACKS}"
225
+ svg = _svg_text(
226
+ commands,
227
+ layout_w,
228
+ layout_h,
229
+ svg_width=size["svg_width"],
230
+ svg_height=size["svg_height"],
231
+ family=family_name,
232
+ wide=browser_wide if suffix == ".svg" else (_wide_font() or browser_wide),
233
+ )
234
+ path.parent.mkdir(parents=True, exist_ok=True)
235
+ fallback = False
236
+ if suffix == ".svg":
237
+ data = svg.encode("utf-8")
238
+ elif suffix == ".pdf":
239
+ data = _pdf_bytes(svg)
240
+ else:
241
+ data, fallback = _png_file_bytes(svg, commands, size)
242
+ if suffix == ".pdf":
243
+ data = _stamp_pdf(data, __version__)
244
+ path.write_bytes(data)
245
+ print(f"plot3: saved {path} ({len(data) // 1024} KB)")
246
+ shown = list(dict.fromkeys(dropped))
247
+ for note in shown:
248
+ print(f"plot3: {note}")
249
+ if shown:
250
+ print("plot3: notes are not drawn in the file; ggsave(..., notes=True) adds them")
251
+ if fallback:
252
+ print(
253
+ "plot3: PNG used the built-in font. "
254
+ "pip install 'plot3[export]' for Helvetica."
255
+ )
256
+ return str(path)
257
+
258
+
259
+ def _print_theme(fig):
260
+ """Saved files default to theme_bw (white page, grey grid); the
261
+ interactive viewer keeps its own default. A theme you add always wins."""
262
+ import copy
263
+
264
+ from plot3.compose import Composition
265
+
266
+ def lighten(plot):
267
+ if getattr(plot, "theme_explicit", True) or getattr(plot, "_payload", None) is not None:
268
+ return plot
269
+ out = copy.copy(plot)
270
+ out.theme_name = "bw"
271
+ return out
272
+
273
+ if isinstance(fig, Composition):
274
+ def walk(node):
275
+ if isinstance(node, Composition):
276
+ clone = copy.copy(node)
277
+ clone.items = [walk(item) for item in node.items]
278
+ return clone
279
+ return lighten(node)
280
+
281
+ return walk(fig)
282
+ return lighten(fig)
283
+
284
+
285
+ def _figure_size(fig, width, height, units, dpi) -> dict:
286
+ unit = str(units or "px").strip().lower()
287
+ if unit in {"px", "pixel", "pixels"}:
288
+ if dpi is not None:
289
+ raise ValueError(
290
+ "ggsave() dpi applies when units is 'in', 'cm', or 'mm'. "
291
+ "For a 7 by 4 inch figure: "
292
+ "ggsave('fig.png', plot, width=7, height=4, units='in', dpi=300)"
293
+ )
294
+ w, h = _figure_pixels(fig, width, height)
295
+ return {
296
+ "layout": (w, h),
297
+ "png": (w, h),
298
+ "svg_width": str(w),
299
+ "svg_height": str(h),
300
+ "scale": (1.0, 1.0),
301
+ "dpi": _CSS_DPI,
302
+ "physical": False,
303
+ }
304
+ if unit not in _UNIT_INCH:
305
+ raise ValueError("ggsave() units must be 'px', 'in', 'cm', or 'mm'")
306
+ dpi_value = 300.0 if dpi is None else _plain_float(
307
+ dpi,
308
+ "dpi",
309
+ "ggsave() dpi must be between 1 and 2400",
310
+ minimum=1,
311
+ maximum=2400,
312
+ type_message="ggsave() dpi must be a number, for example dpi=300",
313
+ )
314
+ default_h = _css_px(getattr(fig, "height", None), 480)
315
+ width_in = _as_inches(width, "width", unit, 800)
316
+ height_in = _as_inches(height, "height", unit, default_h)
317
+ layout = (_px_extent(width_in, _CSS_DPI, "width"), _px_extent(height_in, _CSS_DPI, "height"))
318
+ png = (_px_extent(width_in, dpi_value, "width"), _px_extent(height_in, dpi_value, "height"))
319
+ return {
320
+ "layout": layout,
321
+ "png": png,
322
+ "svg_width": _inch_attr(width_in),
323
+ "svg_height": _inch_attr(height_in),
324
+ "scale": (png[0] / layout[0], png[1] / layout[1]),
325
+ "dpi": dpi_value,
326
+ "physical": True,
327
+ }
328
+
329
+
330
+ def _figure_pixels(fig, width, height) -> tuple[int, int]:
331
+ w = 800 if width is None else _pixels(width, "width")
332
+ h = _css_px(getattr(fig, "height", None), 480) if height is None else _pixels(height, "height")
333
+ return w, h
334
+
335
+
336
+ def _as_inches(value, name: str, unit: str, default_px: int) -> float:
337
+ if value is None:
338
+ return default_px / _CSS_DPI
339
+ if isinstance(value, str):
340
+ raise TypeError(
341
+ f"ggsave() {name} is a number of {unit}. "
342
+ f"For example {name}=7, units='in'."
343
+ )
344
+ number = _plain_float(
345
+ value,
346
+ name,
347
+ f"ggsave() {name} must be greater than 0",
348
+ minimum=1e-6,
349
+ maximum=None,
350
+ type_message=(
351
+ f"ggsave() {name} is a number of {unit}. "
352
+ f"For example {name}=7, units='in'."
353
+ ),
354
+ )
355
+ return number * _UNIT_INCH[unit]
356
+
357
+
358
+ def _px_extent(inches: float, dpi: float, name: str) -> int:
359
+ pixels = int(round(inches * dpi))
360
+ if pixels < 1:
361
+ raise ValueError(f"ggsave() {name} must be at least 1 pixel")
362
+ if pixels > _MAX_PX:
363
+ raise ValueError(
364
+ f"ggsave() {name} is {pixels} pixels, and the maximum is {_MAX_PX}. "
365
+ "Lower dpi or the size."
366
+ )
367
+ return pixels
368
+
369
+
370
+ def _inch_attr(inches: float) -> str:
371
+ text = f"{inches:.6f}".rstrip("0").rstrip(".")
372
+ return f"{text}in"
373
+
374
+
375
+ def _plain_float(value, name: str, bounds_message: str, *, minimum, maximum, type_message=None) -> float:
376
+ if isinstance(value, bool):
377
+ raise TypeError(type_message or bounds_message)
378
+ try:
379
+ number = float(value)
380
+ except (TypeError, ValueError) as exc:
381
+ raise TypeError(type_message or bounds_message) from exc
382
+ if not math.isfinite(number):
383
+ raise ValueError(bounds_message)
384
+ if minimum is not None and number < minimum:
385
+ raise ValueError(bounds_message)
386
+ if maximum is not None and number > maximum:
387
+ raise ValueError(bounds_message)
388
+ return number
389
+
390
+
391
+ def _resolve_family(fig, family) -> str:
392
+ chosen = family if family not in (None, "") else getattr(fig, "theme_family", None)
393
+ if chosen in (None, ""):
394
+ return _DEFAULT_FAMILY
395
+ return str(chosen)
396
+
397
+
398
+ def _resolve_base_pt(fig, fontsize) -> float | None:
399
+ if fontsize is not None:
400
+ return _font_points(fontsize, "fontsize")
401
+ theme_size = getattr(fig, "theme_base_size", None)
402
+ if theme_size is None:
403
+ return None
404
+ return _font_points(theme_size, "base_size")
405
+
406
+
407
+ def _font_points(value, name: str) -> float:
408
+ number = _plain_float(
409
+ value,
410
+ name,
411
+ f"ggsave() {name} must be between 1 and 96 points",
412
+ minimum=1,
413
+ maximum=96,
414
+ type_message=f"ggsave() {name} must be a font size in points, for example {name}=11",
415
+ )
416
+ return number
417
+
418
+
419
+ def _load_cairosvg():
420
+ try:
421
+ import cairosvg
422
+ except Exception:
423
+ return None
424
+ return cairosvg
425
+
426
+
427
+ def _cairo_bytes(method: str, svg: str, **kwargs) -> bytes | None:
428
+ lib = _load_cairosvg()
429
+ if lib is None:
430
+ return None
431
+ buf = io.BytesIO()
432
+ try:
433
+ getattr(lib, method)(bytestring=svg.encode("utf-8"), write_to=buf, **kwargs)
434
+ except OSError:
435
+ return None
436
+ data = buf.getvalue()
437
+ return data or None
438
+
439
+
440
+ def _pdf_bytes(svg: str) -> bytes:
441
+ data = _cairo_bytes("svg2pdf", svg)
442
+ if not data:
443
+ raise RuntimeError(_EXPORT_HINT)
444
+ return data
445
+
446
+
447
+ def _png_file_bytes(svg: str, commands, size: dict) -> tuple[bytes, bool]:
448
+ dpi = size["dpi"]
449
+ data = _cairo_bytes("svg2png", svg, dpi=dpi)
450
+ if data:
451
+ return _with_phys(data, dpi), False
452
+ sx, sy = size["scale"]
453
+ png_w, png_h = size["png"]
454
+ if sx != 1.0 or sy != 1.0:
455
+ commands = _scale_commands(commands, sx, sy)
456
+ return _png_bytes(_raster(commands, png_w, png_h), dpi), True
457
+
458
+
459
+ def _scale_commands(commands, sx: float, sy: float) -> list:
460
+ stroke = (sx + sy) / 2.0
461
+ scaled = []
462
+ for cmd in commands:
463
+ op = cmd[0]
464
+ if op == "rect":
465
+ _op, x, y, w, h, fill, color, sw, alpha = cmd
466
+ scaled.append((
467
+ "rect", x * sx, y * sy, w * sx, h * sy, fill, color,
468
+ (sw or 0) * stroke, alpha,
469
+ ))
470
+ elif op == "line":
471
+ _op, x1, y1, x2, y2, color, sw, alpha = cmd
472
+ scaled.append((
473
+ "line", x1 * sx, y1 * sy, x2 * sx, y2 * sy, color,
474
+ (sw or 0) * stroke, alpha,
475
+ ))
476
+ elif op == "polyline":
477
+ _op, pts, color, sw, alpha = cmd
478
+ scaled.append((
479
+ "polyline", [(px * sx, py * sy) for px, py in pts], color,
480
+ (sw or 0) * stroke, alpha,
481
+ ))
482
+ elif op == "polygon":
483
+ _op, pts, fill, color, sw, alpha = cmd
484
+ scaled.append((
485
+ "polygon", [(px * sx, py * sy) for px, py in pts], fill, color,
486
+ (sw or 0) * stroke, alpha,
487
+ ))
488
+ elif op == "polymask":
489
+ _op, tris, color, alpha = cmd
490
+ scaled.append((
491
+ "polymask",
492
+ [[(px * sx, py * sy) for px, py in tri] for tri in tris],
493
+ color,
494
+ alpha,
495
+ ))
496
+ elif op == "circle":
497
+ _op, cx, cy, r, fill, color, sw, alpha = cmd
498
+ scaled.append((
499
+ "circle", cx * sx, cy * sy, r * stroke, fill, color,
500
+ (sw or 0) * stroke, alpha,
501
+ ))
502
+ elif op == "text":
503
+ _op, x, y, text, size, fill, anchor, baseline, rotate, weight = cmd
504
+ scaled.append((
505
+ "text", x * sx, y * sy, text, float(size) * stroke, fill,
506
+ anchor, baseline, rotate, weight,
507
+ ))
508
+ elif op == "clip":
509
+ _op, x, y, w, h = cmd
510
+ scaled.append(("clip", x * sx, y * sy, w * sx, h * sy))
511
+ else:
512
+ scaled.append(cmd)
513
+ return scaled
514
+
515
+
516
+ def _pixels(value, name: str) -> int:
517
+ if isinstance(value, bool):
518
+ raise TypeError(f"ggsave() {name} must be a pixel count, for example {name}=800")
519
+ text = value
520
+ if isinstance(text, str):
521
+ text = text.strip().lower()
522
+ if text.endswith("px"):
523
+ text = text[:-2].strip()
524
+ try:
525
+ number = float(text)
526
+ except (TypeError, ValueError) as exc:
527
+ raise TypeError(
528
+ f"ggsave() {name} must be a pixel count, for example {name}=800"
529
+ ) from exc
530
+ if not math.isfinite(number) or number < 1:
531
+ raise ValueError(f"ggsave() {name} must be at least 1 pixel")
532
+ if number > _MAX_PX:
533
+ raise ValueError(f"ggsave() {name} must be at most {_MAX_PX} pixels")
534
+ return int(round(number))
535
+
536
+
537
+ def _css_px(value, default: int) -> int:
538
+ if value is None:
539
+ return default
540
+ if isinstance(value, bool):
541
+ return default
542
+ if isinstance(value, (int, float)):
543
+ number = float(value)
544
+ else:
545
+ text = str(value).strip().lower()
546
+ if text.endswith("px"):
547
+ text = text[:-2].strip()
548
+ try:
549
+ number = float(text)
550
+ except ValueError:
551
+ return default
552
+ if not math.isfinite(number) or number < 1:
553
+ return default
554
+ return int(round(min(number, _MAX_PX)))
555
+
556
+
557
+ def _figure_commands(
558
+ fig,
559
+ width: int,
560
+ height: int,
561
+ base_pt: float | None = None,
562
+ *,
563
+ notes: bool = True,
564
+ dropped: list[str] | None = None,
565
+ ) -> list:
566
+ from plot3.compose import Composition
567
+
568
+ if isinstance(fig, Composition):
569
+ return _composition_commands(fig, width, height, base_pt, notes=notes, dropped=dropped)
570
+ panels, layout = _panels(fig)
571
+ if not notes:
572
+ kept = []
573
+ for item in panels:
574
+ if item is None:
575
+ kept.append(None)
576
+ continue
577
+ spec, blobs = item
578
+ if dropped is not None:
579
+ dropped.extend(str(n) for n in spec.get("notes") or [])
580
+ kept.append((dict(spec, notes=[]), blobs))
581
+ panels = kept
582
+ first = next(item for item in panels if item is not None)
583
+ theme = first[0].get("theme") or {}
584
+ surface = theme.get("surface") or "#0b1020"
585
+ commands: list = [("rect", 0, 0, width, height, surface, None, 0, 1.0)]
586
+ if layout is None:
587
+ spec, blobs = panels[0]
588
+ _draw_spec(spec, blobs, 0, 0, width, height, commands, border=False, base_pt=base_pt)
589
+ return commands
590
+ _draw_facets(panels, layout, first[0], theme, width, height, base_pt, commands)
591
+ return commands
592
+
593
+
594
+ def _composition_commands(fig, width, height, base_pt, *, notes, dropped) -> list:
595
+ """``p1 | p2`` and ``p1 / p2``: each plot in its rectangle, one type size."""
596
+ from plot3.compose import _first_theme
597
+
598
+ fig = fig.tagged()
599
+ theme = _first_theme(fig)
600
+ commands: list = [("rect", 0, 0, width, height, theme["surface"], None, 0, 1.0)]
601
+ fonts = _font_sizes(width, base_pt)
602
+ if base_pt is None:
603
+ # The whole figure's type, so a narrow panel does not get smaller text.
604
+ base_pt = fonts[0] / 0.8 * 72.0 / _CSS_DPI
605
+ note = fig.annotation
606
+ header = {
607
+ key: str(getattr(note, key, "") or "")
608
+ for key in ("title", "subtitle", "caption")
609
+ }
610
+ head, foot = _draw_header_footer({}, header, fonts, theme, 0, 0, width, height, commands)
611
+ pad = 6.0
612
+ for plot, x, y, w, h in fig.rects(pad, head + pad, width - 2 * pad, height - head - foot - 2 * pad):
613
+ sub = _figure_commands(plot, w, h, base_pt, notes=notes, dropped=dropped)
614
+ commands.extend(_offset_commands(sub[1:], x, y)) # sub[0] is its page fill
615
+ return commands
616
+
617
+
618
+ def _offset_commands(commands, dx: float, dy: float) -> list:
619
+ out = []
620
+ for cmd in commands:
621
+ op = cmd[0]
622
+ if op in {"rect", "circle", "text", "clip"}:
623
+ out.append((op, cmd[1] + dx, cmd[2] + dy, *cmd[3:]))
624
+ elif op == "line":
625
+ out.append((op, cmd[1] + dx, cmd[2] + dy, cmd[3] + dx, cmd[4] + dy, *cmd[5:]))
626
+ elif op in {"polyline", "polygon"}:
627
+ out.append((op, [(x + dx, y + dy) for x, y in cmd[1]], *cmd[2:]))
628
+ elif op == "polymask":
629
+ out.append((op, [[(x + dx, y + dy) for x, y in tri] for tri in cmd[1]], *cmd[2:]))
630
+ else:
631
+ out.append(cmd)
632
+ return out
633
+
634
+
635
+ def _draw_facets(panels, layout, first_spec, theme, width, height, base_pt, commands) -> None:
636
+ """Panels in a grid with ggplot2's furniture: title rows, strips, one
637
+ x and one y title, and a single legend to the right."""
638
+ ncol, nrow = layout["ncol"], layout["nrow"]
639
+ fonts = _font_sizes(width, base_pt)
640
+ tick = fonts[0]
641
+ label = _static_label(first_spec)
642
+ header = {k: _with_frame(str(v), label) for k, v in (layout.get("header") or {}).items()}
643
+ for key in list(header):
644
+ if "$" in header[key]:
645
+ from plot3.mathtext import split_math
646
+
647
+ header[key], _segments = split_math(header[key])
648
+ head, foot = _draw_header_footer(
649
+ {"themeOpts": first_spec.get("themeOpts")}, header, fonts, theme,
650
+ 0, 0, width, height, commands,
651
+ )
652
+ pad = 8.0
653
+ strip_bg = theme.get("grid") or "#1c2742"
654
+ ink2 = theme.get("ink2") or theme.get("ink") or "#ffffff"
655
+ raw_labs = first_spec.get("labs") or {}
656
+ x_title = _with_frame(raw_labs.get("x") or "", label)
657
+ y_title = _with_frame(raw_labs.get("y") or "", label)
658
+
659
+ # One legend for the figure, from the first panel (colour levels are
660
+ # shared, so every panel has the same keys).
661
+ legend_spec = dict(first_spec, legendPosition="right")
662
+ metrics = _legend_metrics(
663
+ legend_spec, theme, fonts, raw_labs.get("color") or "",
664
+ max_box=(width * 0.3, height - head - foot),
665
+ )
666
+ legend_w = float(metrics["w"]) + 12.0 if metrics else 0.0
667
+
668
+ col_strips = layout.get("col_strips")
669
+ row_strips = layout.get("row_strips")
670
+ wrap = layout.get("kind") != "grid"
671
+ strip = tick + 10.0
672
+ top_strip = strip if col_strips else 0.0
673
+ side_strip = strip if row_strips else 0.0
674
+ left_title = _line_height(tick) + 10.0 if y_title else 0.0
675
+ bottom_title = _line_height(tick) + 10.0 if x_title else 0.0
676
+ top = head + pad + top_strip
677
+ grid_w = width - left_title - legend_w - side_strip - pad * (ncol + 1)
678
+ grid_h = height - top - foot - bottom_title - pad * nrow
679
+ cell_w, cell_h = grid_w / ncol, grid_h / nrow
680
+
681
+ def origin(row, col):
682
+ return left_title + pad + col * (cell_w + pad), top + row * (cell_h + pad)
683
+
684
+ def strip_box(x, y, w, h, text, rotate=0):
685
+ commands.append(("rect", x, y, w, h, strip_bg, None, 0, 1.0))
686
+ commands.append(("text", x + w / 2, y + h / 2, text, tick, ink2, "middle", "middle", rotate, 600))
687
+
688
+ if col_strips:
689
+ for col, text in enumerate(col_strips):
690
+ x, _y = origin(0, col)
691
+ strip_box(x, top - top_strip, cell_w, top_strip - 2, text)
692
+ if row_strips:
693
+ sx = left_title + pad + ncol * (cell_w + pad) - pad + 2
694
+ for row, text in enumerate(row_strips):
695
+ _x, y = origin(row, 0)
696
+ strip_box(sx, y, side_strip - 2, cell_h, text, rotate=90)
697
+ for cell, item in zip(layout["cells"], panels):
698
+ if item is None:
699
+ continue
700
+ spec, blobs = item
701
+ x, y = origin(cell["row"], cell["col"])
702
+ h = cell_h
703
+ if wrap and cell.get("strip"):
704
+ strip_box(x, y, cell_w, strip - 2, cell["strip"])
705
+ y, h = y + strip, cell_h - strip
706
+ _draw_spec(spec, blobs, x, y, cell_w, h, commands, border=False, base_pt=base_pt)
707
+ grid_left = left_title + pad
708
+ grid_right = left_title + pad + ncol * (cell_w + pad) - pad
709
+ if x_title:
710
+ commands.append((
711
+ "text", (grid_left + grid_right) / 2, height - foot - bottom_title + 4,
712
+ x_title, tick, ink2, "middle", "top", 0, 400,
713
+ ))
714
+ if y_title:
715
+ commands.append((
716
+ "text", 4 + _line_height(tick) / 2, top + grid_h / 2 + pad * (nrow - 1) / 2,
717
+ y_title, tick, ink2, "middle", "middle", -90, 400,
718
+ ))
719
+ if metrics:
720
+ lx = width - legend_w + 4
721
+ _paint_legend(commands, (lx, top), metrics, theme, fonts)
722
+
723
+ def _panels(fig):
724
+ """``(panels, layout)``: one panel and no layout, or facet cells.
725
+
726
+ A facet_grid cell with no rows is ``None`` and draws nothing.
727
+ """
728
+ payload = getattr(fig, "_payload", None)
729
+ if payload is not None:
730
+ return [(payload["spec"], payload.get("blobs") or {})], None
731
+
732
+ from plot3.build import build_spec, facet_cells
733
+
734
+ if getattr(fig, "facet", None) is None:
735
+ spec, pairs = build_spec(fig)
736
+ return [(spec, dict(pairs))], None
737
+ layout = facet_cells(fig)
738
+ panels = []
739
+ for cell in layout["cells"]:
740
+ if cell["fig"] is None:
741
+ panels.append(None)
742
+ continue
743
+ spec, pairs = build_spec(cell["fig"])
744
+ panels.append((spec, dict(pairs)))
745
+ return panels, layout
746
+
747
+
748
+ def _is_missing(level) -> bool:
749
+ if level is None:
750
+ return True
751
+ try:
752
+ if bool(np.isnan(level)):
753
+ return True
754
+ except (TypeError, ValueError):
755
+ pass
756
+ try:
757
+ import pandas as pd
758
+
759
+ return bool(pd.isna(level))
760
+ except (TypeError, ValueError):
761
+ return False
762
+
763
+
764
+ def _draw_spec(spec, blobs, x, y, w, h, commands, *, border: bool, base_pt: float | None = None) -> None:
765
+ theme = spec.get("theme") or {}
766
+ if border:
767
+ commands.append((
768
+ "rect", x, y, w, h, None, theme.get("axis") or "#2e3a5c", 1, 1.0,
769
+ ))
770
+ labs = _labs(spec)
771
+ is3d = bool(spec.get("is3d"))
772
+ fonts = _font_sizes(w, base_pt)
773
+ if not is3d:
774
+ spec = _fit_x_labels(spec, w, fonts[0])
775
+ # Tag, title, subtitle above the panel and the caption below it take
776
+ # their own rows; the panel layout then sees a smaller cell, no title.
777
+ head, foot = _draw_header_footer(spec, labs, fonts, theme, x, y, w, h, commands)
778
+ y, h = y + head, max(8.0, h - head - foot)
779
+ labs = dict(labs, title="")
780
+ position = _legend_position(spec)
781
+ left = 16.0 if is3d else _y_gutter(spec, labs, fonts, w)
782
+ # A right-hand key that cannot show its labels moves under the panel.
783
+ # The spec keeps the user's legendPosition; only this painting changes.
784
+ if position == "right" and _legend_prefers_bottom(spec, fonts, w, h, left):
785
+ position = "bottom"
786
+ max_w, max_h = _legend_budget(position, w, h, left)
787
+ metrics = _legend_metrics(
788
+ spec, theme, fonts, labs.get("color") or "", max_box=(max_w, max_h),
789
+ columns=position == "bottom",
790
+ )
791
+ extra_right, extra_bottom = _legend_reserve(position, metrics, w, h, left)
792
+ if is3d:
793
+ _draw_3d(
794
+ spec, blobs, x, y, w, h, commands, labs, theme, fonts,
795
+ extra_right=extra_right, extra_bottom=extra_bottom,
796
+ )
797
+ box = _box_3d(x, y, w, h, labs, fonts, extra_right, extra_bottom)
798
+ else:
799
+ _draw_2d(
800
+ spec, blobs, x, y, w, h, commands, labs, theme, fonts,
801
+ extra_right=extra_right, extra_bottom=extra_bottom,
802
+ )
803
+ box = _box_2d(spec, x, y, w, h, labs, fonts, extra_right, extra_bottom)
804
+ if metrics is not None and position != "none":
805
+ origin = _legend_origin(position, box, metrics, x, y, w, h)
806
+ _paint_legend(commands, origin, metrics, theme, fonts)
807
+ note_lines = _note_lines(spec.get("notes") or [], fonts[2], w)
808
+ if note_lines:
809
+ line_h = _note_line_height(fonts[2])
810
+ # One caption per line, stacked upward so a second curve does not
811
+ # run off the right edge. The axis margin reserved this space.
812
+ baseline = y + h - 4 - extra_bottom
813
+ color = theme.get("muted") or "#898781"
814
+ for index, line in enumerate(reversed(note_lines)):
815
+ commands.append((
816
+ "text", x + 12, baseline - index * line_h, line,
817
+ fonts[2], color, "start", "alphabetic", 0, 400,
818
+ ))
819
+
820
+
821
+ def _labs(spec) -> dict:
822
+ label = _static_label(spec)
823
+ raw = spec.get("labs") or {}
824
+ keys = ("title", "x", "y", "z", "color", "subtitle", "caption", "tag")
825
+ out = {key: _with_frame(raw.get(key) or "", label) for key in keys}
826
+ if spec.get("facetChild"):
827
+ # The facet layout draws one x and one y title for the whole figure.
828
+ out["x"] = out["y"] = ""
829
+ return out
830
+
831
+
832
+ def _fit_x_labels(spec, width: float, tick: float):
833
+ """Turn or thin x labels that would overlap (30 categories at 3.5 in).
834
+
835
+ Tries flat, then 45 degrees, then 90; if even upright labels collide,
836
+ keeps every k-th. An explicit theme(axis_text_x_angle=) is left alone.
837
+ """
838
+ opts = dict(spec.get("themeOpts") or {})
839
+ if "xAngle" in opts:
840
+ return spec
841
+ labels = [str(lab) for _v, lab in _ticks((spec.get("scales") or {}).get("x") or {})]
842
+ if len(labels) < 2:
843
+ return spec
844
+ slot = 0.72 * float(width) / len(labels) # panel is roughly 3/4 of the cell
845
+ longest = max(_text_width(lab, tick) for lab in labels)
846
+ line = 1.25 * tick
847
+ if longest + 6 <= slot:
848
+ return spec
849
+ if line * 1.45 <= slot:
850
+ opts["xAngle"] = 45.0
851
+ else:
852
+ opts["xAngle"] = 90.0
853
+ is_cat = ((spec.get("scales") or {}).get("x") or {}).get("kind") == "cat"
854
+ if line > slot and is_cat:
855
+ opts["xThin"] = int(math.ceil(line / max(slot, 1e-6)))
856
+ return dict(spec, themeOpts=opts)
857
+
858
+
859
+ def _thin_y_ticks(ticks, scale, height: float, tick: float):
860
+ """Every k-th tick when labels would sit closer than 1.6 lines apart."""
861
+ if len(ticks) < 3 or scale.get("kind") == "cat":
862
+ return ticks
863
+ span = abs(float(scale.get("hi", 1.0)) - float(scale.get("lo", 0.0))) or 1.0
864
+ gap = abs(float(ticks[1][0]) - float(ticks[0][0])) / span * float(height)
865
+ step = 1
866
+ while gap * step < 1.6 * tick and step < len(ticks):
867
+ step += 1
868
+ if step == 1:
869
+ return ticks
870
+ # Keep the round ones (0, 10, 20), not whichever tick happens to be first.
871
+ width = abs(float(ticks[1][0]) - float(ticks[0][0])) * step
872
+ offset = 0
873
+ for o in range(step):
874
+ ratio = float(ticks[o][0]) / width if width else 0.5
875
+ if abs(ratio - round(ratio)) < 1e-6:
876
+ offset = o
877
+ break
878
+ return ticks[offset::step]
879
+
880
+
881
+ def _x_label_drop(spec, tick: float) -> float:
882
+ """Extra height turned x labels need below the axis."""
883
+ angle = float((spec.get("themeOpts") or {}).get("xAngle", 0.0))
884
+ if angle <= 0:
885
+ return 0.0
886
+ labels = [str(lab) for _v, lab in _ticks((spec.get("scales") or {}).get("x") or {})]
887
+ longest = max((_text_width(lab, tick) for lab in labels), default=0)
888
+ rad = math.radians(angle)
889
+ return max(0.0, longest * math.sin(rad) + tick * math.cos(rad) - _line_height(tick))
890
+
891
+
892
+ def _draw_header_footer(spec, labs, fonts, theme, x, y, w, h, commands) -> tuple[float, float]:
893
+ """Tag, title, and subtitle rows on top; caption row at the bottom.
894
+
895
+ Returns the heights used, so the panel can be laid out below them.
896
+ """
897
+ tick, title_size, note = fonts
898
+ ink = theme.get("ink") or "#ffffff"
899
+ ink2 = theme.get("ink2") or ink
900
+ muted = theme.get("muted") or "#898781"
901
+ opts = spec.get("themeOpts") or {}
902
+ hjust = float(opts.get("titleHjust", 0.0))
903
+ title, subtitle, tag = labs.get("title"), labs.get("subtitle"), labs.get("tag")
904
+ caption = labs.get("caption")
905
+ top = 6.0
906
+ tag_w = 0.0
907
+ if tag:
908
+ commands.append(("text", x + 10, y + top, tag, title_size, ink, "start", "top", 0, 700))
909
+ tag_w = _text_width(tag, title_size) + 10.0
910
+ left, right = x + 12 + tag_w, x + w - 12
911
+ anchor = "start" if hjust < 0.25 else "end" if hjust > 0.75 else "middle"
912
+ def at(width_hint: float) -> float:
913
+ if anchor == "start":
914
+ return left
915
+ if anchor == "end":
916
+ return right
917
+ return (left + right) / 2.0
918
+ used = 0.0
919
+ if title:
920
+ commands.append(("text", at(0), y + top, title, title_size, ink, anchor, "top", 0, 600))
921
+ used = top + title_size + 4
922
+ if subtitle:
923
+ sub_size = max(tick, title_size - 2)
924
+ start = used if used else top
925
+ commands.append(("text", at(0), y + start, subtitle, sub_size, ink2, anchor, "top", 0, 400))
926
+ used = start + sub_size + 4
927
+ if tag and not used:
928
+ used = top + title_size + 4
929
+ foot = 0.0
930
+ if caption:
931
+ size = max(8, note)
932
+ commands.append((
933
+ "text", x + w - 12, y + h - 6, caption, size, muted, "end", "alphabetic", 0, 400,
934
+ ))
935
+ foot = size + 8
936
+ return (used + 2 if used else 0.0), foot
937
+
938
+
939
+ def _with_frame(text: str, label: str) -> str:
940
+ if not text or "{frame_time}" not in text:
941
+ return text
942
+ return text.replace("{frame_time}", label)
943
+
944
+
945
+ def _fmt_param(value) -> str:
946
+ try:
947
+ number = float(value)
948
+ except (TypeError, ValueError):
949
+ return str(value)
950
+ if not math.isfinite(number):
951
+ return str(value)
952
+ magnitude = abs(number)
953
+ if magnitude >= 1e6 or (magnitude > 0 and magnitude < 1e-3):
954
+ return f"{number:.2e}"
955
+ return f"{number:.2f}"
956
+
957
+
958
+ def _fmt_tick(value, integer: bool) -> str:
959
+ try:
960
+ number = float(value)
961
+ except (TypeError, ValueError):
962
+ return str(value)
963
+ if integer:
964
+ return str(int(round(number)))
965
+ if number == 0:
966
+ return "0"
967
+ magnitude = abs(number)
968
+ if magnitude >= 1e6 or magnitude < 1e-4:
969
+ return f"{number:.3g}"
970
+ return f"{number:.6f}".rstrip("0").rstrip(".")
971
+
972
+
973
+ def _static_label(spec) -> str:
974
+ slider = spec.get("slider") or None
975
+ if slider and slider.get("params"):
976
+ return ", ".join(
977
+ f"{item['name']} = {_fmt_param(item['lo'])}" for item in slider["params"]
978
+ )
979
+ transition = spec.get("transition") or None
980
+ if not transition:
981
+ return ""
982
+ params = transition.get("params") or []
983
+ if params:
984
+ return ", ".join(
985
+ f"{item['name']} = {_fmt_param(item['hi'])}" for item in params
986
+ )
987
+ times = transition.get("times") or []
988
+ if not times:
989
+ return ""
990
+ if transition.get("type") == "states":
991
+ return str(times[-1])
992
+ return _fmt_tick(times[-1], bool(transition.get("integer")))
993
+
994
+
995
+ def _font_sizes(width: float, base_pt: float | None = None) -> tuple[int, int, int]:
996
+ """Tick, title, and note sizes in CSS pixels.
997
+
998
+ ``base_pt`` is a ggplot2 base size in points. Axis text is 0.8× and
999
+ the title is 1.2×. Without it, the size follows the panel width.
1000
+ """
1001
+ if base_pt is not None:
1002
+ px = float(base_pt) * _CSS_DPI / 72.0
1003
+ tick = max(6, int(round(px * 0.8)))
1004
+ title = max(8, int(round(px * 1.2)))
1005
+ note = max(6, int(round(px * 0.7)))
1006
+ return tick, title, note
1007
+ if width < 280:
1008
+ return 9, 11, 9
1009
+ if width < 480:
1010
+ return 11, 13, 10
1011
+ return 12, 14, 11
1012
+
1013
+
1014
+ def _box_3d(x, y, w, h, labs, fonts, extra_right=0.0, extra_bottom=0.0):
1015
+ # The fit padding inside this box holds the axis names. The gutter
1016
+ # itself only keeps those names off the canvas edge.
1017
+ margin = 8
1018
+ top = margin + (fonts[1] + 8 if labs.get("title") else 0)
1019
+ right = margin + extra_right
1020
+ bottom = margin + extra_bottom
1021
+ return (
1022
+ x + margin,
1023
+ y + top,
1024
+ max(8, w - margin - right),
1025
+ max(8, h - top - bottom),
1026
+ )
1027
+
1028
+
1029
+ def _box_2d(spec, x, y, w, h, labs, fonts, extra_right=0.0, extra_bottom=0.0):
1030
+ tick, title, note = fonts
1031
+ void = bool((spec.get("theme") or {}).get("void"))
1032
+ left = 10.0 if void else _y_gutter(spec, labs, fonts, w)
1033
+ x_name = labs.get("x") or ""
1034
+ x_text = (spec.get("themeOpts") or {}).get("xText") is not False
1035
+ bottom = 6 + (_line_height(tick) if x_text else 0) + (4 + _line_height(tick) if x_name else 0) + 8
1036
+ bottom += _x_label_drop(spec, tick) if x_text else 0
1037
+ if void:
1038
+ bottom = 10.0
1039
+ note_lines = _note_lines(spec.get("notes") or [], note, w)
1040
+ if note_lines:
1041
+ bottom += len(note_lines) * _note_line_height(note) + 4
1042
+ bottom += extra_bottom
1043
+ top = 10 + (title + 6 if labs.get("title") else 0)
1044
+ right = 14 + extra_right
1045
+ # A long legend must not squeeze the panel down to a sliver.
1046
+ min_panel = max(48.0, min(w * 0.42, w - left - 8.0))
1047
+ if w - left - right < min_panel:
1048
+ right = max(8.0, w - left - min_panel)
1049
+ # A bottom legend needs more than the usual axis margin.
1050
+ bottom_cap = 0.75 if extra_bottom else 0.38
1051
+ bottom = min(bottom, h * bottom_cap)
1052
+ top = min(top, h * 0.32)
1053
+ return (
1054
+ x + left,
1055
+ y + top,
1056
+ max(8, w - left - right),
1057
+ max(8, h - top - bottom),
1058
+ )
1059
+
1060
+
1061
+ def _draw_2d(
1062
+ spec, blobs, x, y, w, h, commands, labs, theme, fonts,
1063
+ extra_right=0.0, extra_bottom=0.0,
1064
+ ) -> None:
1065
+ box = _box_2d(spec, x, y, w, h, labs, fonts, extra_right, extra_bottom)
1066
+ scales = spec.get("scales") or {}
1067
+ window = _view_window(spec, box[2], box[3])
1068
+ surface = theme.get("surface") or "#0b1020"
1069
+ grid = theme.get("grid") or "#1c2742"
1070
+ axis = theme.get("axis") or "#2e3a5c"
1071
+ muted = theme.get("muted") or "#898781"
1072
+ ink2 = theme.get("ink2") or "#c3c2b7"
1073
+ ink = theme.get("ink") or "#ffffff"
1074
+ frame = str(theme.get("frame") or "box")
1075
+ tick_size, title_size, _note = fonts
1076
+
1077
+ def px(u, v):
1078
+ left, right, bottom, top = window
1079
+ sx = box[0] + (u - left) / (right - left) * box[2]
1080
+ sy = box[1] + (top - v) / (top - bottom) * box[3]
1081
+ return sx, sy
1082
+
1083
+ # A grid painted in the page colour is invisible, and at print
1084
+ # resolution the antialiased edge still shows. Skip it.
1085
+ opts = spec.get("themeOpts") or {}
1086
+ void = bool(theme.get("void"))
1087
+ panel = theme.get("panel") or surface
1088
+ if _rgb(panel) != _rgb(surface):
1089
+ # theme_grey's grey panel (or panel_background=element_rect(fill=)).
1090
+ commands.append(("rect", box[0], box[1], box[2], box[3], panel, None, 0, 1.0))
1091
+ if not void and opts.get("panelGrid", True) and _rgb(grid) != _rgb(panel):
1092
+ for value, _lab in _ticks(scales.get("x") or {}):
1093
+ u = _unit(scales.get("x") or {}, value)
1094
+ if u < window[0] - 0.02 or u > window[1] + 0.02:
1095
+ continue
1096
+ x0, _y0 = px(u, 0)
1097
+ commands.append(("line", x0, box[1], x0, box[1] + box[3], grid, 1, 1.0))
1098
+ for value, _lab in _ticks(scales.get("y") or {}):
1099
+ v = _unit(scales.get("y") or {}, value)
1100
+ if v < window[2] - 0.02 or v > window[3] + 0.02:
1101
+ continue
1102
+ _x0, y0 = px(0, v)
1103
+ commands.append(("line", box[0], y0, box[0] + box[2], y0, grid, 1, 1.0))
1104
+
1105
+ gz = bool(spec.get("gz"))
1106
+ # Marks stay inside the panel, as ggplot2 clips them (coord_cartesian
1107
+ # zooms past the data; a big point at the edge is cut, not spilled).
1108
+ commands.append(("clip", box[0], box[1], box[2], box[3]))
1109
+ for layer in spec.get("layers") or []:
1110
+ _draw_layer_2d(layer, spec, blobs, gz, px, commands)
1111
+ _draw_rugs(spec, commands, box, px, window)
1112
+ commands.append(("unclip",))
1113
+
1114
+ _draw_frame(commands, box, axis, frame)
1115
+ for value, lab in (() if void or opts.get("xText") is False else _ticks(scales.get("x") or {})):
1116
+ u = _unit(scales.get("x") or {}, value)
1117
+ if u < window[0] - 0.02 or u > window[1] + 0.02:
1118
+ continue
1119
+ sx, _sy = px(u, 0)
1120
+ if sx < box[0] - 1 or sx > box[0] + box[2] + 1:
1121
+ continue
1122
+ angle = float(opts.get("xAngle", 0.0))
1123
+ thin = int(opts.get("xThin", 1) or 1)
1124
+ if thin > 1 and int(round(float(value))) % thin:
1125
+ continue
1126
+ if angle > 0:
1127
+ # Turned labels end at their tick, as ggplot2's hjust = 1.
1128
+ commands.append((
1129
+ "text", sx, box[1] + box[3] + 6, str(lab), tick_size, muted,
1130
+ "end", "middle" if angle >= 60 else "top", -angle, 400,
1131
+ ))
1132
+ else:
1133
+ commands.append((
1134
+ "text", sx, box[1] + box[3] + 4, str(lab), tick_size, muted,
1135
+ "middle", "top", 0, 400,
1136
+ ))
1137
+ y_ticks = [] if void or opts.get("yText") is False else _thin_y_ticks(_ticks(scales.get("y") or {}), scales.get("y") or {}, box[3], tick_size)
1138
+ for value, lab in y_ticks:
1139
+ v = _unit(scales.get("y") or {}, value)
1140
+ if v < window[2] - 0.02 or v > window[3] + 0.02:
1141
+ continue
1142
+ _sx, sy = px(0, v)
1143
+ if sy < box[1] - 1 or sy > box[1] + box[3] + 1:
1144
+ continue
1145
+ commands.append((
1146
+ "text", box[0] - 6, sy, str(lab), tick_size, muted,
1147
+ "end", "middle", 0, 400,
1148
+ ))
1149
+ if labs.get("x") and not void:
1150
+ commands.append((
1151
+ "text", box[0] + box[2] / 2,
1152
+ box[1] + box[3] + 6 + (
1153
+ _line_height(tick_size) + _x_label_drop(spec, tick_size)
1154
+ if opts.get("xText") is not False else 0
1155
+ ),
1156
+ labs["x"], tick_size, ink2, "middle", "top", 0, 400,
1157
+ ))
1158
+ if labs.get("y") and not void:
1159
+ commands.append((
1160
+ "text", x + 8 + _line_height(tick_size) / 2, box[1] + box[3] / 2,
1161
+ labs["y"], tick_size, ink2, "middle", "middle", -90, 400,
1162
+ ))
1163
+ _draw_refs(spec, commands, window, px)
1164
+ _draw_arrows(spec, commands, px)
1165
+ placed: list[tuple[float, float, float, float]] = []
1166
+ text_boxes: list[tuple[float, float, float, float]] = []
1167
+ for ann in spec.get("ann") or []:
1168
+ text = str(ann.get("text") or "")
1169
+ if not text:
1170
+ continue
1171
+ try:
1172
+ u = _unit(scales.get("x") or {}, float(ann["x"]))
1173
+ v = _unit(scales.get("y") or {}, float(ann["y"]))
1174
+ except (TypeError, ValueError, KeyError):
1175
+ continue
1176
+ if u < window[0] - 0.02 or u > window[1] + 0.02:
1177
+ continue
1178
+ if v < window[2] - 0.02 or v > window[3] + 0.02:
1179
+ continue
1180
+ sx, sy = px(u, v)
1181
+ if ann.get("style") in {"text", "label"}:
1182
+ # ggplot2: geom_text's default 3.88 mm equals the base font. Keep
1183
+ # that ratio to this figure's base (axis text is 0.8 of it).
1184
+ scale = (tick_size / 0.8) / (3.88 * 96.0 / 25.4)
1185
+ _draw_text_ann(commands, ann, text, sx, sy, surface, text_boxes, scale)
1186
+ continue
1187
+ size = tick_size
1188
+ half_w = _text_width(text, size) / 2.0 + 3.0
1189
+ half_h = 0.65 * size + 1.0
1190
+ # Keep the label inside the panel. A thin tail area puts its centroid
1191
+ # on the axis; lift it clear.
1192
+ sx = min(max(sx, box[0] + half_w + 2.0), box[0] + box[2] - half_w - 2.0)
1193
+ sy = min(max(sy, box[1] + half_h + 2.0), box[1] + box[3] - half_h - 4.0)
1194
+ for _try in range(len(placed) + 1):
1195
+ hit = any(
1196
+ abs(sx - px0) < half_w + pw and abs(sy - py0) < half_h + ph
1197
+ for px0, py0, pw, ph in placed
1198
+ )
1199
+ if not hit:
1200
+ break
1201
+ sy -= 2.0 * half_h + 2.0
1202
+ placed.append((sx, sy, half_w, half_h))
1203
+ # A backing in the page colour keeps the label readable where it
1204
+ # crosses the curve or the fill.
1205
+ commands.append((
1206
+ "rect", sx - half_w, sy - half_h, 2.0 * half_w, 2.0 * half_h,
1207
+ surface, None, 0, 0.82,
1208
+ ))
1209
+ commands.append((
1210
+ "text", sx, sy, text, size, ink,
1211
+ "middle", "middle", 0, 600,
1212
+ ))
1213
+ if labs.get("title"):
1214
+ commands.append((
1215
+ "text", x + 12, y + 6, labs["title"], title_size, ink,
1216
+ "start", "top", 0, 600,
1217
+ ))
1218
+
1219
+
1220
+ def _draw_frame(commands, box, axis, frame: str) -> None:
1221
+ x, y, w, h = box
1222
+ if frame == "none" or w <= 0 or h <= 0:
1223
+ return
1224
+ if frame == "axes":
1225
+ commands.append(("line", x, y, x, y + h, axis, 1, 1.0))
1226
+ commands.append(("line", x, y + h, x + w, y + h, axis, 1, 1.0))
1227
+ return
1228
+ commands.append(("rect", x, y, w, h, None, axis, 1, 1.0))
1229
+
1230
+
1231
+ def _draw_layer_2d(layer, spec, blobs, gz, px, commands) -> None:
1232
+ kind = layer.get("kind")
1233
+ if kind not in _KINDS:
1234
+ raise ValueError(f"ggsave() cannot draw a {kind!r} layer")
1235
+ if kind in {"surface", "isosurface"}:
1236
+ raise ValueError(f"ggsave() cannot draw a {kind!r} layer on a 2D figure")
1237
+ n = int(layer.get("n") or 0)
1238
+ if n <= 0 or layer.get("blank"):
1239
+ return
1240
+ colors = _layer_colors(layer, spec, blobs, gz, n)
1241
+ alpha = float(layer.get("alpha") if layer.get("alpha") is not None else 1.0)
1242
+ xs = _norm_channel(layer, "x", blobs, gz, n)
1243
+ ys = _norm_channel(layer, "y", blobs, gz, n)
1244
+ if kind == "point":
1245
+ radii = _point_radii(layer, blobs, gz, n, scene=False, min_dim=1.0)
1246
+ shapes = _point_shapes(layer, blobs, gz, n)
1247
+ # aes(alpha=): one opacity per point.
1248
+ opacity = _norm_channel(layer, "opacity", blobs, gz, n) if layer.get("opacity") else None
1249
+ for i in range(n):
1250
+ cx, cy = px(float(xs[i]), float(ys[i]))
1251
+ a = float(opacity[i]) if opacity is not None else alpha
1252
+ _marker(commands, cx, cy, float(radii[i]), shapes[i], _hex(colors[i]), a)
1253
+ return
1254
+ if kind == "col":
1255
+ hw = float(layer.get("width") or 0.08) * 0.5
1256
+ y0 = float(layer["y0"]) if layer.get("y0") is not None else 0.0
1257
+ for i in range(n):
1258
+ x0, y_top = px(float(xs[i]) - hw, max(float(ys[i]), y0))
1259
+ x1, y_bot = px(float(xs[i]) + hw, min(float(ys[i]), y0))
1260
+ commands.append((
1261
+ "rect", min(x0, x1), min(y_top, y_bot), abs(x1 - x0), abs(y_bot - y_top),
1262
+ _hex(colors[i]), None, 0, alpha,
1263
+ ))
1264
+ return
1265
+ if kind == "box":
1266
+ _draw_boxes(layer, blobs, gz, n, colors, alpha, px, commands, spec)
1267
+ return
1268
+ if kind == "area":
1269
+ y0 = float(layer["y0"]) if layer.get("y0") is not None else 0.0
1270
+ width = float(layer.get("linewidth") or 1.5)
1271
+ for start, count in _groups(layer, n):
1272
+ if count < 2:
1273
+ continue
1274
+ color = _hex(colors[start])
1275
+ curve = [px(float(xs[i]), float(ys[i])) for i in range(start, start + count)]
1276
+ base_l = px(float(xs[start]), y0)
1277
+ base_r = px(float(xs[start + count - 1]), y0)
1278
+ commands.append(("polygon", curve + [base_r, base_l], color, None, 0, alpha))
1279
+ commands.append(("polyline", curve, color, width, min(1.0, alpha + 0.3)))
1280
+ return
1281
+ if kind == "poly" and layer.get("polygon"):
1282
+ # geom_polygon, crossbar bodies: SVG fills any simple shape itself.
1283
+ width = float(layer.get("linewidth") if layer.get("linewidth") is not None else 0.5)
1284
+ for start, count in _groups(layer, n):
1285
+ if count < 3:
1286
+ continue
1287
+ color = _hex(colors[start])
1288
+ corners = [px(float(xs[i]), float(ys[i])) for i in range(start, start + count)]
1289
+ commands.append(("polygon", corners, color, color if width > 0 else None, width, alpha))
1290
+ return
1291
+ if kind == "poly":
1292
+ width = float(layer.get("linewidth") or 1.5)
1293
+ for start, count in _groups(layer, n):
1294
+ if count < 3:
1295
+ continue
1296
+ color = _hex(colors[start])
1297
+ tris = _poly_triangles(xs, ys, start, count, px)
1298
+ if tris:
1299
+ # One mask, so shared edges do not darken the fill. A violin
1300
+ # with no colour of its own is white inside, as in ggplot2.
1301
+ if layer.get("plainFill"):
1302
+ surface = _hex(_rgb((spec.get("theme") or {}).get("surface") or "#ffffff"))
1303
+ commands.append(("polymask", tris, surface, 1.0))
1304
+ else:
1305
+ commands.append(("polymask", tris, color, alpha))
1306
+ curve = [px(float(xs[i]), float(ys[i])) for i in range(start, start + count)]
1307
+ commands.append(("polyline", curve, color, width, min(1.0, alpha + 0.3)))
1308
+ return
1309
+ width = float(layer.get("linewidth") or 2.0)
1310
+ dashes = layer.get("dashes")
1311
+ for index, (start, count) in enumerate(_groups(layer, n)):
1312
+ if count < 2:
1313
+ continue
1314
+ curve = [px(float(xs[i]), float(ys[i])) for i in range(start, start + count)]
1315
+ dash = dashes[index] if dashes and index < len(dashes) else layer.get("dash")
1316
+ color = _hex(colors[start])
1317
+ if not dash:
1318
+ commands.append(("polyline", curve, color, width, alpha))
1319
+ continue
1320
+ for a, b in _dash_path(curve, dash, width):
1321
+ commands.append(("line", a[0], a[1], b[0], b[1], color, width, alpha))
1322
+
1323
+
1324
+ def _poly_triangles(xs, ys, start, count, px):
1325
+ tris = []
1326
+ half = count // 2
1327
+ if half >= 2 and half * 2 == count:
1328
+ for i in range(half - 1):
1329
+ l0, l1 = start + i, start + i + 1
1330
+ r0, r1 = start + count - 1 - i, start + count - 2 - i
1331
+ for tri in ((l0, r0, l1), (r0, r1, l1)):
1332
+ tris.append([px(float(xs[k]), float(ys[k])) for k in tri])
1333
+ return tris
1334
+ for i in range(1, count - 1):
1335
+ tris.append([
1336
+ px(float(xs[k]), float(ys[k])) for k in (start, start + i, start + i + 1)
1337
+ ])
1338
+ return tris
1339
+
1340
+
1341
+ def _draw_boxes(layer, blobs, gz, n, colors, alpha, px, commands, spec) -> None:
1342
+ hw = float(layer.get("width") or 0.08) * 0.5
1343
+ cap = hw * 0.55
1344
+ ink = _hex(_rgb((spec.get("theme") or {}).get("ink") or "#ffffff"))
1345
+ ymin = _norm_channel(layer, "ymin", blobs, gz, n)
1346
+ lower = _norm_channel(layer, "lower", blobs, gz, n)
1347
+ middle = _norm_channel(layer, "middle", blobs, gz, n)
1348
+ upper = _norm_channel(layer, "upper", blobs, gz, n)
1349
+ ymax = _norm_channel(layer, "ymax", blobs, gz, n)
1350
+ xs = _norm_channel(layer, "x", blobs, gz, n)
1351
+ fill_alpha = min(1.0, alpha * 0.35)
1352
+ # aes(fill=): a filled box with a dark outline, whiskers, and median.
1353
+ filled = bool(layer.get("fillMapped"))
1354
+ plain = bool(layer.get("plainFill"))
1355
+ surface = _hex(_rgb((spec.get("theme") or {}).get("surface") or "#ffffff"))
1356
+ line_ink = _hex(_rgb((spec.get("theme") or {}).get("ink2") or "#333333"))
1357
+ for i in range(n):
1358
+ color = _hex(colors[i])
1359
+ x = float(xs[i])
1360
+ corners = [
1361
+ px(x - hw, float(lower[i])),
1362
+ px(x + hw, float(upper[i])),
1363
+ ]
1364
+ rect = (
1365
+ min(corners[0][0], corners[1][0]),
1366
+ min(corners[0][1], corners[1][1]),
1367
+ abs(corners[1][0] - corners[0][0]),
1368
+ abs(corners[1][1] - corners[0][1]),
1369
+ )
1370
+ stroke = line_ink if filled else color
1371
+ if plain:
1372
+ # No colour of its own: white inside, as ggplot2 draws it.
1373
+ commands.append(("rect", rect[0], rect[1], rect[2], rect[3], surface, None, 0, 1.0))
1374
+ else:
1375
+ commands.append((
1376
+ "rect", rect[0], rect[1], rect[2], rect[3], color, color, 1,
1377
+ min(1.0, alpha * 0.9) if filled else fill_alpha,
1378
+ ))
1379
+ commands.append(("rect", rect[0], rect[1], rect[2], rect[3], None, stroke, 1, alpha))
1380
+ _seg(commands, px(x, float(ymin[i])), px(x, float(lower[i])), stroke, alpha)
1381
+ _seg(commands, px(x, float(upper[i])), px(x, float(ymax[i])), stroke, alpha)
1382
+ _seg(commands, px(x - cap, float(ymin[i])), px(x + cap, float(ymin[i])), stroke, alpha)
1383
+ _seg(commands, px(x - cap, float(ymax[i])), px(x + cap, float(ymax[i])), stroke, alpha)
1384
+ _seg(commands, px(x - hw, float(middle[i])), px(x + hw, float(middle[i])), line_ink if filled else ink, alpha)
1385
+ n_out = int(layer.get("nOut") or 0)
1386
+ if n_out <= 0 or layer.get("ox") is None:
1387
+ return
1388
+ ox = _norm_channel(layer, "ox", blobs, gz, n_out)
1389
+ oy = _norm_channel(layer, "oy", blobs, gz, n_out)
1390
+ radius = float(layer.get("outlierSize") or 3.0) / 2.0
1391
+ ocolors = _outlier_colors(layer, spec, blobs, gz, n_out, colors)
1392
+ for i in range(n_out):
1393
+ cx, cy = px(float(ox[i]), float(oy[i]))
1394
+ commands.append(("circle", cx, cy, radius, _hex(ocolors[i]), None, 0, alpha))
1395
+
1396
+
1397
+ def _seg(commands, a, b, color, alpha) -> None:
1398
+ commands.append(("line", a[0], a[1], b[0], b[1], color, 1.25, alpha))
1399
+
1400
+
1401
+ def _draw_3d(
1402
+ spec, blobs, x, y, w, h, commands, labs, theme, fonts,
1403
+ extra_right=0.0, extra_bottom=0.0,
1404
+ ) -> None:
1405
+ box = _box_3d(x, y, w, h, labs, fonts, extra_right, extra_bottom)
1406
+ scales = spec.get("scales") or {}
1407
+ spans = []
1408
+ for axis in ("x", "y", "z"):
1409
+ scale = scales.get(axis) or {}
1410
+ spans.append((float(scale.get("hi", 1.0)) - float(scale.get("lo", 0.0))) or 1.0)
1411
+ max_span = max(spans + [1e-12])
1412
+ coord = spec.get("coord") or {}
1413
+ aspect = coord.get("aspect") or "data"
1414
+ ext = [1.0, 1.0, 1.0] if aspect == "equal" else [s / max_span for s in spans]
1415
+ if coord.get("ext") and len(coord["ext"]) == 3:
1416
+ ext = [float(v) for v in coord["ext"]]
1417
+ # Same camera as the viewer. A uniform scale about the projected
1418
+ # centre fills the panel and leaves a centred mark where it was.
1419
+ void = bool(theme.get("void"))
1420
+ layout = _axis_layout(fonts[0])
1421
+ # No tick labels to make room for: the box fills the panel.
1422
+ inner = _inset_box(box, 8.0 if void else layout["pad"], top=8.0)
1423
+ camera = coord.get("camera") or {}
1424
+ zoom = float(camera.get("zoom") or 1.0)
1425
+ base = _projector(ext, box, camera.get("dir"), zoom)
1426
+ project, centre = _fit_projector(base, ext, inner, zoom)
1427
+ if not void and centre is not None and _labels_on_top(project, centre, ext, inner):
1428
+ # Perspective can put an axis along the top of the box: give its
1429
+ # labels the same room as the others.
1430
+ inner = _inset_box(box, layout["pad"])
1431
+ project, centre = _fit_projector(base, ext, inner, zoom)
1432
+ ink = theme.get("ink") or "#ffffff"
1433
+ gz = bool(spec.get("gz"))
1434
+ min_dim = min(box[2], box[3])
1435
+
1436
+ if not void:
1437
+ _draw_back_panes(spec, ext, project, commands, theme)
1438
+ # A zoomed camera can put marks past the panel: keep them inside it.
1439
+ commands.append(("clip", box[0], box[1], box[2], box[3]))
1440
+
1441
+ triangles = []
1442
+ lines = []
1443
+ points = []
1444
+ for layer in spec.get("layers") or []:
1445
+ kind = layer.get("kind")
1446
+ if kind not in _KINDS:
1447
+ raise ValueError(f"ggsave() cannot draw a {kind!r} layer")
1448
+ if kind in {"col", "box", "area", "poly"}:
1449
+ raise ValueError(f"ggsave() cannot draw a {kind!r} layer on a 3D figure")
1450
+ n = int(layer.get("n") or 0)
1451
+ if n <= 0 or layer.get("blank"):
1452
+ continue
1453
+ colors = _layer_colors(layer, spec, blobs, gz, n)
1454
+ alpha = float(layer.get("alpha") if layer.get("alpha") is not None else 1.0)
1455
+ xs = _norm_channel(layer, "x", blobs, gz, n)
1456
+ ys = _norm_channel(layer, "y", blobs, gz, n)
1457
+ zs = _norm_channel(layer, "z", blobs, gz, n)
1458
+ world = np.column_stack([
1459
+ xs * ext[0], ys * ext[1], zs * ext[2],
1460
+ ])
1461
+ if kind in {"surface", "isosurface"}:
1462
+ _append_surface(
1463
+ layer, blobs, gz, world, colors, alpha, theme, project, triangles, lines,
1464
+ )
1465
+ elif kind == "point":
1466
+ radii = _point_radii(layer, blobs, gz, n, scene=True, min_dim=min_dim * 0.6)
1467
+ mode = (spec.get("coord") or {}).get("sizeMode") or "scene"
1468
+ if mode == "screen":
1469
+ radii = np.maximum(
1470
+ _raw_size_numbers(layer, blobs, gz, n) / 2.0, 0.75,
1471
+ )
1472
+ opacity = _norm_channel(layer, "opacity", blobs, gz, n) if layer.get("opacity") else None
1473
+ for i in range(n):
1474
+ hit = project(world[i])
1475
+ if hit is None:
1476
+ continue
1477
+ a = float(opacity[i]) if opacity is not None else alpha
1478
+ points.append((hit[2], hit[0], hit[1], float(radii[i]), _hex(colors[i]), a))
1479
+ else:
1480
+ width = float(layer.get("linewidth") or 2.0)
1481
+ for start, count in _groups(layer, n):
1482
+ if count < 2:
1483
+ continue
1484
+ projected = []
1485
+ for i in range(start, start + count):
1486
+ hit = project(world[i])
1487
+ if hit is None:
1488
+ if len(projected) >= 2:
1489
+ lines.append((projected, _hex(colors[start]), width, alpha))
1490
+ projected = []
1491
+ continue
1492
+ projected.append((hit[0], hit[1]))
1493
+ if len(projected) >= 2:
1494
+ lines.append((projected, _hex(colors[start]), width, alpha))
1495
+
1496
+ # Far triangles first, then lines and points.
1497
+ triangles.sort(key=lambda item: -item[0])
1498
+ for _depth, poly, color, alpha in triangles:
1499
+ # Neighbouring triangles leave antialiased hairlines between them;
1500
+ # a stroke in the fill colour closes them on an opaque surface.
1501
+ if alpha >= 0.9:
1502
+ commands.append(("polygon", poly, color, color, 0.6, alpha))
1503
+ else:
1504
+ commands.append(("polygon", poly, color, None, 0, alpha))
1505
+ for projected, color, width, alpha in lines:
1506
+ commands.append(("polyline", projected, color, width, alpha))
1507
+ points.sort(key=lambda item: -item[0])
1508
+ for _depth, sx, sy, radius, color, alpha in points:
1509
+ if box[0] - radius <= sx <= box[0] + box[2] + radius and box[1] - radius <= sy <= box[1] + box[3] + radius:
1510
+ commands.append(("circle", sx, sy, radius, color, None, 0, alpha))
1511
+ commands.append(("unclip",))
1512
+
1513
+ if not void:
1514
+ _draw_3d_axes(
1515
+ spec, ext, project, centre, commands, theme, fonts, labs, (x, y, w, h), layout,
1516
+ )
1517
+ if labs.get("title"):
1518
+ commands.append((
1519
+ "text", x + 12, y + 6, labs["title"], fonts[1], ink,
1520
+ "start", "top", 0, 600,
1521
+ ))
1522
+
1523
+
1524
+ _CUBE_EDGES = (
1525
+ (0, 1), (1, 3), (3, 2), (2, 0),
1526
+ (4, 5), (5, 7), (7, 6), (6, 4),
1527
+ (0, 4), (1, 5), (2, 6), (3, 7),
1528
+ )
1529
+
1530
+
1531
+ def _back_sides(corners) -> tuple[int, int, int] | None:
1532
+ """For x, y, z: which side (0 = min, 1 = max) faces away from the eye."""
1533
+ if any(c is None for c in corners):
1534
+ return None
1535
+ sides = []
1536
+ for bit in range(3):
1537
+ near = [c[2] for i, c in enumerate(corners) if not (i >> bit) & 1]
1538
+ far = [c[2] for i, c in enumerate(corners) if (i >> bit) & 1]
1539
+ sides.append(0 if sum(near) > sum(far) else 1)
1540
+ return tuple(sides)
1541
+
1542
+
1543
+ def _labels_on_top(project, centre, ext, inner) -> bool:
1544
+ """Whether an axis is labelled along an edge near the top of the panel."""
1545
+ corners = _cube_corners(project, ext)
1546
+ if any(c is None for c in corners):
1547
+ return False
1548
+ hull = _hull_edge_set(corners)
1549
+ top = inner[1] + 0.2 * inner[3]
1550
+ for axis in ("x", "y", "z"):
1551
+ pair = _visible_axis_edge(axis, hull, corners, centre)
1552
+ a, b = corners[pair[0]], corners[pair[1]]
1553
+ if axis != "z" and min(a[1], b[1]) < top and _outward_normal(a, b, centre)[1] < 0:
1554
+ return True
1555
+ return False
1556
+
1557
+
1558
+ def _draw_back_panes(spec, ext, project, commands, theme) -> None:
1559
+ """The three far walls with grid lines, as matplotlib and plotly draw a
1560
+ 3D box. The three edges at the near corner are left out, so they never
1561
+ cross the data."""
1562
+ corners = _cube_corners(project, ext)
1563
+ back = _back_sides(corners)
1564
+ if back is None:
1565
+ return
1566
+ surface = theme.get("surface") or "#0b1020"
1567
+ grid = theme.get("grid") or "#1c2742"
1568
+ axis_color = theme.get("axis") or "#2e3a5c"
1569
+ scales = spec.get("scales") or {}
1570
+ opts = spec.get("themeOpts") or {}
1571
+ if opts.get("panelGrid", True) and _rgb(grid) != _rgb(surface):
1572
+ for wall in range(3):
1573
+ fixed = float(back[wall]) * ext[wall]
1574
+ for axis in range(3):
1575
+ if axis == wall:
1576
+ continue
1577
+ other = 3 - wall - axis
1578
+ scale = scales.get("xyz"[axis]) or {}
1579
+ for value, _lab in _ticks(scale):
1580
+ try:
1581
+ u = _unit(scale, value)
1582
+ except (TypeError, ValueError):
1583
+ continue
1584
+ if not 0.001 < u < 0.999:
1585
+ continue
1586
+ a = np.zeros(3)
1587
+ a[wall] = fixed
1588
+ a[axis] = u * ext[axis]
1589
+ b = a.copy()
1590
+ b[other] = ext[other]
1591
+ pa, pb = project(a), project(b)
1592
+ if pa is None or pb is None:
1593
+ continue
1594
+ commands.append(("line", pa[0], pa[1], pb[0], pb[1], grid, 1, 1.0))
1595
+ if str(theme.get("frame") or "box") == "none":
1596
+ return
1597
+ for a, b in _CUBE_EDGES:
1598
+ bit = (a ^ b).bit_length() - 1
1599
+ on_back = any(
1600
+ ((a >> wall) & 1) == back[wall] for wall in range(3) if wall != bit
1601
+ )
1602
+ if not on_back:
1603
+ continue
1604
+ pa, pb = corners[a], corners[b]
1605
+ commands.append(("line", pa[0], pa[1], pb[0], pb[1], axis_color, 1, 1.0))
1606
+
1607
+
1608
+ def _append_surface(layer, blobs, gz, world, colors, alpha, theme, project, triangles, lines) -> None:
1609
+ node = layer.get("indices")
1610
+ if not isinstance(node, dict) or "id" not in node:
1611
+ raise ValueError(f"ggsave() cannot draw a {layer.get('kind')!r} layer without triangles")
1612
+ flat = _decode(blobs[node["id"]], node.get("dtype") or "u32", gz)
1613
+ usable = flat.size - (flat.size % 3)
1614
+ if usable <= 0:
1615
+ return
1616
+ faces = flat[:usable].reshape(-1, 3)
1617
+ if layer.get("wireframe"):
1618
+ ink = (theme or {}).get("ink2") or "#c3c2b7"
1619
+ base = layer.get("constColor") or ink
1620
+ seen = set()
1621
+ for face in faces:
1622
+ ids = [int(v) for v in face]
1623
+ for i in range(3):
1624
+ a = ids[i]
1625
+ b = ids[(i + 1) % 3]
1626
+ if a < 0 or b < 0 or a >= len(world) or b >= len(world):
1627
+ continue
1628
+ key = (a, b) if a < b else (b, a)
1629
+ if key in seen:
1630
+ continue
1631
+ seen.add(key)
1632
+ pa = project(world[a])
1633
+ pb = project(world[b])
1634
+ if pa is None or pb is None:
1635
+ continue
1636
+ lines.append(([(pa[0], pa[1]), (pb[0], pb[1])], base, 1.0, alpha))
1637
+ return
1638
+ light = np.array([1.2, 0.8, 1.5], dtype=np.float64)
1639
+ light /= np.linalg.norm(light)
1640
+ for face in faces:
1641
+ idx = [int(v) for v in face]
1642
+ if any(v < 0 or v >= len(world) for v in idx):
1643
+ continue
1644
+ tri = world[idx]
1645
+ normal = np.cross(tri[1] - tri[0], tri[2] - tri[0])
1646
+ length = float(np.linalg.norm(normal))
1647
+ if length < 1e-12:
1648
+ continue
1649
+ hits = [project(tri[k]) for k in range(3)]
1650
+ if any(hit is None for hit in hits):
1651
+ continue
1652
+ shade = min(1.0, 0.55 + 0.7 * abs(float(np.dot(normal / length, light))))
1653
+ rgb = tuple(int(round(channel * shade)) for channel in _average(colors, idx))
1654
+ depth = sum(hit[2] for hit in hits) / 3.0
1655
+ triangles.append((depth, [(hit[0], hit[1]) for hit in hits], _hex(rgb), alpha))
1656
+
1657
+
1658
+ def _y_gutter(spec, labs, fonts, width: float) -> float:
1659
+ """Pixels reserved on the left for the y ticks and the rotated y title."""
1660
+ tick = fonts[0]
1661
+ hidden = (spec.get("themeOpts") or {}).get("yText") is False
1662
+ yticks = [] if hidden else [str(lab) for _t, lab in _ticks(spec.get("scales", {}).get("y") or {})]
1663
+ tick_w = max((_text_width(lab, tick) for lab in yticks), default=0)
1664
+ y_name = labs.get("y") or ""
1665
+ name_w = _line_height(tick) if y_name else 0
1666
+ left = 8 + name_w + (6 if y_name else 0) + tick_w + 8
1667
+ return min(float(left), float(width) * 0.42)
1668
+
1669
+
1670
+ def _legend_budget(position, width: float, height: float, left: float) -> tuple[float, float]:
1671
+ """Largest legend box that stays inside the figure and leaves a panel."""
1672
+ if position == "none":
1673
+ return 0.0, 0.0
1674
+ if position == "bottom":
1675
+ return max(32.0, width - 8.0), max(24.0, height * 0.34)
1676
+ if isinstance(position, tuple):
1677
+ return max(32.0, width * 0.62), max(24.0, height * 0.62)
1678
+ # The panel keeps about half the figure. The 14px base margin and a
1679
+ # small gap sit between the panel and the legend box.
1680
+ min_panel = max(72.0, width * 0.46)
1681
+ box_w = width - left - 14.0 - 8.0 - min_panel
1682
+ if box_w < 44.0:
1683
+ box_w = max(36.0, min(width * 0.38, width - 8.0))
1684
+ return min(max(28.0, box_w), width - 4.0), max(24.0, height - 4.0)
1685
+
1686
+
1687
+ _WRAP_OPS = {"−", "+", "×", "·", "=", "/", "-", "–"}
1688
+
1689
+
1690
+ def _wrap_pieces(text: str) -> list[str]:
1691
+ """Words, with a lone operator glued to the word that follows it."""
1692
+ words = [word for word in str(text).split(" ") if word]
1693
+ pieces: list[str] = []
1694
+ index = 0
1695
+ while index < len(words):
1696
+ word = words[index]
1697
+ if word in _WRAP_OPS and index + 1 < len(words):
1698
+ pieces.append(word + " " + words[index + 1])
1699
+ index += 2
1700
+ else:
1701
+ pieces.append(word)
1702
+ index += 1
1703
+ return pieces
1704
+
1705
+
1706
+ def _wrap_text(text: str, size: float, max_width: float) -> list[str]:
1707
+ """Break ``text`` on spaces. A token wider than the line is shortened, not split."""
1708
+ raw = str(text).strip()
1709
+ if not raw:
1710
+ return []
1711
+ limit = max(8.0, float(max_width))
1712
+ if _text_width(raw, size) <= limit:
1713
+ return [raw]
1714
+ lines: list[str] = []
1715
+ current = ""
1716
+ for piece in _wrap_pieces(raw):
1717
+ trial = piece if not current else current + " " + piece
1718
+ if _text_width(trial, size) <= limit:
1719
+ current = trial
1720
+ continue
1721
+ if current:
1722
+ lines.append(current)
1723
+ current = ""
1724
+ if _text_width(piece, size) <= limit:
1725
+ current = piece
1726
+ else:
1727
+ lines.append(_ellipsis(piece, size, limit))
1728
+ if current:
1729
+ lines.append(current)
1730
+ return lines or [raw]
1731
+
1732
+
1733
+ def _ellipsis(text: str, size: float, limit: float) -> str:
1734
+ if _text_width(text, size) <= limit:
1735
+ return text
1736
+ piece = text
1737
+ while piece and _text_width(piece + "...", size) > limit:
1738
+ piece = piece[:-1]
1739
+ return (piece + "...") if piece else "..."
1740
+
1741
+
1742
+ def _split_caption(label: str) -> tuple[str, str]:
1743
+ """Separate ``formula (a = 2, b = 5)`` into the formula and the tail."""
1744
+ text = str(label).strip()
1745
+ if " (" in text and text.endswith(")"):
1746
+ formula, _, rest = text.rpartition(" (")
1747
+ return formula.strip(), "(" + rest
1748
+ return text, ""
1749
+
1750
+
1751
+ def _value_units(tail: str) -> list[str]:
1752
+ """``(a = 2, b = 5)`` becomes ``a = 2`` and ``b = 5``, each kept whole."""
1753
+ if not tail:
1754
+ return []
1755
+ body = tail[1:-1] if tail.startswith("(") and tail.endswith(")") else tail
1756
+ return [part.strip() for part in body.split(",") if part.strip()]
1757
+
1758
+
1759
+ def _pack_units(units: list[str], size: float, limit: float) -> list[str]:
1760
+ lines: list[str] = []
1761
+ current = ""
1762
+ for unit in units:
1763
+ piece = unit.strip()
1764
+ if not piece:
1765
+ continue
1766
+ if _text_width(piece, size) > limit:
1767
+ if current:
1768
+ lines.append(current)
1769
+ current = ""
1770
+ lines.append(_ellipsis(piece, size, limit))
1771
+ continue
1772
+ trial = piece if not current else current + " " + piece
1773
+ if _text_width(trial, size) <= limit:
1774
+ current = trial
1775
+ else:
1776
+ if current:
1777
+ lines.append(current)
1778
+ current = piece
1779
+ if current:
1780
+ lines.append(current)
1781
+ return lines
1782
+
1783
+
1784
+ def _cut_lines(lines: list[str], count: int, size: float, limit: float) -> list[str]:
1785
+ if len(lines) <= count:
1786
+ return lines
1787
+ kept = lines[: max(0, count)]
1788
+ if not kept:
1789
+ return kept
1790
+ last = kept[-1]
1791
+ if _text_width(last + "...", size) <= limit:
1792
+ kept[-1] = last + "..."
1793
+ else:
1794
+ kept[-1] = _ellipsis(last, size, limit)
1795
+ return kept
1796
+
1797
+
1798
+ def _formula_segments(text: str) -> list[str]:
1799
+ """Split juxtaposed factors ``f)(g`` so a wrap can fall between them."""
1800
+ if ")(" not in text:
1801
+ return [text]
1802
+ parts = text.split(")(")
1803
+ segments = []
1804
+ for index, part in enumerate(parts):
1805
+ if index == 0:
1806
+ segments.append(part + ")")
1807
+ elif index < len(parts) - 1:
1808
+ segments.append("(" + part + ")")
1809
+ else:
1810
+ segments.append("(" + part)
1811
+ return segments
1812
+
1813
+
1814
+ def _wrap_formula(text: str, size: float, limit: float) -> list[str]:
1815
+ """Wrap a formula. A break between ``)(`` is preferred to a break before ``−``."""
1816
+ raw = str(text).strip()
1817
+ if not raw or _text_width(raw, size) <= limit:
1818
+ return [raw] if raw else []
1819
+ lines: list[str] = []
1820
+ current = ""
1821
+ for segment in _formula_segments(raw):
1822
+ trial = (current + segment) if current else segment
1823
+ if _text_width(trial, size) <= limit:
1824
+ current = trial
1825
+ continue
1826
+ wrapped = _wrap_text(segment, size, limit)
1827
+ if current:
1828
+ lines.append(current)
1829
+ current = ""
1830
+ if len(wrapped) <= 1:
1831
+ current = wrapped[0] if wrapped else ""
1832
+ else:
1833
+ lines.extend(wrapped[:-1])
1834
+ current = wrapped[-1]
1835
+ if current:
1836
+ lines.append(current)
1837
+ return lines or [raw]
1838
+
1839
+
1840
+ def _legend_parts(label: str, size: float, width: float) -> tuple[list[str], list[str]]:
1841
+ """Formula lines and parameter lines. ``a = 2`` is never split across a line."""
1842
+ formula, tail = _split_caption(label)
1843
+ limit = max(8.0, float(width))
1844
+ if tail and _text_width(tail, size) <= limit:
1845
+ value_lines = [tail]
1846
+ else:
1847
+ units = _value_units(tail)
1848
+ shown = [
1849
+ unit + ("," if index < len(units) - 1 else "")
1850
+ for index, unit in enumerate(units)
1851
+ ]
1852
+ value_lines = _pack_units(shown, size, limit)
1853
+ formula_lines = _wrap_formula(formula, size, limit) if formula else []
1854
+ return formula_lines, value_lines
1855
+
1856
+
1857
+ def _legend_lines(label: str, size: float, width: float, max_lines: int) -> list[str]:
1858
+ """Wrap a legend label. The parameter values survive when the box is short."""
1859
+ formula_lines, value_lines = _legend_parts(label, size, width)
1860
+ limit = max(8.0, float(width))
1861
+ if not value_lines:
1862
+ return _cut_lines(formula_lines, max_lines, size, limit)
1863
+ room = max_lines - len(value_lines)
1864
+ if room < 1:
1865
+ return _cut_lines(value_lines, max_lines, size, limit)
1866
+ return _cut_lines(formula_lines, room, size, limit) + value_lines
1867
+
1868
+
1869
+ def _plan_entry_lines(labels: list[str], size: float, width: float, budget: int) -> list[list[str]]:
1870
+ """Share ``budget`` lines so every entry keeps its values."""
1871
+ if not labels:
1872
+ return []
1873
+ budget = max(len(labels), int(budget))
1874
+ limit = max(8.0, float(width))
1875
+ parts = [_legend_parts(label, size, width) for label in labels]
1876
+ full = [formula + values for formula, values in parts]
1877
+ if sum(len(lines) for lines in full) <= budget:
1878
+ return full
1879
+ value_count = sum(len(values) for _formula, values in parts)
1880
+ if value_count > budget:
1881
+ planned = [
1882
+ _cut_lines(values, 1, size, limit) or values[:1]
1883
+ for _formula, values in parts
1884
+ ]
1885
+ spare = budget - sum(len(lines) for lines in planned)
1886
+ for index, (_formula, values) in enumerate(parts):
1887
+ if spare <= 0:
1888
+ break
1889
+ longer = _cut_lines(values, len(planned[index]) + spare, size, limit)
1890
+ extra = len(longer) - len(planned[index])
1891
+ if extra > 0:
1892
+ planned[index] = longer
1893
+ spare -= extra
1894
+ return planned
1895
+ # A formula line for one curve and not the other is harder to read.
1896
+ formula_budget = budget - value_count
1897
+ cap = 0 if formula_budget < len(labels) else formula_budget // len(labels)
1898
+ return [
1899
+ (_cut_lines(formula, cap, size, limit) if cap else []) + values
1900
+ for formula, values in parts
1901
+ ]
1902
+
1903
+
1904
+ def _legend_prefers_bottom(spec, fonts, width: float, height: float, left: float) -> bool:
1905
+ """True when a right-hand key would split a token or cover the panel."""
1906
+ entries = [str(entry.get("label") or "") for entry in (spec.get("legend") or [])]
1907
+ if not entries:
1908
+ return False
1909
+ max_w, _max_h = _legend_budget("right", width, height, left)
1910
+ label_w = max(16.0, max_w - 16.0 - 18.0)
1911
+ size = fonts[0]
1912
+ row_h = _line_height(size) + 4
1913
+ total = 0
1914
+ for label in entries:
1915
+ lines = _legend_lines(label, size, label_w, 100)
1916
+ total += max(1, len(lines))
1917
+ for line in lines:
1918
+ if line.endswith("...") and not label.endswith("..."):
1919
+ return True
1920
+ if line.strip() in _WRAP_OPS:
1921
+ return True
1922
+ return total * row_h > float(height) * 0.40
1923
+
1924
+
1925
+ def _note_lines(notes, size: float, width: float) -> list[str]:
1926
+ lines: list[str] = []
1927
+ limit = max(40.0, float(width) - 24.0)
1928
+ for note in notes:
1929
+ text = str(note).strip()
1930
+ if text:
1931
+ lines.extend(_wrap_text(text, size, limit))
1932
+ return lines
1933
+
1934
+
1935
+ def _legend_position(spec):
1936
+ raw = spec.get("legendPosition")
1937
+ if isinstance(raw, (list, tuple)) and len(raw) == 2:
1938
+ try:
1939
+ return (float(raw[0]), float(raw[1]))
1940
+ except (TypeError, ValueError):
1941
+ return "right"
1942
+ if raw in {"right", "bottom", "none"}:
1943
+ return raw
1944
+ return "right"
1945
+
1946
+
1947
+ def _legend_metrics(
1948
+ spec, theme, fonts, color_label: str = "", max_box=None, *, columns: bool = False
1949
+ ):
1950
+ """Rows and the legend box size, or None when there is nothing to draw.
1951
+
1952
+ Labels wrap to ``max_box`` so the box cannot be wider than the figure.
1953
+ ``columns`` (a legend under the panel) puts short entries side by side.
1954
+ """
1955
+ entries = list(spec.get("legend") or [])
1956
+ color = spec.get("color") or {}
1957
+ size_legend = spec.get("sizeLegend")
1958
+ if color.get("guide") is False:
1959
+ # guides(colour="none"): the colours stay, the bar goes.
1960
+ color = {}
1961
+ if (
1962
+ not entries and color.get("kind") != "num" and not size_legend
1963
+ and not spec.get("shapeLegend") and not spec.get("linetypeLegend")
1964
+ and not spec.get("alphaLegend")
1965
+ ):
1966
+ return None
1967
+ ink = theme.get("ink") or "#ffffff"
1968
+ tick = fonts[0]
1969
+ row_h = _line_height(tick) + 4
1970
+ max_w = None if max_box is None else float(max_box[0])
1971
+ max_h = None if max_box is None else float(max_box[1])
1972
+ separate_keys = spec.get("shapeLegend") or spec.get("linetypeLegend")
1973
+ if columns and entries and not size_legend and not separate_keys and max_w is not None:
1974
+ grid = _legend_grid(entries, color_label, tick, row_h, max_w, ink)
1975
+ if grid is not None:
1976
+ return grid
1977
+ label_w = None if max_w is None else max(16.0, max_w - 16.0 - 18.0)
1978
+ title_w = None if max_w is None else max(16.0, max_w - 16.0)
1979
+ max_lines = 8
1980
+ if max_h is not None and row_h:
1981
+ max_lines = max(2, int((max_h - 8) / row_h))
1982
+ rows = []
1983
+ title = color_label or ""
1984
+ title_count = 0
1985
+ if title and (entries or color.get("kind") == "num"):
1986
+ parts = _legend_lines(title, tick, title_w or 10_000, max_lines) if title_w else [title]
1987
+ rows.append(("title", parts[0] if parts else title))
1988
+ for extra in parts[1:]:
1989
+ rows.append(("cont", extra))
1990
+ title_count = len(parts) if parts else 1
1991
+ labels = [str(entry.get("label") or "") for entry in entries]
1992
+ if max_h is not None and row_h:
1993
+ # Never drop an entry: a missing row hides a curve. The box may grow
1994
+ # past its budget by one line per entry; _legend_reserve makes room.
1995
+ needed = 8 + row_h * (title_count + len(labels))
1996
+ if needed > max_h:
1997
+ max_h = float(needed)
1998
+ max_lines = max(max_lines, title_count + len(labels))
1999
+ budget = max_lines - title_count if max_h is not None else max(max_lines, len(labels) or 1)
2000
+ plans = _plan_entry_lines(labels, tick, label_w or 10_000, budget)
2001
+ for entry, parts in zip(entries, plans):
2002
+ color_hex = entry.get("color") or ink
2003
+ if not parts:
2004
+ parts = [str(entry.get("label") or "")]
2005
+ rows.append(("swatch", parts[0], color_hex, entry.get("shape"), entry.get("dash")))
2006
+ for extra in parts[1:]:
2007
+ rows.append(("cont", extra))
2008
+ ramp = color.get("ramp") or []
2009
+ if not entries and color.get("kind") == "num" and ramp:
2010
+ rows.append(("ramp", ramp, color.get("lo"), color.get("hi")))
2011
+ bar_title = (spec.get("labs") or {}).get("colorBar")
2012
+ if entries and color.get("kind") == "num" and ramp and bar_title is not None:
2013
+ # Class entries and a colour bar together (boxes on a height cloud).
2014
+ if bar_title:
2015
+ rows.append(("size-title", str(bar_title)))
2016
+ rows.append(("ramp", ramp, color.get("lo"), color.get("hi")))
2017
+ rows.extend(_key_rows(spec, ink))
2018
+ if size_legend and size_legend.get("breaks"):
2019
+ rows.append(("size-title", str(size_legend.get("label") or "size")))
2020
+ for br in size_legend["breaks"]:
2021
+ rows.append(("bubble", str(br.get("label") or ""), float(br.get("t") or 0),
2022
+ float(br.get("alpha", 0.85))))
2023
+ alpha_legend = spec.get("alphaLegend")
2024
+ if alpha_legend and alpha_legend.get("breaks"):
2025
+ rows.append(("size-title", str(alpha_legend.get("label") or "alpha")))
2026
+ for br in alpha_legend["breaks"]:
2027
+ rows.append(("alpha", str(br.get("label") or ""), float(br.get("alpha") or 0)))
2028
+ if not rows:
2029
+ return None
2030
+ text_w = 0
2031
+ for row in rows:
2032
+ if row[0] in {"title", "size-title"}:
2033
+ text_w = max(text_w, _text_width(row[1], tick))
2034
+ elif row[0] in {"swatch", "bubble", "cont", "alpha"}:
2035
+ text_w = max(text_w, _text_width(row[1], tick) + 18)
2036
+ elif row[0] == "ramp":
2037
+ # A vertical colour bar (ggplot2's colourbar): bar, gap, labels.
2038
+ labels = [_ramp_label(v) for v in _ramp_ticks(row[2], row[3])]
2039
+ text_w = max(text_w, 10 + 6 + max(_text_width(t, max(9, tick - 1)) for t in labels))
2040
+ box_w = text_w + 16
2041
+ if max_w is not None:
2042
+ box_w = min(box_w, max_w)
2043
+ box_h = 8.0
2044
+ kept = []
2045
+ for row in rows:
2046
+ if row[0] == "ramp":
2047
+ step = _RAMP_HEIGHT + 6
2048
+ elif row[0] == "bubble":
2049
+ step = max(row_h, 8 + int(round(row[2] * 16)))
2050
+ else:
2051
+ step = row_h
2052
+ if max_h is not None and kept and box_h + step > max_h:
2053
+ break
2054
+ kept.append(row)
2055
+ box_h += step
2056
+ if max_h is not None:
2057
+ box_h = min(box_h, max_h)
2058
+ return {"rows": kept, "w": box_w, "h": box_h, "row_h": row_h}
2059
+
2060
+
2061
+ def _legend_grid(entries, title: str, tick: float, row_h: float, max_w: float, ink: str):
2062
+ """Entries in columns under the panel, or None when two do not fit side by side.
2063
+
2064
+ Every entry is kept: a bottom legend that drops rows hides a curve.
2065
+ """
2066
+ labels = [str(entry.get("label") or "") for entry in entries]
2067
+ swatch, gap, pad = 18.0, 14.0, 16.0
2068
+ col_w = max(_text_width(label, tick) for label in labels) + swatch
2069
+ ncols = int((max_w - pad + gap) // (col_w + gap))
2070
+ ncols = min(ncols, len(labels))
2071
+ if ncols < 2:
2072
+ return None
2073
+ nrows = -(-len(labels) // ncols)
2074
+ rows = []
2075
+ if title:
2076
+ rows.append(("title", title))
2077
+ cells = [
2078
+ ("swatch", label, entry.get("color") or ink, entry.get("shape"), entry.get("dash"))
2079
+ for label, entry in zip(labels, entries)
2080
+ ]
2081
+ body_w = ncols * col_w + (ncols - 1) * gap
2082
+ box_w = min(max_w, max(body_w, _text_width(title, tick) if title else 0.0) + pad)
2083
+ box_h = 8.0 + row_h * ((1 if title else 0) + nrows)
2084
+ return {
2085
+ "rows": rows,
2086
+ "cells": cells,
2087
+ "ncols": ncols,
2088
+ "col_w": col_w + gap,
2089
+ "w": box_w,
2090
+ "h": box_h,
2091
+ "row_h": row_h,
2092
+ }
2093
+
2094
+
2095
+ def _key_rows(spec, ink: str) -> list:
2096
+ """Rows for shape and linetype legends that are not merged with colour."""
2097
+ rows = []
2098
+ for key, legend in (("shape", spec.get("shapeLegend")), ("dash", spec.get("linetypeLegend"))):
2099
+ if not legend:
2100
+ continue
2101
+ rows.append(("size-title", str(legend.get("label") or key)))
2102
+ for entry in legend.get("entries") or []:
2103
+ shape = entry.get("shape") if key == "shape" else None
2104
+ dash = entry.get("dash") if key == "dash" else None
2105
+ rows.append(("swatch", str(entry.get("label") or ""), ink, shape, dash))
2106
+ return rows
2107
+
2108
+
2109
+ def _short_number(value) -> str:
2110
+ try:
2111
+ number = float(value)
2112
+ except (TypeError, ValueError):
2113
+ return str(value)
2114
+ return f"{number:.3g}".replace("-", "−")
2115
+
2116
+
2117
+ def _ramp_label(value) -> str:
2118
+ """A colour-bar value written like the axis numbers."""
2119
+ from plot3.scales import fmt_num
2120
+
2121
+ number = float(value)
2122
+ if number != 0 and (abs(number) >= 1e4 or abs(number) < 1e-3):
2123
+ return f"{number:.3g}"
2124
+ return fmt_num(float(f"{number:.4g}"))
2125
+
2126
+
2127
+ def _legend_key(commands, x, y, row) -> None:
2128
+ """The key in front of a legend label: a square, a point shape, or a line."""
2129
+ color = row[2]
2130
+ shape = row[3] if len(row) > 3 else None
2131
+ dash = row[4] if len(row) > 4 else None
2132
+ if shape:
2133
+ _marker(commands, x + 4.5, y + 4.5, 4.0, shape, color, 1.0)
2134
+ elif dash is not None:
2135
+ a, b = (x - 1, y + 4.5), (x + 11, y + 4.5)
2136
+ if dash:
2137
+ for p0, p1 in _dash_segments(a, b, dash, 1.6):
2138
+ commands.append(("line", p0[0], p0[1], p1[0], p1[1], color, 1.6, 1.0))
2139
+ else:
2140
+ commands.append(("line", a[0], a[1], b[0], b[1], color, 1.6, 1.0))
2141
+ else:
2142
+ commands.append(("rect", x, y, 9, 9, color, None, 0, 1.0))
2143
+
2144
+
2145
+ def _legend_reserve(position, metrics, width: float, height: float, left: float = 0.0) -> tuple[float, float]:
2146
+ if metrics is None or position in {"none"} or isinstance(position, tuple):
2147
+ return 0.0, 0.0
2148
+ if position == "bottom":
2149
+ # Usually a third of the figure; more only when every entry needs it.
2150
+ cap = max(height * 0.36, min(float(metrics["h"]) + 10.0, height * 0.55))
2151
+ return 0.0, min(float(metrics["h"]) + 10.0, cap)
2152
+ # Leave the panel at least ~46% of the figure, after the y-axis gutter.
2153
+ room = width - left - max(64.0, width * 0.46)
2154
+ return min(float(metrics["w"]) + 12.0, max(0.0, room)), 0.0
2155
+
2156
+
2157
+ def _legend_origin(position, box, metrics, x, y, w, h) -> tuple[float, float]:
2158
+ box_w = min(float(metrics["w"]), max(1.0, w - 4.0))
2159
+ box_h = min(float(metrics["h"]), max(1.0, h - 4.0))
2160
+ metrics["w"] = box_w
2161
+ metrics["h"] = box_h
2162
+ if position == "bottom":
2163
+ lx = box[0] + max(0.0, (box[2] - box_w) / 2.0)
2164
+ ly = y + h - box_h - 6
2165
+ elif isinstance(position, tuple):
2166
+ px, py = position
2167
+ lx = box[0] + px * max(0.0, box[2] - box_w)
2168
+ ly = box[1] + (1.0 - py) * max(0.0, box[3] - box_h)
2169
+ else:
2170
+ lx = x + w - box_w - 6
2171
+ ly = box[1]
2172
+ # A box wider than the cell used to clamp to a negative x. Pin it to the cell.
2173
+ lx = min(max(lx, x), x + max(0.0, w - box_w))
2174
+ ly = min(max(ly, y), y + max(0.0, h - box_h))
2175
+ return lx, ly
2176
+
2177
+
2178
+ def _paint_legend(commands, origin, metrics, theme, fonts) -> None:
2179
+ ink = theme.get("ink") or "#ffffff"
2180
+ ink2 = theme.get("ink2") or "#c3c2b7"
2181
+ surface = theme.get("surface") or "#0b1020"
2182
+ grid = theme.get("grid") or "#1c2742"
2183
+ if _rgb(grid) == _rgb(surface):
2184
+ grid = theme.get("muted") or "#898781"
2185
+ tick = fonts[0]
2186
+ row_h = metrics["row_h"]
2187
+ lx, ly = origin
2188
+ # Opaque, so a legend inside the panel does not fade the marks under it.
2189
+ commands.append((
2190
+ "rect", lx, ly, metrics["w"], metrics["h"], surface, grid, 1, 1.0,
2191
+ ))
2192
+ cursor = ly + 6
2193
+ for row in metrics["rows"]:
2194
+ if row[0] in {"title", "size-title"}:
2195
+ commands.append(("text", lx + 8, cursor, row[1], tick, ink, "start", "top", 0, 600))
2196
+ cursor += row_h
2197
+ elif row[0] == "swatch":
2198
+ _legend_key(commands, lx + 8, cursor + 2, row)
2199
+ commands.append(("text", lx + 22, cursor, row[1], tick, ink2, "start", "top", 0, 400))
2200
+ cursor += row_h
2201
+ elif row[0] == "cont":
2202
+ commands.append(("text", lx + 22, cursor, row[1], tick, ink2, "start", "top", 0, 400))
2203
+ cursor += row_h
2204
+ elif row[0] == "alpha":
2205
+ # aes(alpha=): a dot at each break's opacity.
2206
+ commands.append(("circle", lx + 13, cursor + row_h / 2 - 2, 4.5, ink, None, 0, row[2]))
2207
+ commands.append(("text", lx + 22, cursor, row[1], tick, ink2, "start", "top", 0, 400))
2208
+ cursor += row_h
2209
+ elif row[0] == "ramp":
2210
+ # Vertical colour bar: high at the top, as ggplot2 draws it, with
2211
+ # a few labelled values beside it (three significant figures).
2212
+ top, height = cursor + 2, float(_RAMP_HEIGHT)
2213
+ _draw_ramp_vertical(commands, lx + 8, top, 10, height, row[1])
2214
+ lo, hi = float(row[2]), float(row[3])
2215
+ for value in _ramp_ticks(lo, hi):
2216
+ frac = 0.0 if hi == lo else (value - lo) / (hi - lo)
2217
+ ty = top + height * (1.0 - frac)
2218
+ # ggplot2's colour bar: short white ticks inside the bar.
2219
+ commands.append(("line", lx + 8, ty, lx + 10.5, ty, "#ffffff", 1, 0.9))
2220
+ commands.append(("line", lx + 15.5, ty, lx + 18, ty, "#ffffff", 1, 0.9))
2221
+ commands.append((
2222
+ "text", lx + 22, ty, _ramp_label(value), max(9, tick - 1), ink2,
2223
+ "start", "middle", 0, 400,
2224
+ ))
2225
+ cursor += _RAMP_HEIGHT + 6
2226
+ else:
2227
+ diameter = max(4.0, row[2] * 16.0)
2228
+ commands.append((
2229
+ "circle", lx + 8 + diameter / 2, cursor + diameter / 2, diameter / 2,
2230
+ ink2, None, 0, row[3] if len(row) > 3 else 0.85,
2231
+ ))
2232
+ commands.append((
2233
+ "text", lx + 8 + diameter + 6, cursor, row[1], tick, ink2, "start", "top", 0, 400,
2234
+ ))
2235
+ cursor += max(row_h, diameter + 3)
2236
+ cells = metrics.get("cells") or []
2237
+ ncols = int(metrics.get("ncols") or 1)
2238
+ for index, cell in enumerate(cells):
2239
+ row, col = divmod(index, ncols)
2240
+ cx = lx + 8 + col * float(metrics["col_w"])
2241
+ cy = cursor + row * row_h
2242
+ _legend_key(commands, cx, cy + 2, cell)
2243
+ commands.append(("text", cx + 14, cy, cell[1], tick, ink2, "start", "top", 0, 400))
2244
+
2245
+
2246
+ _RAMP_HEIGHT = 84
2247
+
2248
+
2249
+ def _ramp_ticks(lo, hi) -> list[float]:
2250
+ """Nice values inside the bar, as ggplot2 labels it; the ends when
2251
+ fewer than two nice values fit."""
2252
+ lo, hi = float(lo), float(hi)
2253
+ if not (math.isfinite(lo) and math.isfinite(hi)) or hi <= lo:
2254
+ return [lo]
2255
+ from plot3.scales import nice_ticks
2256
+
2257
+ eps = 1e-9 * (hi - lo)
2258
+ inner = [float(t) for t in nice_ticks(lo, hi, 5) if lo - eps <= t <= hi + eps]
2259
+ if len(inner) > 5:
2260
+ inner = inner[::2]
2261
+ if len(inner) >= 2:
2262
+ return inner
2263
+ return [lo] + inner + [hi]
2264
+
2265
+
2266
+ def _draw_ramp_vertical(commands, x, y, w, h, ramp) -> None:
2267
+ stops = ramp or ["#000000", "#ffffff"]
2268
+ steps = max(2, int(h))
2269
+ for i in range(steps):
2270
+ color = _hex(_ramp_at([_rgb(stop) for stop in stops], 1.0 - i / (steps - 1)))
2271
+ commands.append(("rect", x, y + i * h / steps, w, h / steps + 0.5, color, None, 0, 1.0))
2272
+
2273
+
2274
+ def _draw_ramp(commands, x, y, w, h, ramp) -> None:
2275
+ stops = ramp or ["#000000", "#ffffff"]
2276
+ steps = max(2, int(w))
2277
+ for i in range(steps):
2278
+ color = _hex(_ramp_at([_rgb(stop) for stop in stops], i / (steps - 1)))
2279
+ commands.append(("rect", x + i * w / steps, y, w / steps + 0.5, h, color, None, 0, 1.0))
2280
+
2281
+
2282
+ # Corner order matches _draw_3d: iz, then iy, then ix. The first edge of
2283
+ # each axis is the min edge the viewer labels.
2284
+ _AXIS_EDGES = {
2285
+ "x": ((0, 1), (2, 3), (4, 5), (6, 7)),
2286
+ "y": ((0, 2), (1, 3), (4, 6), (5, 7)),
2287
+ "z": ((0, 4), (1, 5), (2, 6), (3, 7)),
2288
+ }
2289
+
2290
+
2291
+ def _axis_layout(size: float) -> dict:
2292
+ """Pixels between the cube and the axis title, and the pad that reserves them."""
2293
+ line = float(_line_height(size))
2294
+ # Tick text starts just outside the edge. The title clears that whole line.
2295
+ tick_gap = 6.0
2296
+ title_gap = tick_gap + line + 8.0
2297
+ pad = title_gap + line + 4.0
2298
+ return {"line": line, "tick_gap": tick_gap, "title_gap": title_gap, "pad": pad}
2299
+
2300
+
2301
+ def _inset_box(box, pad: float, top: float | None = None):
2302
+ """Room for axis labels. The camera never puts labels along the top of
2303
+ the cube, so that side keeps only a small margin and the cube grows."""
2304
+ x, y, w, h = box
2305
+ pad = min(max(0.0, float(pad)), w * 0.28, h * 0.28)
2306
+ top = pad if top is None else min(max(0.0, float(top)), pad)
2307
+ return (x + pad, y + top, max(4.0, w - 2.0 * pad), max(4.0, h - pad - top))
2308
+
2309
+
2310
+ def _fit_projector(project, ext, inner, zoom: float = 1.0):
2311
+ """Scale and centre the projected cube's outline until it meets `inner`,
2312
+ then scale by ``zoom`` (coord_3d(zoom=2) shows the middle, twice as
2313
+ large). Perspective makes the outline lopsided about the cube's centre,
2314
+ so the outline, not the centre, is what gets centred."""
2315
+ corners = [
2316
+ hit for hit in (
2317
+ project(np.array([ix, iy, iz], dtype=np.float64))
2318
+ for iz in (0.0, float(ext[2]))
2319
+ for iy in (0.0, float(ext[1]))
2320
+ for ix in (0.0, float(ext[0]))
2321
+ ) if hit is not None
2322
+ ]
2323
+ if len(corners) < 2:
2324
+ return project, None
2325
+ xs = [hit[0] for hit in corners]
2326
+ ys = [hit[1] for hit in corners]
2327
+ bx, by = (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0
2328
+ bw, bh = max(max(xs) - min(xs), 1e-9), max(max(ys) - min(ys), 1e-9)
2329
+ x0, y0, w, h = inner
2330
+ scale = min(w / bw, h / bh) * max(float(zoom), 1e-6)
2331
+ tx, ty = x0 + w / 2.0, y0 + h / 2.0
2332
+
2333
+ def fitted(point):
2334
+ hit = project(point)
2335
+ if hit is None:
2336
+ return None
2337
+ return (tx + (hit[0] - bx) * scale, ty + (hit[1] - by) * scale, hit[2])
2338
+
2339
+ centre_hit = fitted(np.asarray(ext, dtype=np.float64) / 2.0)
2340
+ return fitted, (None if centre_hit is None else (centre_hit[0], centre_hit[1]))
2341
+
2342
+
2343
+ def _cube_corners(project, ext):
2344
+ return [
2345
+ project(np.array([ix, iy, iz], dtype=np.float64))
2346
+ for iz in (0.0, float(ext[2]))
2347
+ for iy in (0.0, float(ext[1]))
2348
+ for ix in (0.0, float(ext[0]))
2349
+ ]
2350
+
2351
+
2352
+ def _hull_edge_set(points) -> set[tuple[int, int]]:
2353
+ """Index pairs of the convex hull. `points` may contain None."""
2354
+ indexed = [(i, p[0], p[1]) for i, p in enumerate(points) if p is not None]
2355
+ indexed.sort(key=lambda item: (item[1], item[2]))
2356
+ if len(indexed) < 3:
2357
+ return set()
2358
+
2359
+ def cross(o, a, b):
2360
+ return (a[1] - o[1]) * (b[2] - o[2]) - (a[2] - o[2]) * (b[1] - o[1])
2361
+
2362
+ lower: list = []
2363
+ for point in indexed:
2364
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0:
2365
+ lower.pop()
2366
+ lower.append(point)
2367
+ upper: list = []
2368
+ for point in reversed(indexed):
2369
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0:
2370
+ upper.pop()
2371
+ upper.append(point)
2372
+ hull = [point[0] for point in lower[:-1] + upper[:-1]]
2373
+ edges = set()
2374
+ for i, a in enumerate(hull):
2375
+ b = hull[(i + 1) % len(hull)]
2376
+ edges.add((a, b) if a < b else (b, a))
2377
+ return edges
2378
+
2379
+
2380
+ def _corner_xyz(index: int, ext) -> np.ndarray:
2381
+ return np.array([
2382
+ (index & 1) * ext[0],
2383
+ ((index >> 1) & 1) * ext[1],
2384
+ ((index >> 2) & 1) * ext[2],
2385
+ ], dtype=np.float64)
2386
+
2387
+
2388
+ def _visible_axis_edge(axis: str, hull, corners, centre):
2389
+ """A silhouette edge parallel to `axis`. Prefer the viewer's min edge."""
2390
+ pairs = _AXIS_EDGES[axis]
2391
+ best = None
2392
+ best_dist = -1.0
2393
+ for pair in pairs:
2394
+ key = pair if pair[0] < pair[1] else (pair[1], pair[0])
2395
+ if key not in hull:
2396
+ continue
2397
+ if pair == pairs[0]:
2398
+ return pair
2399
+ a = corners[pair[0]]
2400
+ b = corners[pair[1]]
2401
+ mx = (a[0] + b[0]) / 2.0 - centre[0]
2402
+ my = (a[1] + b[1]) / 2.0 - centre[1]
2403
+ dist = mx * mx + my * my
2404
+ if dist > best_dist:
2405
+ best_dist = dist
2406
+ best = pair
2407
+ return best if best is not None else pairs[0]
2408
+
2409
+
2410
+ def _outward_normal(p0, p1, centre) -> tuple[float, float]:
2411
+ """Screen normal of an edge, pointing away from the cube centre."""
2412
+ dx = p1[0] - p0[0]
2413
+ dy = p1[1] - p0[1]
2414
+ length = math.hypot(dx, dy)
2415
+ if length < 1e-6:
2416
+ return 0.0, 1.0
2417
+ nx, ny = -dy / length, dx / length
2418
+ mx = (p0[0] + p1[0]) / 2.0 - centre[0]
2419
+ my = (p0[1] + p1[1]) / 2.0 - centre[1]
2420
+ if nx * mx + ny * my < 0.0:
2421
+ nx, ny = -nx, -ny
2422
+ return nx, ny
2423
+
2424
+
2425
+ def _outside_anchor(nx: float, ny: float) -> tuple[str, str]:
2426
+ """Anchor so the glyphs sit further outside the edge than the anchor point."""
2427
+ if abs(nx) >= abs(ny):
2428
+ return ("start" if nx > 0.0 else "end"), "middle"
2429
+ return "middle", ("top" if ny > 0.0 else "alphabetic")
2430
+
2431
+
2432
+ def _label_box(x: float, y: float, text: str, size: float, anchor: str, baseline: str):
2433
+ width = float(_text_width(text, size)) + 2.0
2434
+ height = 1.2 * float(size)
2435
+ left = x - width if anchor == "end" else x - width / 2.0 if anchor == "middle" else x
2436
+ top = y if baseline == "top" else y - height if baseline == "alphabetic" else y - height / 2.0
2437
+ return (left, top, width, height)
2438
+
2439
+
2440
+ def _boxes_overlap(a, b) -> bool:
2441
+ return a[0] < b[0] + b[2] and b[0] < a[0] + a[2] and a[1] < b[1] + b[3] and b[1] < a[1] + a[3]
2442
+
2443
+
2444
+ def _clamp_point(px: float, py: float, bounds) -> tuple[float, float]:
2445
+ x, y, w, h = bounds
2446
+ return (
2447
+ min(max(px, x + 3.0), x + w - 3.0),
2448
+ min(max(py, y + 3.0), y + h - 3.0),
2449
+ )
2450
+
2451
+
2452
+ def _thin_marks(marks, air: float) -> list:
2453
+ """Every k-th tick, the smallest k whose labels do not touch, keeping
2454
+ round values (0, 5, 10 rather than 1, 4, 7) when the ticks allow."""
2455
+ if len(marks) < 3:
2456
+ return marks
2457
+
2458
+ def roomy(box):
2459
+ return (box[0] - air, box[1] - air, box[2] + 2 * air, box[3] + 2 * air)
2460
+
2461
+ for k in range(1, len(marks)):
2462
+ offsets = list(range(k))
2463
+ try:
2464
+ step = abs(float(marks[1][0]) - float(marks[0][0])) * k
2465
+ offsets.sort(key=lambda o: 0 if abs(math.remainder(float(marks[o][0]), step)) < 1e-9 * max(step, 1e-300) else 1)
2466
+ except (TypeError, ValueError, ZeroDivisionError):
2467
+ pass
2468
+ for offset in offsets:
2469
+ kept = marks[offset::k]
2470
+ if all(not _boxes_overlap(roomy(a[5]), b[5]) for a, b in zip(kept, kept[1:])):
2471
+ return kept
2472
+ return marks[:1]
2473
+
2474
+
2475
+ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, bounds, layout) -> None:
2476
+ """Tick numbers and axis names just outside the three visible edges."""
2477
+ if centre is None:
2478
+ return
2479
+ muted = theme.get("muted") or "#898781"
2480
+ ink2 = theme.get("ink2") or "#c3c2b7"
2481
+ corners = _cube_corners(project, ext)
2482
+ hull = _hull_edge_set(corners)
2483
+ scales = spec.get("scales") or {}
2484
+ tick_gap = layout["tick_gap"]
2485
+ title_gap = layout["title_gap"]
2486
+ placed: list[tuple[float, float, float, float]] = [] # label boxes, all axes
2487
+ for axis in ("x", "y", "z"):
2488
+ pair = _visible_axis_edge(axis, hull, corners, centre)
2489
+ p0 = corners[pair[0]]
2490
+ p1 = corners[pair[1]]
2491
+ if p0 is None or p1 is None:
2492
+ continue
2493
+ nx, ny = _outward_normal(p0, p1, centre)
2494
+ anchor, baseline = _outside_anchor(nx, ny)
2495
+ scale = scales.get(axis) or {}
2496
+ marks = []
2497
+ for value, lab in _ticks(scale):
2498
+ try:
2499
+ u = _unit(scale, value)
2500
+ except (TypeError, ValueError):
2501
+ continue
2502
+ if u < -0.001 or u > 1.001:
2503
+ continue
2504
+ hit = project(_corner_xyz(pair[0], ext) * (1.0 - u) + _corner_xyz(pair[1], ext) * u)
2505
+ if hit is None:
2506
+ continue
2507
+ tx, ty = _clamp_point(hit[0] + nx * tick_gap, hit[1] + ny * tick_gap, bounds)
2508
+ box = _label_box(tx, ty, str(lab), fonts[0], anchor, baseline)
2509
+ marks.append((value, str(lab), hit, tx, ty, box))
2510
+ reach = max(3.0, tick_gap - 2.0)
2511
+ for value, lab, hit, tx, ty, label_box in _thin_marks(marks, 0.25 * float(fonts[0])):
2512
+ commands.append((
2513
+ "line", hit[0], hit[1], hit[0] + nx * reach, hit[1] + ny * reach,
2514
+ muted, 1, 1.0,
2515
+ ))
2516
+ # Where two axis edges meet ("3" ending x, "-3" starting y), the
2517
+ # second label would sit on the first: leave it out.
2518
+ if any(_boxes_overlap(label_box, other) for other in placed):
2519
+ continue
2520
+ placed.append(label_box)
2521
+ commands.append((
2522
+ "text", tx, ty, lab, fonts[0], muted,
2523
+ anchor, baseline, 0, 400,
2524
+ ))
2525
+ text = labs.get(axis) or ""
2526
+ if not text:
2527
+ continue
2528
+ mid = project(_corner_xyz(pair[0], ext) * 0.5 + _corner_xyz(pair[1], ext) * 0.5)
2529
+ if mid is None:
2530
+ continue
2531
+ lx, ly = _clamp_point(mid[0] + nx * title_gap, mid[1] + ny * title_gap, bounds)
2532
+ commands.append((
2533
+ "text", lx, ly, text, fonts[0], ink2, anchor, baseline, 0, 400,
2534
+ ))
2535
+
2536
+
2537
+ def _projector(ext, box, direction=None, zoom: float = 1.0):
2538
+ """The 3D viewer's camera: same direction, distance, and perspective.
2539
+
2540
+ The eye sits on the same side of the cube as the viewer (fov 60, up
2541
+ = +z), so near things are larger and parallel edges converge, as on
2542
+ screen. The cube's centre projects to the centre of ``box``, so fitting
2543
+ the panel does not slide a centred mark.
2544
+ """
2545
+ ctr = np.array([ext[0] / 2, ext[1] / 2, ext[2] / 2], dtype=np.float64)
2546
+ zoom = max(float(zoom), 1e-6)
2547
+ backward = np.array(direction if direction else [0.55, -0.85, 0.5], dtype=np.float64)
2548
+ backward /= max(float(np.linalg.norm(backward)), 1e-12)
2549
+ radius = max(float(np.linalg.norm(ext)) / 2.0, 1e-3)
2550
+ # The viewer's camera: fov 60, the same distance, the same zoom.
2551
+ dist = radius * 1.25 / math.tan(math.radians(30.0)) / zoom
2552
+ eye = ctr + backward * dist
2553
+ up = np.array([0.0, 0.0, 1.0])
2554
+ right = np.cross(up, backward)
2555
+ norm = float(np.linalg.norm(right))
2556
+ if norm < 1e-8:
2557
+ right = np.cross(np.array([0.0, 1.0, 0.0]), backward)
2558
+ norm = float(np.linalg.norm(right))
2559
+ right /= norm
2560
+ cam_up = np.cross(backward, right)
2561
+ samples = []
2562
+ for iz in (0.0, float(ext[2])):
2563
+ for iy in (0.0, float(ext[1])):
2564
+ for ix in (0.0, float(ext[0])):
2565
+ rel = np.array([ix, iy, iz], dtype=np.float64) - eye
2566
+ depth = max(-float(np.dot(rel, backward)), 1e-6)
2567
+ samples.append((float(np.dot(rel, right)) / depth, float(np.dot(rel, cam_up)) / depth))
2568
+ span = max(
2569
+ max(point[0] for point in samples) - min(point[0] for point in samples),
2570
+ max(point[1] for point in samples) - min(point[1] for point in samples),
2571
+ 1e-6,
2572
+ )
2573
+ half = span / 2.0
2574
+ limit = min(box[2], box[3]) / 2.0
2575
+
2576
+ def project(point):
2577
+ rel = np.asarray(point, dtype=np.float64) - eye
2578
+ cam_x = float(np.dot(rel, right))
2579
+ cam_y = float(np.dot(rel, cam_up))
2580
+ cam_z = float(np.dot(rel, backward))
2581
+ depth = -cam_z
2582
+ if depth < 1e-6:
2583
+ return None
2584
+ sx = box[0] + box[2] / 2.0 + (cam_x / depth / half) * limit
2585
+ sy = box[1] + box[3] / 2.0 - (cam_y / depth / half) * limit
2586
+ return sx, sy, depth
2587
+
2588
+ return project
2589
+
2590
+
2591
+ def _view_window(spec, plot_w, plot_h):
2592
+ coord = spec.get("coord") or {}
2593
+ if coord.get("expand") is False:
2594
+ # coord_cartesian(expand=False): the limits are the panel's edges.
2595
+ return (0.0, 1.0, 0.0, 1.0)
2596
+ if coord.get("aspect") != "equal":
2597
+ return (-_PAD, 1 + _PAD, -_PAD, 1 + _PAD)
2598
+ ratio = float(coord.get("ratio") or 1.0) or 1.0
2599
+ scales = spec.get("scales") or {}
2600
+ xspan = (float(scales.get("x", {}).get("hi", 1)) - float(scales.get("x", {}).get("lo", 0))) or 1.0
2601
+ yspan = (float(scales.get("y", {}).get("hi", 1)) - float(scales.get("y", {}).get("lo", 0))) or 1.0
2602
+ target = (plot_w / max(plot_h, 1.0)) * (yspan / xspan) / ratio
2603
+ need = 1 + 2 * _PAD
2604
+ nx = ny = need
2605
+ if nx / ny < target:
2606
+ nx = ny * target
2607
+ else:
2608
+ ny = nx / target
2609
+ return (0.5 - nx / 2, 0.5 + nx / 2, 0.5 - ny / 2, 0.5 + ny / 2)
2610
+
2611
+
2612
+ def _ticks(scale: dict) -> list:
2613
+ if not scale:
2614
+ return []
2615
+ if scale.get("kind") == "cat":
2616
+ return [[i, str(cat)] for i, cat in enumerate(scale.get("cats") or [])]
2617
+ ticks = scale.get("ticks")
2618
+ if ticks:
2619
+ return ticks
2620
+ ladder = scale.get("ladder")
2621
+ if ladder and ladder[0]:
2622
+ return _date_ticks(scale, ladder)
2623
+ return []
2624
+
2625
+
2626
+ def _date_ticks(scale: dict, ladder: list) -> list:
2627
+ """Pick a date ladder level the way the viewer does, sized for print.
2628
+
2629
+ Levels run coarse to fine. Take the first that shows 3 to 8 ticks;
2630
+ otherwise thin the closest level so labels do not overlap.
2631
+ """
2632
+ lo = float(scale.get("lo", -math.inf))
2633
+ hi = float(scale.get("hi", math.inf))
2634
+ levels = [[t for t in level if lo <= float(t[0]) <= hi] for level in ladder]
2635
+ for visible in levels:
2636
+ if 3 <= len(visible) <= 8:
2637
+ return visible
2638
+ visible = levels[0] if len(levels[0]) >= 3 else levels[-1]
2639
+ if len(visible) <= 8:
2640
+ return visible
2641
+ step = -(-len(visible) // 7)
2642
+ return visible[::step]
2643
+
2644
+
2645
+ def _draw_text_ann(commands, ann, text, sx, sy, surface, boxes, scale: float = 1.0) -> None:
2646
+ """geom_text / geom_label: anchored by hjust and vjust like ggplot2."""
2647
+ size = float(ann.get("size") or 14.7) * scale
2648
+ width = float(_text_width(text, size))
2649
+ height = 1.2 * size
2650
+ hj = float(ann.get("hjust", 0.5))
2651
+ vj = float(ann.get("vjust", 0.5))
2652
+ left = sx - hj * width
2653
+ top = sy - (1.0 - vj) * height
2654
+ if not ann.get("overlap", True):
2655
+ # check_overlap=True: skip a label that would cover one already drawn.
2656
+ for bl, bt, bw, bh in boxes:
2657
+ if left < bl + bw and bl < left + width and top < bt + bh and bt < top + height:
2658
+ return
2659
+ boxes.append((left, top, width, height))
2660
+ color = ann.get("color") or "#000000"
2661
+ if ann.get("style") == "label":
2662
+ pad = 0.25 * size
2663
+ commands.append((
2664
+ "rect", left - pad, top - pad * 0.6, width + 2 * pad, height + pad * 1.2,
2665
+ surface, color, 0.8, 1.0,
2666
+ ))
2667
+ # Draw from the box centre: the renderers agree on "middle" anchoring.
2668
+ commands.append((
2669
+ "text", left + width / 2.0, top + height / 2.0, text, size, color,
2670
+ "middle", "middle", 0, int(ann.get("weight") or 400),
2671
+ ))
2672
+
2673
+
2674
+ def _point_shapes(layer, blobs, gz, n: int) -> list[str]:
2675
+ node = layer.get("shape")
2676
+ if isinstance(node, str):
2677
+ return [node] * n
2678
+ if not isinstance(node, dict) or "id" not in node:
2679
+ return ["circle"] * n
2680
+ names = node.get("names") or ["circle"]
2681
+ codes = _decode(blobs[node["id"]], node.get("dtype") or "u16", gz)
2682
+ return [names[int(codes[i]) % len(names)] for i in range(n)]
2683
+
2684
+
2685
+ def _marker(commands, cx, cy, r, shape, color, alpha) -> None:
2686
+ """A point symbol of radius ``r`` (circle, triangle, square, diamond, plus, cross)."""
2687
+ if shape == "triangle":
2688
+ h = r * 1.25
2689
+ commands.append(("polygon", [(cx, cy - h), (cx + h * 0.95, cy + h * 0.6), (cx - h * 0.95, cy + h * 0.6)], color, None, 0, alpha))
2690
+ elif shape == "square":
2691
+ k = r * 0.9
2692
+ commands.append(("polygon", [(cx - k, cy - k), (cx + k, cy - k), (cx + k, cy + k), (cx - k, cy + k)], color, None, 0, alpha))
2693
+ elif shape == "diamond":
2694
+ k = r * 1.2
2695
+ commands.append(("polygon", [(cx, cy - k), (cx + k, cy), (cx, cy + k), (cx - k, cy)], color, None, 0, alpha))
2696
+ elif shape in {"plus", "cross"}:
2697
+ k = r * 1.1
2698
+ w = max(1.0, r * 0.45)
2699
+ if shape == "plus":
2700
+ segs = [((cx - k, cy), (cx + k, cy)), ((cx, cy - k), (cx, cy + k))]
2701
+ else:
2702
+ d = k * 0.75
2703
+ segs = [((cx - d, cy - d), (cx + d, cy + d)), ((cx - d, cy + d), (cx + d, cy - d))]
2704
+ for a, b in segs:
2705
+ commands.append(("line", a[0], a[1], b[0], b[1], color, w, alpha))
2706
+ else:
2707
+ commands.append(("circle", cx, cy, r, color, None, 0, alpha))
2708
+
2709
+
2710
+ def _dash_path(points, pattern, width):
2711
+ """Dashes along a polyline, continuing the pattern across vertices."""
2712
+ unit = max(float(width), 1.0)
2713
+ steps = [max(float(p), 0.5) * unit for p in pattern]
2714
+ out = []
2715
+ index, left = 0, steps[0]
2716
+ for a, b in zip(points, points[1:]):
2717
+ length = math.hypot(b[0] - a[0], b[1] - a[1])
2718
+ if length <= 0:
2719
+ continue
2720
+ dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
2721
+ at = 0.0
2722
+ while at < length:
2723
+ step = min(left, length - at)
2724
+ if index % 2 == 0:
2725
+ out.append(((a[0] + dx * at, a[1] + dy * at), (a[0] + dx * (at + step), a[1] + dy * (at + step))))
2726
+ at += step
2727
+ left -= step
2728
+ if left <= 1e-9:
2729
+ index += 1
2730
+ left = steps[index % len(steps)]
2731
+ return out
2732
+
2733
+
2734
+ def _arrow_head(tip, back, angle: float, length: float):
2735
+ """The two barb ends of an arrowhead at ``tip``, pointing away from ``back``."""
2736
+ dx, dy = tip[0] - back[0], tip[1] - back[1]
2737
+ norm = math.hypot(dx, dy)
2738
+ if norm < 1e-9:
2739
+ return None
2740
+ ux, uy = dx / norm, dy / norm
2741
+ a = math.radians(angle)
2742
+ barbs = []
2743
+ for sign in (1.0, -1.0):
2744
+ cos_a, sin_a = math.cos(a), sign * math.sin(a)
2745
+ rx = ux * cos_a - uy * sin_a
2746
+ ry = ux * sin_a + uy * cos_a
2747
+ barbs.append((tip[0] - rx * length, tip[1] - ry * length))
2748
+ return barbs
2749
+
2750
+
2751
+ def _draw_arrows(spec, commands, px) -> None:
2752
+ """arrow() heads, drawn in screen space at the ends of their lines."""
2753
+ scales = spec.get("scales") or {}
2754
+ sx, sy = scales.get("x") or {}, scales.get("y") or {}
2755
+ for item in spec.get("arrows") or []:
2756
+ tip = px(_unit(sx, item["x1"]), _unit(sy, item["y1"]))
2757
+ back = px(_unit(sx, item["x0"]), _unit(sy, item["y0"]))
2758
+ barbs = _arrow_head(tip, back, float(item.get("angle", 30.0)), float(item.get("length", 24.0)))
2759
+ if barbs is None:
2760
+ continue
2761
+ color = _hex(item.get("color") or "#000000")
2762
+ width = float(item.get("width") or 1.0)
2763
+ if item.get("type") == "closed":
2764
+ commands.append(("polygon", [barbs[0], tip, barbs[1]], color, color, width, 1.0))
2765
+ else:
2766
+ for barb in barbs:
2767
+ commands.append(("line", tip[0], tip[1], barb[0], barb[1], color, width, 1.0))
2768
+
2769
+
2770
+ def _draw_rugs(spec, commands, box, px, window) -> None:
2771
+ """geom_rug: a short tick at the panel edge for every value."""
2772
+ scales = spec.get("scales") or {}
2773
+ left, right, bottom, top = window
2774
+ for rug in spec.get("rugs") or []:
2775
+ side = rug.get("side")
2776
+ axis = "x" if side in {"b", "t"} else "y"
2777
+ scale = scales.get(axis) or {}
2778
+ reach = float(rug.get("length") or 0.03) * (box[3] if axis == "x" else box[2])
2779
+ width = float(rug.get("width") or 0.75)
2780
+ alpha = float(rug.get("alpha", 1.0))
2781
+ colors = rug.get("colors")
2782
+ base = rug.get("color") or "#000000"
2783
+ for i, value in enumerate(rug.get("values") or []):
2784
+ u = _unit(scale, value)
2785
+ color = _hex(colors[i]) if colors else base
2786
+ if axis == "x":
2787
+ if not left <= u <= right:
2788
+ continue
2789
+ sx, _sy = px(u, bottom)
2790
+ y0 = box[1] + box[3] if side == "b" else box[1]
2791
+ y1 = y0 - reach if side == "b" else y0 + reach
2792
+ commands.append(("line", sx, y0, sx, y1, color, width, alpha))
2793
+ else:
2794
+ if not bottom <= u <= top:
2795
+ continue
2796
+ _sx, sy = px(left, u)
2797
+ x0 = box[0] if side == "l" else box[0] + box[2]
2798
+ x1 = x0 + reach if side == "l" else x0 - reach
2799
+ commands.append(("line", x0, sy, x1, sy, color, width, alpha))
2800
+
2801
+
2802
+ def _draw_refs(spec, commands, window, px) -> None:
2803
+ """geom_hline / geom_vline / geom_abline, clipped to the panel."""
2804
+ scales = spec.get("scales") or {}
2805
+ sx, sy = scales.get("x") or {}, scales.get("y") or {}
2806
+ left, right, bottom, top = window
2807
+ for ref in spec.get("refs") or []:
2808
+ kind = ref.get("kind")
2809
+ if kind == "hline":
2810
+ v = _unit(sy, ref["value"])
2811
+ if not bottom <= v <= top:
2812
+ continue
2813
+ ends = [(left, v), (right, v)]
2814
+ elif kind == "vline":
2815
+ u = _unit(sx, ref["value"])
2816
+ if not left <= u <= right:
2817
+ continue
2818
+ ends = [(u, bottom), (u, top)]
2819
+ else:
2820
+ ends = _abline_ends(ref, sx, sy, window)
2821
+ if ends is None:
2822
+ continue
2823
+ a, b = px(*ends[0]), px(*ends[1])
2824
+ width = float(ref.get("width") or 1.0)
2825
+ color = ref.get("color") or "#000000"
2826
+ alpha = float(ref.get("alpha", 1.0))
2827
+ for p0, p1 in _dash_segments(a, b, ref.get("dash"), width):
2828
+ commands.append(("line", p0[0], p0[1], p1[0], p1[1], color, width, alpha))
2829
+
2830
+
2831
+ def _abline_ends(ref, sx, sy, window):
2832
+ """The visible piece of y = intercept + slope x, in unit coordinates."""
2833
+ left, right, bottom, top = window
2834
+ xlo, xhi = float(sx.get("lo", 0.0)), float(sx.get("hi", 1.0))
2835
+ ylo, yhi = float(sy.get("lo", 0.0)), float(sy.get("hi", 1.0))
2836
+ def unit_y(u):
2837
+ x = xlo + u * (xhi - xlo)
2838
+ y = float(ref["intercept"]) + float(ref["slope"]) * x
2839
+ return (y - ylo) / ((yhi - ylo) or 1.0)
2840
+ u0, u1 = left, right
2841
+ v0, v1 = unit_y(u0), unit_y(u1)
2842
+ # Clip the segment to bottom <= v <= top.
2843
+ if v0 == v1:
2844
+ return [(u0, v0), (u1, v1)] if bottom <= v0 <= top else None
2845
+ t_lo = (bottom - v0) / (v1 - v0)
2846
+ t_hi = (top - v0) / (v1 - v0)
2847
+ t0, t1 = max(0.0, min(t_lo, t_hi)), min(1.0, max(t_lo, t_hi))
2848
+ if t1 <= t0:
2849
+ return None
2850
+ return [
2851
+ (u0 + t0 * (u1 - u0), v0 + t0 * (v1 - v0)),
2852
+ (u0 + t1 * (u1 - u0), v0 + t1 * (v1 - v0)),
2853
+ ]
2854
+
2855
+
2856
+ def _dash_segments(a, b, pattern, width):
2857
+ """Split a line into dashes; the pattern is in multiples of the width."""
2858
+ if not pattern:
2859
+ return [(a, b)]
2860
+ length = math.hypot(b[0] - a[0], b[1] - a[1])
2861
+ if length <= 0:
2862
+ return []
2863
+ unit = max(float(width), 1.0)
2864
+ steps = [max(float(p), 0.5) * unit for p in pattern]
2865
+ dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
2866
+ out, at, index = [], 0.0, 0
2867
+ while at < length:
2868
+ step = steps[index % len(steps)]
2869
+ end = min(at + step, length)
2870
+ if index % 2 == 0:
2871
+ out.append(((a[0] + dx * at, a[1] + dy * at), (a[0] + dx * end, a[1] + dy * end)))
2872
+ at = end
2873
+ index += 1
2874
+ return out
2875
+
2876
+
2877
+ def _unit(scale: dict, value) -> float:
2878
+ lo = float(scale.get("lo", 0.0))
2879
+ hi = float(scale.get("hi", 1.0))
2880
+ span = hi - lo or 1.0
2881
+ return (float(value) - lo) / span
2882
+
2883
+
2884
+ def _groups(layer, n: int):
2885
+ groups = layer.get("groups")
2886
+ if not groups:
2887
+ return [(0, n)]
2888
+ out = []
2889
+ for start, count in groups:
2890
+ start = int(start)
2891
+ count = int(count)
2892
+ if start < 0 or start >= n or count <= 0:
2893
+ continue
2894
+ out.append((start, min(count, n - start)))
2895
+ return out or [(0, n)]
2896
+
2897
+
2898
+ def _decode(b64: str, dtype: str, gz: bool) -> np.ndarray:
2899
+ raw = base64.b64decode(b64)
2900
+ if gz:
2901
+ raw = gzip.decompress(raw)
2902
+ if dtype == "f32":
2903
+ return np.frombuffer(raw, dtype="<f4").astype(np.float64, copy=False)
2904
+ if dtype == "u32":
2905
+ return np.frombuffer(raw, dtype="<u4").copy()
2906
+ if dtype == "u8":
2907
+ return np.frombuffer(raw, dtype=np.uint8).copy()
2908
+ buf = np.frombuffer(raw, dtype=np.uint8)
2909
+ if gz:
2910
+ half = buf.size // 2
2911
+ delta = buf[:half].astype(np.uint32) | (buf[half:half + half].astype(np.uint32) << 8)
2912
+ return (np.cumsum(delta, dtype=np.uint32) & np.uint32(0xFFFF)).astype(np.uint16)
2913
+ return np.frombuffer(raw, dtype="<u2").copy()
2914
+
2915
+
2916
+ def _norm_channel(layer, name, blobs, gz, n: int) -> np.ndarray:
2917
+ node = layer.get(name)
2918
+ if not isinstance(node, dict) or "id" not in node:
2919
+ raise ValueError(f"ggsave() is missing the {name} channel on a {layer.get('kind')!r} layer")
2920
+ values = _decode(blobs[node["id"]], node.get("dtype") or "u16", gz)
2921
+ if values.dtype == np.float64 or str(node.get("dtype")) == "f32":
2922
+ out = np.asarray(values, dtype=np.float64)
2923
+ else:
2924
+ out = values.astype(np.float64) / 65535.0
2925
+ if out.size < n:
2926
+ raise ValueError(f"ggsave() {name} channel is shorter than the layer")
2927
+ return out[:n]
2928
+
2929
+
2930
+ def _layer_colors(layer, spec, blobs, gz, n: int):
2931
+ theme = spec.get("theme") or {}
2932
+ default = _rgb(layer.get("constColor") or (theme.get("cat") or ["#3987e5"])[0])
2933
+ node = layer.get("color")
2934
+ if not isinstance(node, dict) or "id" not in node:
2935
+ return [default] * n
2936
+ data = _decode(blobs[node["id"]], node.get("dtype") or "u16", gz)
2937
+ if node.get("kind") == "cat":
2938
+ palette = [
2939
+ _rgb(item) for item in ((spec.get("color") or {}).get("palette") or theme.get("cat") or [])
2940
+ ]
2941
+ if not palette:
2942
+ return [default] * n
2943
+ return [palette[int(data[i]) % len(palette)] for i in range(n)]
2944
+ ramp = [_rgb(item) for item in ((spec.get("color") or {}).get("ramp") or theme.get("seq") or [])]
2945
+ if data.dtype == np.float64:
2946
+ return [_ramp_at(ramp, float(data[i])) for i in range(n)]
2947
+ return [_ramp_at(ramp, float(data[i]) / 65535.0) for i in range(n)]
2948
+
2949
+
2950
+ def _outlier_colors(layer, spec, blobs, gz, n, colors):
2951
+ node = layer.get("ocolor")
2952
+ if not isinstance(node, dict) or "id" not in node:
2953
+ if layer.get("constColor"):
2954
+ return [_rgb(layer["constColor"])] * n
2955
+ return [colors[0]] * n
2956
+ fake = {"color": node, "constColor": layer.get("constColor"), "n": n}
2957
+ return _layer_colors(fake, spec, blobs, gz, n)
2958
+
2959
+
2960
+ def _point_radii(layer, blobs, gz, n, *, scene: bool, min_dim: float) -> np.ndarray:
2961
+ node = layer.get("size")
2962
+ if isinstance(node, dict) and node.get("id"):
2963
+ frac = _norm_channel(layer, "size", blobs, gz, n)
2964
+ diam = frac * float(node.get("max") or 1.0)
2965
+ elif isinstance(node, (int, float)) and not isinstance(node, bool):
2966
+ diam = np.full(n, float(node), dtype=np.float64)
2967
+ else:
2968
+ diam = np.full(n, 6.0, dtype=np.float64)
2969
+ if scene:
2970
+ diam = np.maximum(diam * float(min_dim) * 0.5, 1.5)
2971
+ return np.maximum(diam / 2.0, 0.6)
2972
+
2973
+
2974
+ def _raw_size_numbers(layer, blobs, gz, n) -> np.ndarray:
2975
+ node = layer.get("size")
2976
+ if isinstance(node, dict) and node.get("id"):
2977
+ return _norm_channel(layer, "size", blobs, gz, n) * float(node.get("max") or 1.0)
2978
+ if isinstance(node, (int, float)) and not isinstance(node, bool):
2979
+ return np.full(n, float(node), dtype=np.float64)
2980
+ return np.full(n, 2.0, dtype=np.float64)
2981
+
2982
+
2983
+ def _average(colors, idx):
2984
+ acc = np.zeros(3, dtype=np.float64)
2985
+ for i in idx:
2986
+ acc += colors[i]
2987
+ return acc / len(idx)
2988
+
2989
+
2990
+ _CSS_COLOURS = {
2991
+ "aliceblue": "f0f8ff", "antiquewhite": "faebd7", "aqua": "00ffff", "aquamarine": "7fffd4",
2992
+ "azure": "f0ffff", "beige": "f5f5dc", "bisque": "ffe4c4", "black": "000000",
2993
+ "blanchedalmond": "ffebcd", "blue": "0000ff", "blueviolet": "8a2be2", "brown": "a52a2a",
2994
+ "burlywood": "deb887", "cadetblue": "5f9ea0", "chartreuse": "7fff00", "chocolate": "d2691e",
2995
+ "coral": "ff7f50", "cornflowerblue": "6495ed", "cornsilk": "fff8dc", "crimson": "dc143c",
2996
+ "cyan": "00ffff", "darkblue": "00008b", "darkcyan": "008b8b", "darkgoldenrod": "b8860b",
2997
+ "darkgray": "a9a9a9", "darkgreen": "006400", "darkgrey": "a9a9a9", "darkkhaki": "bdb76b",
2998
+ "darkmagenta": "8b008b", "darkolivegreen": "556b2f", "darkorange": "ff8c00", "darkorchid": "9932cc",
2999
+ "darkred": "8b0000", "darksalmon": "e9967a", "darkseagreen": "8fbc8f", "darkslateblue": "483d8b",
3000
+ "darkslategray": "2f4f4f", "darkslategrey": "2f4f4f", "darkturquoise": "00ced1", "darkviolet": "9400d3",
3001
+ "deeppink": "ff1493", "deepskyblue": "00bfff", "dimgray": "696969", "dimgrey": "696969",
3002
+ "dodgerblue": "1e90ff", "firebrick": "b22222", "floralwhite": "fffaf0", "forestgreen": "228b22",
3003
+ "fuchsia": "ff00ff", "gainsboro": "dcdcdc", "ghostwhite": "f8f8ff", "gold": "ffd700",
3004
+ "goldenrod": "daa520", "gray": "808080", "green": "008000", "greenyellow": "adff2f",
3005
+ "grey": "808080", "honeydew": "f0fff0", "hotpink": "ff69b4", "indianred": "cd5c5c",
3006
+ "indigo": "4b0082", "ivory": "fffff0", "khaki": "f0e68c", "lavender": "e6e6fa",
3007
+ "lavenderblush": "fff0f5", "lawngreen": "7cfc00", "lemonchiffon": "fffacd", "lightblue": "add8e6",
3008
+ "lightcoral": "f08080", "lightcyan": "e0ffff", "lightgoldenrodyellow": "fafad2", "lightgray": "d3d3d3",
3009
+ "lightgreen": "90ee90", "lightgrey": "d3d3d3", "lightpink": "ffb6c1", "lightsalmon": "ffa07a",
3010
+ "lightseagreen": "20b2aa", "lightskyblue": "87cefa", "lightslategray": "778899", "lightslategrey": "778899",
3011
+ "lightsteelblue": "b0c4de", "lightyellow": "ffffe0", "lime": "00ff00", "limegreen": "32cd32",
3012
+ "linen": "faf0e6", "magenta": "ff00ff", "maroon": "800000", "mediumaquamarine": "66cdaa",
3013
+ "mediumblue": "0000cd", "mediumorchid": "ba55d3", "mediumpurple": "9370db", "mediumseagreen": "3cb371",
3014
+ "mediumslateblue": "7b68ee", "mediumspringgreen": "00fa9a", "mediumturquoise": "48d1cc", "mediumvioletred": "c71585",
3015
+ "midnightblue": "191970", "mintcream": "f5fffa", "mistyrose": "ffe4e1", "moccasin": "ffe4b5",
3016
+ "navajowhite": "ffdead", "navy": "000080", "oldlace": "fdf5e6", "olive": "808000",
3017
+ "olivedrab": "6b8e23", "orange": "ffa500", "orangered": "ff4500", "orchid": "da70d6",
3018
+ "palegoldenrod": "eee8aa", "palegreen": "98fb98", "paleturquoise": "afeeee", "palevioletred": "db7093",
3019
+ "papayawhip": "ffefd5", "peachpuff": "ffdab9", "peru": "cd853f", "pink": "ffc0cb",
3020
+ "plum": "dda0dd", "powderblue": "b0e0e6", "purple": "800080", "rebeccapurple": "663399",
3021
+ "red": "ff0000", "rosybrown": "bc8f8f", "royalblue": "4169e1", "saddlebrown": "8b4513",
3022
+ "salmon": "fa8072", "sandybrown": "f4a460", "seagreen": "2e8b57", "seashell": "fff5ee",
3023
+ "sienna": "a0522d", "silver": "c0c0c0", "skyblue": "87ceeb", "slateblue": "6a5acd",
3024
+ "slategray": "708090", "slategrey": "708090", "snow": "fffafa", "springgreen": "00ff7f",
3025
+ "steelblue": "4682b4", "tan": "d2b48c", "teal": "008080", "thistle": "d8bfd8",
3026
+ "tomato": "ff6347", "turquoise": "40e0d0", "violet": "ee82ee", "wheat": "f5deb3",
3027
+ "white": "ffffff", "whitesmoke": "f5f5f5", "yellow": "ffff00", "yellowgreen": "9acd32",
3028
+ }
3029
+
3030
+
3031
+ def _named_colour(text: str) -> str | None:
3032
+ """CSS names ("steelblue"), R's greys ("grey50", "gray80"), rgb(r, g, b)."""
3033
+ name = text.strip().lower().replace(" ", "")
3034
+ if name in _CSS_COLOURS:
3035
+ return _CSS_COLOURS[name]
3036
+ for prefix in ("grey", "gray"):
3037
+ if name.startswith(prefix) and name[len(prefix):].isdigit():
3038
+ level = int(round(int(name[len(prefix):]) / 100.0 * 255))
3039
+ if 0 <= level <= 255:
3040
+ return f"{level:02x}" * 3
3041
+ if name.startswith("rgb(") and name.endswith(")"):
3042
+ try:
3043
+ parts = [int(float(v)) for v in name[4:-1].split(",")[:3]]
3044
+ return "".join(f"{max(0, min(255, v)):02x}" for v in parts)
3045
+ except ValueError:
3046
+ return None
3047
+ return None
3048
+
3049
+
3050
+ def _rgb(color) -> tuple[int, int, int]:
3051
+ text = str(color or "#000000").strip()
3052
+ if not text.startswith("#"):
3053
+ text = _named_colour(text) or text
3054
+ if text.startswith("#"):
3055
+ text = text[1:]
3056
+ if len(text) == 3:
3057
+ text = "".join(ch * 2 for ch in text)
3058
+ try:
3059
+ return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
3060
+ except (ValueError, IndexError):
3061
+ return (0, 0, 0)
3062
+
3063
+
3064
+ def _hex(color) -> str:
3065
+ if isinstance(color, str):
3066
+ named = None if color.startswith("#") else _named_colour(color)
3067
+ return f"#{named}" if named else color
3068
+ return "#{:02x}{:02x}{:02x}".format(*color)
3069
+
3070
+
3071
+ def _ramp_at(ramp, t: float) -> tuple[int, int, int]:
3072
+ if not ramp:
3073
+ return (128, 128, 128)
3074
+ if len(ramp) == 1:
3075
+ return ramp[0]
3076
+ k = min(len(ramp) - 1.001, max(0.0, float(t) * (len(ramp) - 1)))
3077
+ i = int(math.floor(k))
3078
+ f = k - i
3079
+ a = ramp[i]
3080
+ b = ramp[min(i + 1, len(ramp) - 1)]
3081
+ return tuple(int(round(a[c] + (b[c] - a[c]) * f)) for c in range(3))
3082
+
3083
+
3084
+ def _scale_for(size: float) -> int:
3085
+ return max(1, int(round(float(size) / 7.0)))
3086
+
3087
+
3088
+ def _line_height(size: float) -> int:
3089
+ return 7 * _scale_for(size)
3090
+
3091
+
3092
+ def _note_line_height(size: float) -> float:
3093
+ # Real fonts need about 1.25 em; the bitmap height alone lets two
3094
+ # stacked notes touch.
3095
+ return max(_line_height(size) + 2, 1.25 * float(size))
3096
+
3097
+
3098
+ def _glyph_for(ch: str):
3099
+ if ch in _FONT:
3100
+ return _FONT[ch], "normal"
3101
+ if ch in _SUP_CHARS:
3102
+ return _FONT.get(_SUP_CHARS[ch], _FONT["?"]), "sup"
3103
+ if ch in _SUB_CHARS:
3104
+ return _FONT.get(_SUB_CHARS[ch], _FONT["?"]), "sub"
3105
+ folded = ch.translate(_FOLD)
3106
+ if folded in _FONT:
3107
+ return _FONT[folded], "normal"
3108
+ return _FONT["?"], "normal"
3109
+
3110
+
3111
+ _SUP_CHARS = dict(zip(
3112
+ "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁽⁾ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻ",
3113
+ "0123456789+-()abcdefghijklmnoprstuvwxyz",
3114
+ ))
3115
+ _SUB_CHARS = dict(zip("₀₁₂₃₄₅₆₇₈₉₊₋₍₎", "0123456789+-()"))
3116
+
3117
+
3118
+ def _pieces(text: str, scale: int):
3119
+ """Glyphs as (columns, pixel width, pixel height, kind)."""
3120
+ out = []
3121
+ for ch in text.replace("\n", " "):
3122
+ cols, kind = _glyph_for(ch)
3123
+ used = 1 if kind != "normal" else scale
3124
+ if ch == " ":
3125
+ out.append((None, 4 * used, 7 * used, "space"))
3126
+ continue
3127
+ out.append((cols, 5 * used, 7 * used, kind))
3128
+ return out
3129
+
3130
+
3131
+ # Helvetica advance widths (AFM, 1/1000 em). Arial matches them. Used when
3132
+ # the file is drawn with real fonts (SVG, PDF, Cairo PNG); the bitmap
3133
+ # fallback keeps its own wider metrics below.
3134
+ _HELVETICA = {
3135
+ " ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
3136
+ "'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
3137
+ ".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
3138
+ "?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556,
3139
+ "`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
3140
+ "A": 667, "B": 667, "C": 722, "D": 722, "E": 667, "F": 611, "G": 778,
3141
+ "H": 722, "I": 278, "J": 500, "K": 667, "L": 556, "M": 833, "N": 722,
3142
+ "O": 778, "P": 667, "Q": 778, "R": 722, "S": 667, "T": 611, "U": 722,
3143
+ "V": 667, "W": 944, "X": 667, "Y": 667, "Z": 611,
3144
+ "a": 556, "b": 556, "c": 500, "d": 556, "e": 556, "f": 278, "g": 556,
3145
+ "h": 556, "i": 222, "j": 222, "k": 500, "l": 222, "m": 833, "n": 556,
3146
+ "o": 556, "p": 556, "q": 556, "r": 333, "s": 500, "t": 278, "u": 556,
3147
+ "v": 500, "w": 722, "x": 500, "y": 500, "z": 500,
3148
+ "−": 584, "±": 584, "×": 584, "÷": 584, "·": 278, "°": 400, "…": 1000,
3149
+ "≤": 584, "≥": 584, "≠": 584, "≈": 584, "∞": 713, "∫": 274, "√": 549,
3150
+ }
3151
+ _RAISED = set("⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₒₓₕₖₗₘₙₚₛₜᵃᵇᶜᵈᵉᶠᵍʰʲᵏˡᵐᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵢⱼᵣᵤᵥ")
3152
+ _REAL_FONT = contextvars.ContextVar("plot3_real_font", default=False)
3153
+
3154
+
3155
+ def _real_width(text: str, size: float) -> int:
3156
+ units = 0
3157
+ for ch in text.replace("\n", " "):
3158
+ if ch in _HELVETICA:
3159
+ units += _HELVETICA[ch]
3160
+ elif ch in _RAISED:
3161
+ units += 380
3162
+ elif ch.isdigit():
3163
+ units += 556
3164
+ else:
3165
+ units += 600 # Greek and other symbols: a little wider than a letter
3166
+ # A few percent of headroom: the real font may be Arial or a fallback.
3167
+ return int(math.ceil(units * float(size) / 1000.0 * 1.04))
3168
+
3169
+
3170
+ def _text_width(text: str, size: float) -> int:
3171
+ if _REAL_FONT.get():
3172
+ return _real_width(text, size)
3173
+ scale = _scale_for(size)
3174
+ pieces = _pieces(text, scale)
3175
+ if not pieces:
3176
+ return 0
3177
+ gaps = scale * (len(pieces) - 1)
3178
+ return sum(item[1] for item in pieces) + gaps
3179
+
3180
+
3181
+ def _svg_text(
3182
+ commands,
3183
+ width: int,
3184
+ height: int,
3185
+ *,
3186
+ svg_width: str | None = None,
3187
+ svg_height: str | None = None,
3188
+ family: str | None = None,
3189
+ wide: str | None = None,
3190
+ ) -> str:
3191
+ """``wide`` is the font for text with scripts the main family lacks
3192
+ (CJK, ✓, emoji): one name for Cairo, which does not fall back per
3193
+ character, or a fallback list for browsers."""
3194
+ shown_w = str(width) if svg_width is None else svg_width
3195
+ shown_h = str(height) if svg_height is None else svg_height
3196
+ family_name = family or _DEFAULT_FAMILY
3197
+ parts = [
3198
+ '<?xml version="1.0" encoding="UTF-8"?>',
3199
+ (
3200
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{shown_w}" height="{shown_h}" '
3201
+ f'viewBox="0 0 {width} {height}" overflow="hidden">'
3202
+ ),
3203
+ f"<metadata>plot3 {escape(__version__)}</metadata>",
3204
+ ]
3205
+ clips = 0
3206
+ for cmd in commands:
3207
+ if cmd[0] == "clip":
3208
+ # Marks drawn until the matching "unclip" stay inside this box.
3209
+ clips += 1
3210
+ _op, x, y, w, h = cmd
3211
+ parts.append(
3212
+ f'<clipPath id="plot3clip{clips}"><rect x="{_num(x)}" y="{_num(y)}" '
3213
+ f'width="{_num(max(w, 0))}" height="{_num(max(h, 0))}"/></clipPath>'
3214
+ f'<g clip-path="url(#plot3clip{clips})">'
3215
+ )
3216
+ continue
3217
+ if cmd[0] == "unclip":
3218
+ parts.append("</g>")
3219
+ continue
3220
+ if wide and cmd[0] == "text" and _needs_wide_font(str(cmd[3])):
3221
+ parts.append(_svg_cmd(cmd, wide))
3222
+ else:
3223
+ parts.append(_svg_cmd(cmd, family_name))
3224
+ parts.append("</svg>")
3225
+ return "".join(parts)
3226
+
3227
+
3228
+ _WIDE_FALLBACKS = (
3229
+ "'Arial Unicode MS', 'Hiragino Sans', 'PingFang SC', 'Noto Sans CJK SC', "
3230
+ "'Microsoft YaHei', 'Segoe UI Symbol', 'DejaVu Sans', sans-serif"
3231
+ )
3232
+ # Scripts and symbols Helvetica / Arial do not have.
3233
+ _WIDE_RANGES = (
3234
+ (0x0590, 0x0FFF), # Hebrew, Arabic, Indic, Thai, Tibetan
3235
+ (0x1100, 0x11FF), # Hangul Jamo
3236
+ (0x2600, 0x27BF), # symbols and dingbats (✓, ★)
3237
+ (0x2E80, 0x9FFF), # CJK, kana
3238
+ (0xAC00, 0xD7AF), # Hangul
3239
+ (0xF900, 0xFAFF),
3240
+ (0xFF00, 0xFFEF), # full-width forms
3241
+ (0x1F000, 0x1FAFF), # emoji
3242
+ )
3243
+ _WIDE_CANDIDATES = {
3244
+ "darwin": [
3245
+ ("Arial Unicode MS", ["/Library/Fonts/Arial Unicode.ttf",
3246
+ "/System/Library/Fonts/Supplemental/Arial Unicode.ttf"]),
3247
+ ("Hiragino Sans", ["/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
3248
+ "/System/Library/Fonts/Hiragino Sans GB.ttc"]),
3249
+ ],
3250
+ "win32": [
3251
+ ("Microsoft YaHei", ["C:/Windows/Fonts/msyh.ttc"]),
3252
+ ("Arial Unicode MS", ["C:/Windows/Fonts/ARIALUNI.TTF"]),
3253
+ ],
3254
+ }
3255
+ _WIDE_FONT: list = []
3256
+
3257
+
3258
+ def _needs_wide_font(text: str) -> bool:
3259
+ return any(lo <= ord(ch) <= hi for ch in text for lo, hi in _WIDE_RANGES)
3260
+
3261
+
3262
+ def _wide_font() -> str | None:
3263
+ """One installed font with CJK and symbol glyphs, or None."""
3264
+ if _WIDE_FONT:
3265
+ return _WIDE_FONT[0]
3266
+ import os
3267
+ import shutil
3268
+ import subprocess
3269
+ import sys
3270
+
3271
+ found = None
3272
+ for name, paths in _WIDE_CANDIDATES.get(sys.platform, []):
3273
+ if any(os.path.exists(path) for path in paths):
3274
+ found = name
3275
+ break
3276
+ if found is None and shutil.which("fc-list"):
3277
+ try:
3278
+ listing = subprocess.run(
3279
+ ["fc-list", ":", "family"], capture_output=True, text=True, timeout=5,
3280
+ ).stdout
3281
+ except (OSError, subprocess.SubprocessError):
3282
+ listing = ""
3283
+ for name in ("Noto Sans CJK SC", "Noto Sans CJK JP", "Source Han Sans SC",
3284
+ "WenQuanYi Zen Hei", "Arial Unicode MS", "DejaVu Sans"):
3285
+ if name in listing:
3286
+ found = name
3287
+ break
3288
+ _WIDE_FONT.append(found)
3289
+ return found
3290
+
3291
+
3292
+ def _svg_cmd(cmd, family: str = _DEFAULT_FAMILY) -> str:
3293
+ op = cmd[0]
3294
+ if op == "rect":
3295
+ _op, x, y, w, h, fill, stroke, sw, alpha = cmd
3296
+ attrs = [
3297
+ f'x="{_num(x)}"', f'y="{_num(y)}"', f'width="{_num(max(w, 0))}"',
3298
+ f'height="{_num(max(h, 0))}"',
3299
+ ]
3300
+ if fill:
3301
+ attrs.append(f'fill="{fill}"')
3302
+ if alpha < 0.999:
3303
+ attrs.append(f'fill-opacity="{alpha:.3f}"')
3304
+ else:
3305
+ attrs.append('fill="none"')
3306
+ if stroke and sw:
3307
+ attrs.append(f'stroke="{stroke}"')
3308
+ attrs.append(f'stroke-width="{_num(sw)}"')
3309
+ if alpha < 0.999 and not fill:
3310
+ attrs.append(f'stroke-opacity="{alpha:.3f}"')
3311
+ return "<rect " + " ".join(attrs) + "/>"
3312
+ if op == "line":
3313
+ _op, x1, y1, x2, y2, stroke, sw, alpha = cmd
3314
+ opacity = f' stroke-opacity="{alpha:.3f}"' if alpha < 0.999 else ""
3315
+ return (
3316
+ f'<line x1="{_num(x1)}" y1="{_num(y1)}" x2="{_num(x2)}" y2="{_num(y2)}" '
3317
+ f'stroke="{stroke}" stroke-width="{_num(sw)}" stroke-linecap="round"{opacity}/>'
3318
+ )
3319
+ if op == "polymask":
3320
+ _op, tris, color, alpha = cmd
3321
+ parts = []
3322
+ for tri in tris:
3323
+ if len(tri) < 3:
3324
+ continue
3325
+ step = [f"M{_num(tri[0][0])},{_num(tri[0][1])}"]
3326
+ step.extend(f"L{_num(px)},{_num(py)}" for px, py in tri[1:])
3327
+ step.append("Z")
3328
+ parts.append("".join(step))
3329
+ if not parts:
3330
+ return ""
3331
+ opacity = f' fill-opacity="{alpha:.3f}"' if alpha < 0.999 else ""
3332
+ return f'<path d="{" ".join(parts)}" fill="{color}" fill-rule="nonzero"{opacity}/>'
3333
+ if op in {"polyline", "polygon"}:
3334
+ _op, pts, color, extra, sw, alpha = _poly_fields(cmd)
3335
+ points = " ".join(f"{_num(px)},{_num(py)}" for px, py in pts)
3336
+ if op == "polyline":
3337
+ opacity = f' stroke-opacity="{alpha:.3f}"' if alpha < 0.999 else ""
3338
+ return (
3339
+ f'<polyline points="{points}" fill="none" stroke="{color}" '
3340
+ f'stroke-width="{_num(sw)}" stroke-linejoin="round" stroke-linecap="round"{opacity}/>'
3341
+ )
3342
+ fill_op = f' fill-opacity="{alpha:.3f}"' if alpha < 0.999 else ""
3343
+ stroke = ""
3344
+ if extra and sw:
3345
+ stroke = f' stroke="{extra}" stroke-width="{_num(sw)}"'
3346
+ return f'<polygon points="{points}" fill="{color or "none"}"{fill_op}{stroke}/>'
3347
+ if op == "circle":
3348
+ _op, cx, cy, r, fill, stroke, sw, alpha = cmd
3349
+ if r <= 0:
3350
+ return ""
3351
+ opacity = f' fill-opacity="{alpha:.3f}"' if alpha < 0.999 else ""
3352
+ stroke_attr = f' stroke="{stroke}" stroke-width="{_num(sw)}"' if stroke and sw else ""
3353
+ return (
3354
+ f'<circle cx="{_num(cx)}" cy="{_num(cy)}" r="{_num(r)}" '
3355
+ f'fill="{fill or "none"}"{opacity}{stroke_attr}/>'
3356
+ )
3357
+ if op == "text":
3358
+ _op, x, y, text, size, fill, anchor, baseline, rotate, weight = cmd
3359
+ if not text:
3360
+ return ""
3361
+ dominant = {"top": "hanging", "middle": "middle", "alphabetic": "alphabetic"}[baseline]
3362
+ transform = f' transform="rotate({int(rotate)} {_num(x)} {_num(y)})"' if rotate else ""
3363
+ family_attr = f"font-family={quoteattr(family)}"
3364
+ return (
3365
+ f'<text x="{_num(x)}" y="{_num(y)}" fill="{fill}" font-size="{int(round(float(size)))}" '
3366
+ f'{family_attr} font-weight="{int(weight)}" text-anchor="{anchor}" '
3367
+ f'dominant-baseline="{dominant}"{transform}>{escape(str(text))}</text>'
3368
+ )
3369
+ return ""
3370
+
3371
+
3372
+ def _poly_fields(cmd):
3373
+ if cmd[0] == "polyline":
3374
+ return cmd[0], cmd[1], cmd[2], None, cmd[3], cmd[4]
3375
+ return cmd[0], cmd[1], cmd[2], cmd[3], cmd[4], cmd[5]
3376
+
3377
+
3378
+ def _num(value) -> str:
3379
+ text = f"{float(value):.2f}"
3380
+ if "." in text:
3381
+ text = text.rstrip("0").rstrip(".")
3382
+ return text or "0"
3383
+
3384
+
3385
+ def _raster(commands, width: int, height: int) -> np.ndarray:
3386
+ image = np.zeros((height, width, 3), dtype=np.uint8)
3387
+ saved: list = []
3388
+ for cmd in commands:
3389
+ if cmd[0] == "clip":
3390
+ saved.append((image.copy(), cmd[1:]))
3391
+ continue
3392
+ if cmd[0] == "unclip":
3393
+ if saved:
3394
+ before, (x, y, w, h) = saved.pop()
3395
+ keep = np.ones(image.shape[:2], dtype=bool)
3396
+ x0, y0 = max(0, int(math.floor(x))), max(0, int(math.floor(y)))
3397
+ x1, y1 = min(width, int(math.ceil(x + w))), min(height, int(math.ceil(y + h)))
3398
+ keep[y0:y1, x0:x1] = False
3399
+ image[keep] = before[keep]
3400
+ continue
3401
+ _paint(image, cmd)
3402
+ return image
3403
+
3404
+
3405
+ def _paint(image, cmd) -> None:
3406
+ op = cmd[0]
3407
+ if op == "rect":
3408
+ _op, x, y, w, h, fill, stroke, sw, alpha = cmd
3409
+ if fill and w > 0 and h > 0:
3410
+ _blend_rect(image, x, y, w, h, _rgb(fill), alpha)
3411
+ if stroke and sw and w > 0 and h > 0:
3412
+ color = _rgb(stroke)
3413
+ t = float(sw)
3414
+ _blend_rect(image, x, y, w, t, color, alpha)
3415
+ _blend_rect(image, x, y + h - t, w, t, color, alpha)
3416
+ _blend_rect(image, x, y, t, h, color, alpha)
3417
+ _blend_rect(image, x + w - t, y, t, h, color, alpha)
3418
+ return
3419
+ if op == "line":
3420
+ _op, x1, y1, x2, y2, stroke, sw, alpha = cmd
3421
+ _stroke_segment(image, x1, y1, x2, y2, float(sw), _rgb(stroke), alpha)
3422
+ return
3423
+ if op == "polyline":
3424
+ _op, pts, color, sw, alpha = cmd
3425
+ rgb = _rgb(color)
3426
+ for a, b in zip(pts, pts[1:]):
3427
+ _stroke_segment(image, a[0], a[1], b[0], b[1], float(sw), rgb, alpha)
3428
+ return
3429
+ if op == "polymask":
3430
+ _op, tris, fill, alpha = cmd
3431
+ _fill_polymask(image, tris, _rgb(fill), alpha)
3432
+ return
3433
+ if op == "polygon":
3434
+ _op, pts, fill, stroke, sw, alpha = cmd
3435
+ if fill and len(pts) >= 3:
3436
+ _fill_polygon(image, pts, _rgb(fill), alpha)
3437
+ if stroke and sw and len(pts) >= 2:
3438
+ rgb = _rgb(stroke)
3439
+ loop = list(pts) + [pts[0]]
3440
+ for a, b in zip(loop, loop[1:]):
3441
+ _stroke_segment(image, a[0], a[1], b[0], b[1], float(sw), rgb, alpha)
3442
+ return
3443
+ if op == "circle":
3444
+ _op, cx, cy, r, fill, stroke, sw, alpha = cmd
3445
+ if fill and r > 0:
3446
+ _fill_circle(image, cx, cy, float(r), _rgb(fill), alpha)
3447
+ if stroke and sw and r > 0:
3448
+ _stroke_circle(image, cx, cy, float(r), float(sw), _rgb(stroke), alpha)
3449
+ return
3450
+ if op == "text":
3451
+ _paint_text(image, cmd)
3452
+
3453
+
3454
+ def _blend_rect(image, x, y, w, h, color, alpha) -> None:
3455
+ height, width = image.shape[:2]
3456
+ x0 = max(0, int(math.floor(x)))
3457
+ y0 = max(0, int(math.floor(y)))
3458
+ x1 = min(width, int(math.ceil(x + w)))
3459
+ y1 = min(height, int(math.ceil(y + h)))
3460
+ if x1 <= x0 or y1 <= y0:
3461
+ return
3462
+ view = image[y0:y1, x0:x1]
3463
+ _blend_array(view, color, alpha)
3464
+
3465
+
3466
+ def _blend_array(view, color, alpha) -> None:
3467
+ if alpha >= 0.999:
3468
+ view[:] = color
3469
+ return
3470
+ paint = np.array(color, dtype=np.float32)
3471
+ mixed = view.astype(np.float32) * (1.0 - alpha) + paint * alpha + 0.5
3472
+ view[:] = np.clip(mixed, 0, 255).astype(np.uint8)
3473
+
3474
+
3475
+ def _blend_mask(image, y0, x0, mask, color, alpha) -> None:
3476
+ height, width = image.shape[:2]
3477
+ if y0 >= height or x0 >= width or y0 + mask.shape[0] <= 0 or x0 + mask.shape[1] <= 0:
3478
+ return
3479
+ sub_y = 0
3480
+ sub_x = 0
3481
+ y1 = y0 + mask.shape[0]
3482
+ x1 = x0 + mask.shape[1]
3483
+ if y0 < 0:
3484
+ sub_y = -y0
3485
+ y0 = 0
3486
+ if x0 < 0:
3487
+ sub_x = -x0
3488
+ x0 = 0
3489
+ y1 = min(height, y1)
3490
+ x1 = min(width, x1)
3491
+ mask = mask[sub_y:sub_y + (y1 - y0), sub_x:sub_x + (x1 - x0)]
3492
+ if not mask.any():
3493
+ return
3494
+ view = image[y0:y1, x0:x1]
3495
+ if alpha >= 0.999:
3496
+ view[mask] = color
3497
+ return
3498
+ paint = np.array(color, dtype=np.float32)
3499
+ pix = view[mask].astype(np.float32)
3500
+ view[mask] = np.clip(pix * (1.0 - alpha) + paint * alpha + 0.5, 0, 255).astype(np.uint8)
3501
+
3502
+
3503
+ def _fill_circle(image, cx, cy, radius, color, alpha) -> None:
3504
+ pad = radius + 1
3505
+ x0 = int(math.floor(cx - pad))
3506
+ y0 = int(math.floor(cy - pad))
3507
+ x1 = int(math.ceil(cx + pad)) + 1
3508
+ y1 = int(math.ceil(cy + pad)) + 1
3509
+ yy, xx = np.ogrid[y0:y1, x0:x1]
3510
+ mask = (xx - cx) ** 2 + (yy - cy) ** 2 <= radius ** 2
3511
+ _blend_mask(image, y0, x0, mask, color, alpha)
3512
+
3513
+
3514
+ def _stroke_circle(image, cx, cy, radius, width, color, alpha) -> None:
3515
+ outer = radius + width / 2
3516
+ inner = max(0.0, radius - width / 2)
3517
+ pad = outer + 1
3518
+ x0 = int(math.floor(cx - pad))
3519
+ y0 = int(math.floor(cy - pad))
3520
+ x1 = int(math.ceil(cx + pad)) + 1
3521
+ y1 = int(math.ceil(cy + pad)) + 1
3522
+ yy, xx = np.ogrid[y0:y1, x0:x1]
3523
+ dist2 = (xx - cx) ** 2 + (yy - cy) ** 2
3524
+ mask = (dist2 <= outer ** 2) & (dist2 >= inner ** 2)
3525
+ _blend_mask(image, y0, x0, mask, color, alpha)
3526
+
3527
+
3528
+ def _stroke_segment(image, x0, y0, x1, y1, width, color, alpha) -> None:
3529
+ radius = max(width, 0.8) / 2.0
3530
+ dx = x1 - x0
3531
+ dy = y1 - y0
3532
+ length2 = dx * dx + dy * dy
3533
+ pad = radius + 1
3534
+ minx = int(math.floor(min(x0, x1) - pad))
3535
+ miny = int(math.floor(min(y0, y1) - pad))
3536
+ maxx = int(math.ceil(max(x0, x1) + pad)) + 1
3537
+ maxy = int(math.ceil(max(y0, y1) + pad)) + 1
3538
+ if maxx <= minx or maxy <= miny:
3539
+ return
3540
+ yy, xx = np.ogrid[miny:maxy, minx:maxx]
3541
+ if length2 < 1e-8:
3542
+ mask = (xx - x0) ** 2 + (yy - y0) ** 2 <= radius ** 2
3543
+ else:
3544
+ t = np.clip(((xx - x0) * dx + (yy - y0) * dy) / length2, 0.0, 1.0)
3545
+ px = x0 + t * dx
3546
+ py = y0 + t * dy
3547
+ mask = (xx - px) ** 2 + (yy - py) ** 2 <= radius ** 2
3548
+ _blend_mask(image, miny, minx, mask, color, alpha)
3549
+
3550
+
3551
+ def _fill_polymask(image, tris, color, alpha) -> None:
3552
+ height, width = image.shape[:2]
3553
+ mask = np.zeros((height, width), dtype=bool)
3554
+ for pts in tris:
3555
+ if len(pts) >= 3:
3556
+ _fill_polygon(mask, pts, None, 1.0, mark=True)
3557
+ if not mask.any():
3558
+ return
3559
+ if alpha >= 0.999:
3560
+ image[mask] = color
3561
+ return
3562
+ paint = np.array(color, dtype=np.float32)
3563
+ pix = image[mask].astype(np.float32)
3564
+ image[mask] = np.clip(pix * (1.0 - alpha) + paint * alpha + 0.5, 0, 255).astype(np.uint8)
3565
+
3566
+
3567
+ def _fill_polygon(image, pts, color, alpha, mark=False) -> None:
3568
+ height, width = image.shape[:2]
3569
+ ys = [p[1] for p in pts]
3570
+ y0 = max(0, int(math.floor(min(ys))))
3571
+ y1 = min(height - 1, int(math.floor(max(ys))))
3572
+ count = len(pts)
3573
+ for y in range(y0, y1 + 1):
3574
+ scan = y + 0.5
3575
+ hits = []
3576
+ for i in range(count):
3577
+ ax, ay = pts[i]
3578
+ bx, by = pts[(i + 1) % count]
3579
+ if ay > by:
3580
+ ax, ay, bx, by = bx, by, ax, ay
3581
+ if ay <= scan < by:
3582
+ hits.append(ax + (scan - ay) / (by - ay) * (bx - ax))
3583
+ hits.sort()
3584
+ for i in range(0, len(hits) - 1, 2):
3585
+ if mark:
3586
+ _mark_span(image, y, hits[i], hits[i + 1])
3587
+ else:
3588
+ _blend_span(image, y, hits[i], hits[i + 1], color, alpha, width)
3589
+
3590
+
3591
+ def _mark_span(mask, y, x0, x1) -> None:
3592
+ if x1 < x0:
3593
+ x0, x1 = x1, x0
3594
+ width = mask.shape[1]
3595
+ ia = max(0, int(math.floor(x0)))
3596
+ ib = min(width, int(math.ceil(x1)))
3597
+ if ib > ia:
3598
+ mask[y, ia:ib] = True
3599
+
3600
+
3601
+ def _blend_span(image, y, x0, x1, color, alpha, width) -> None:
3602
+ if x1 < x0:
3603
+ x0, x1 = x1, x0
3604
+ ia = max(0, int(math.floor(x0)))
3605
+ ib = min(width, int(math.ceil(x1)))
3606
+ if ib <= ia:
3607
+ return
3608
+ _blend_array(image[y:y + 1, ia:ib], color, alpha)
3609
+
3610
+
3611
+ def _paint_text(image, cmd) -> None:
3612
+ _op, ax, ay, text, size, fill, anchor, baseline, rotate, weight = cmd
3613
+ if not text:
3614
+ return
3615
+ scale = _scale_for(size)
3616
+ pieces = _pieces(str(text), scale)
3617
+ if not pieces:
3618
+ return
3619
+ gaps = scale
3620
+ total = sum(item[1] for item in pieces) + gaps * (len(pieces) - 1)
3621
+ line_h = 7 * scale
3622
+ local_x = 0.0
3623
+ if anchor == "middle":
3624
+ local_x = -total / 2
3625
+ elif anchor == "end":
3626
+ local_x = -total
3627
+ local_y = 0.0
3628
+ if baseline == "middle":
3629
+ local_y = -line_h / 2
3630
+ elif baseline == "alphabetic":
3631
+ local_y = -line_h
3632
+ color = _rgb(fill)
3633
+ cursor = local_x
3634
+ for cols, gw, gh, kind in pieces:
3635
+ if kind != "space" and cols is not None:
3636
+ top = local_y
3637
+ if kind == "sub":
3638
+ top = local_y + line_h - gh
3639
+ _blit(image, ax, ay, cursor, top, cols, gw // 5, color, rotate)
3640
+ if weight >= 600:
3641
+ _blit(image, ax, ay, cursor + 1, top, cols, gw // 5, color, rotate)
3642
+ cursor += gw + gaps
3643
+
3644
+
3645
+ def _blit(image, ax, ay, local_x, local_y, cols, scale, color, rotate) -> None:
3646
+ scale = max(1, int(scale))
3647
+ for col, bits in enumerate(cols):
3648
+ if not bits:
3649
+ continue
3650
+ for row in range(7):
3651
+ if not bits & (1 << row):
3652
+ continue
3653
+ for dy in range(scale):
3654
+ for dx in range(scale):
3655
+ px = local_x + col * scale + dx
3656
+ py = local_y + row * scale + dy
3657
+ if rotate == -90:
3658
+ sx = int(round(ax + py))
3659
+ sy = int(round(ay - px))
3660
+ else:
3661
+ sx = int(round(ax + px))
3662
+ sy = int(round(ay + py))
3663
+ if 0 <= sy < image.shape[0] and 0 <= sx < image.shape[1]:
3664
+ image[sy, sx] = color
3665
+
3666
+
3667
+ def _png_chunk(tag: bytes, payload: bytes) -> bytes:
3668
+ crc = zlib.crc32(tag + payload) & 0xFFFFFFFF
3669
+ return struct.pack(">I", len(payload)) + tag + payload + struct.pack(">I", crc)
3670
+
3671
+
3672
+ def _phys_chunk(dpi: float) -> bytes:
3673
+ """PNG pHYs: pixels per metre, so a 300 dpi file is not read as 72 dpi."""
3674
+ ppm = max(1, int(round(float(dpi) / 0.0254)))
3675
+ return _png_chunk(b"pHYs", struct.pack(">IIB", ppm, ppm, 1))
3676
+
3677
+
3678
+ def _with_phys(data: bytes, dpi: float) -> bytes:
3679
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
3680
+ return data
3681
+ phys = _phys_chunk(dpi)
3682
+ pos = 8
3683
+ out = bytearray(data[:8])
3684
+ inserted = False
3685
+ while pos + 8 <= len(data):
3686
+ length = struct.unpack(">I", data[pos:pos + 4])[0]
3687
+ tag = data[pos + 4:pos + 8]
3688
+ chunk_end = pos + 12 + length
3689
+ if chunk_end > len(data):
3690
+ return data
3691
+ if tag != b"pHYs":
3692
+ out += data[pos:chunk_end]
3693
+ if tag == b"IHDR" and not inserted:
3694
+ out += phys
3695
+ inserted = True
3696
+ pos = chunk_end
3697
+ if tag == b"IEND":
3698
+ break
3699
+ return bytes(out)
3700
+
3701
+
3702
+ def _png_bytes(rgb: np.ndarray, dpi: float = _CSS_DPI) -> bytes:
3703
+ height, width = rgb.shape[:2]
3704
+ raw = b"".join(b"\x00" + rgb[y].tobytes() for y in range(height))
3705
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
3706
+ return (
3707
+ b"\x89PNG\r\n\x1a\n"
3708
+ + _png_chunk(b"IHDR", ihdr)
3709
+ + _phys_chunk(dpi)
3710
+ + _png_chunk(b"IDAT", zlib.compress(raw, 9))
3711
+ + _png_chunk(b"IEND", b"")
3712
+ )
3713
+
3714
+
3715
+ def _stamp_pdf(data: bytes, version: str) -> bytes:
3716
+ """Append an incremental Info dictionary naming the plot3 version.
3717
+
3718
+ Cairo writes its own Producer entry inside a compressed stream. An
3719
+ incremental update keeps that file valid and adds an uncompressed
3720
+ ``/Creator (plot3 <version>)`` a reader can see.
3721
+ """
3722
+ if not data.startswith(b"%PDF") or b"startxref" not in data:
3723
+ return data
3724
+ start_at = data.rfind(b"startxref")
3725
+ tail = data[start_at + len(b"startxref"):].splitlines()
3726
+ prev = None
3727
+ for line in tail:
3728
+ text = line.strip()
3729
+ if not text:
3730
+ continue
3731
+ if text == b"%%EOF":
3732
+ break
3733
+ try:
3734
+ prev = int(text)
3735
+ except ValueError:
3736
+ return data
3737
+ break
3738
+ if prev is None:
3739
+ return data
3740
+ root = re.search(br"/Root\s+\d+\s+\d+\s+R", data)
3741
+ size = re.search(br"/Size\s+(\d+)", data)
3742
+ if root is None or size is None:
3743
+ return data
3744
+ obj_num = int(size.group(1))
3745
+ if not data.endswith(b"\n"):
3746
+ data += b"\n"
3747
+ info = f"<< /Creator (plot3 {version}) /Producer (plot3 {version}) >>".encode()
3748
+ obj = f"{obj_num} 0 obj\n".encode() + info + b"\nendobj\n"
3749
+ obj_at = len(data)
3750
+ xref_at = obj_at + len(obj)
3751
+ xref = b"xref\n" + f"{obj_num} 1\n".encode() + f"{obj_at:010d} 00000 n \n".encode()
3752
+ trailer = (
3753
+ b"trailer\n<< "
3754
+ + root.group(0)
3755
+ + f" /Size {obj_num + 1} /Prev {prev} /Info {obj_num} 0 R ".encode()
3756
+ + b">>\nstartxref\n"
3757
+ + f"{xref_at}\n".encode()
3758
+ + b"%%EOF\n"
3759
+ )
3760
+ return data + obj + xref + trailer