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/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
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
|