rgrid-python 4.5.3__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.
- grid_py/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
grid_py/renderer_web.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""WebRenderer — scene-graph renderer that outputs interactive HTML.
|
|
2
|
+
|
|
3
|
+
Builds a JSON-serialisable scene graph during ``draw_*()`` calls. The
|
|
4
|
+
browser-side runtime (``gridpy.js``) reads the scene graph and renders
|
|
5
|
+
it to layered SVG + Canvas with D3.js-powered interactions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ._font_metrics import get_font_backend, FontMetricsBackend
|
|
19
|
+
from ._gpar import Gpar
|
|
20
|
+
from ._patterns import LinearGradient, RadialGradient, Pattern
|
|
21
|
+
from ._renderer_base import GridRenderer
|
|
22
|
+
from ._scene_graph import (
|
|
23
|
+
DefsCollection,
|
|
24
|
+
GrobNode,
|
|
25
|
+
SceneGraph,
|
|
26
|
+
SceneNode,
|
|
27
|
+
ViewportNode,
|
|
28
|
+
_IdGenerator,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["WebRenderer"]
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Colour / Gpar serialisation helpers
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
from ._colour import colour_to_css as _parse_colour_str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _serialise_gpar(gp: Optional[Gpar], defs: DefsCollection, id_gen: _IdGenerator) -> dict:
|
|
41
|
+
"""Convert Gpar to a JSON-safe dict. Gradient/pattern fills become def refs."""
|
|
42
|
+
if gp is None:
|
|
43
|
+
return {}
|
|
44
|
+
result: Dict[str, Any] = {}
|
|
45
|
+
for key in ("col", "fill", "lwd", "lty", "lineend", "linejoin",
|
|
46
|
+
"fontsize", "fontfamily", "fontface", "alpha"):
|
|
47
|
+
val = gp.get(key, None)
|
|
48
|
+
if val is None:
|
|
49
|
+
continue
|
|
50
|
+
# Unwrap single-element lists/arrays to scalar
|
|
51
|
+
if isinstance(val, (list, tuple, np.ndarray)) and not isinstance(val, str):
|
|
52
|
+
if len(val) == 0:
|
|
53
|
+
continue
|
|
54
|
+
if len(val) == 1:
|
|
55
|
+
val = val[0]
|
|
56
|
+
else:
|
|
57
|
+
# Multi-element: keep as list for per-element rendering
|
|
58
|
+
if key in ("col", "fill"):
|
|
59
|
+
result[key] = [_parse_colour_str(v) for v in val]
|
|
60
|
+
elif key in ("lwd", "fontsize", "alpha"):
|
|
61
|
+
result[key] = [float(v) for v in val]
|
|
62
|
+
else:
|
|
63
|
+
result[key] = [str(v) for v in val]
|
|
64
|
+
continue
|
|
65
|
+
if key in ("col", "fill"):
|
|
66
|
+
if isinstance(val, LinearGradient):
|
|
67
|
+
grad_id = _register_gradient(val, defs, id_gen)
|
|
68
|
+
result[key] = f"url(#{grad_id})"
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(val, RadialGradient):
|
|
71
|
+
grad_id = _register_gradient(val, defs, id_gen)
|
|
72
|
+
result[key] = f"url(#{grad_id})"
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(val, Pattern):
|
|
75
|
+
pat_id = _register_pattern(val, defs, id_gen)
|
|
76
|
+
result[key] = f"url(#{pat_id})"
|
|
77
|
+
continue
|
|
78
|
+
result[key] = _parse_colour_str(val)
|
|
79
|
+
elif key in ("lwd", "fontsize", "alpha"):
|
|
80
|
+
result[key] = float(val)
|
|
81
|
+
else:
|
|
82
|
+
result[key] = str(val) if not isinstance(val, (int, float)) else val
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _register_gradient(grad: Any, defs: DefsCollection, id_gen: _IdGenerator) -> str:
|
|
87
|
+
grad_id = id_gen.next("grad")
|
|
88
|
+
entry: Dict[str, Any] = {"id": grad_id}
|
|
89
|
+
if isinstance(grad, LinearGradient):
|
|
90
|
+
entry["type"] = "linear"
|
|
91
|
+
entry["x1"] = float(grad.x1.values[0])
|
|
92
|
+
entry["y1"] = float(grad.y1.values[0])
|
|
93
|
+
entry["x2"] = float(grad.x2.values[0])
|
|
94
|
+
entry["y2"] = float(grad.y2.values[0])
|
|
95
|
+
else:
|
|
96
|
+
entry["type"] = "radial"
|
|
97
|
+
entry["cx1"] = float(grad.cx1.values[0])
|
|
98
|
+
entry["cy1"] = float(grad.cy1.values[0])
|
|
99
|
+
entry["r1"] = float(grad.r1.values[0])
|
|
100
|
+
entry["cx2"] = float(grad.cx2.values[0])
|
|
101
|
+
entry["cy2"] = float(grad.cy2.values[0])
|
|
102
|
+
entry["r2"] = float(grad.r2.values[0])
|
|
103
|
+
entry["colours"] = list(grad.colours)
|
|
104
|
+
entry["stops"] = [float(s) for s in grad.stops]
|
|
105
|
+
entry["extend"] = getattr(grad, "extend", "pad")
|
|
106
|
+
defs.gradients.append(entry)
|
|
107
|
+
return grad_id
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _register_pattern(pat: Any, defs: DefsCollection, id_gen: _IdGenerator) -> str:
|
|
111
|
+
pat_id = id_gen.next("pat")
|
|
112
|
+
entry: Dict[str, Any] = {
|
|
113
|
+
"id": pat_id,
|
|
114
|
+
"width": float(pat.width.values[0]) if hasattr(pat.width, "values") else float(pat.width),
|
|
115
|
+
"height": float(pat.height.values[0]) if hasattr(pat.height, "values") else float(pat.height),
|
|
116
|
+
}
|
|
117
|
+
defs.patterns.append(entry)
|
|
118
|
+
return pat_id
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# HTML template
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
_D3_CDN_URL = "https://d3js.org/d3.v7.min.js"
|
|
126
|
+
|
|
127
|
+
_RESOURCES_DIR = os.path.join(os.path.dirname(__file__), "resources")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_resource(name: str) -> str:
|
|
131
|
+
path = os.path.join(_RESOURCES_DIR, name)
|
|
132
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
133
|
+
return f.read()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_HTML_TEMPLATE = """\
|
|
137
|
+
<!DOCTYPE html>
|
|
138
|
+
<html lang="en">
|
|
139
|
+
<head>
|
|
140
|
+
<meta charset="utf-8">
|
|
141
|
+
<style>
|
|
142
|
+
{css}
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<div id="gridpy-plot"></div>
|
|
147
|
+
{d3_block}
|
|
148
|
+
<script>
|
|
149
|
+
{runtime_js}
|
|
150
|
+
</script>
|
|
151
|
+
<script>
|
|
152
|
+
gridpy.render(
|
|
153
|
+
document.getElementById("gridpy-plot"),
|
|
154
|
+
{scene_json},
|
|
155
|
+
{{interactive: {interactive}, theme: "{theme}"}}
|
|
156
|
+
);
|
|
157
|
+
</script>
|
|
158
|
+
</body>
|
|
159
|
+
</html>
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _array_to_data_uri(image: Any) -> str:
|
|
164
|
+
"""Convert a numpy image array to a base64 PNG data URI."""
|
|
165
|
+
from PIL import Image as PILImage
|
|
166
|
+
|
|
167
|
+
img_array = np.asarray(image)
|
|
168
|
+
if img_array.dtype != np.uint8:
|
|
169
|
+
if img_array.max() <= 1.0:
|
|
170
|
+
img_array = (img_array * 255).astype(np.uint8)
|
|
171
|
+
else:
|
|
172
|
+
img_array = img_array.astype(np.uint8)
|
|
173
|
+
|
|
174
|
+
if img_array.ndim == 2:
|
|
175
|
+
pil_img = PILImage.fromarray(img_array)
|
|
176
|
+
elif img_array.shape[2] == 3:
|
|
177
|
+
pil_img = PILImage.fromarray(img_array)
|
|
178
|
+
else:
|
|
179
|
+
pil_img = PILImage.fromarray(img_array)
|
|
180
|
+
|
|
181
|
+
buf = io.BytesIO()
|
|
182
|
+
pil_img.save(buf, format="PNG")
|
|
183
|
+
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
184
|
+
return f"data:image/png;base64,{b64}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# WebRenderer
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class WebRenderer(GridRenderer):
|
|
193
|
+
"""Scene-graph renderer producing interactive HTML output.
|
|
194
|
+
|
|
195
|
+
Parameters
|
|
196
|
+
----------
|
|
197
|
+
width : float
|
|
198
|
+
Device width in inches.
|
|
199
|
+
height : float
|
|
200
|
+
Device height in inches.
|
|
201
|
+
dpi : float
|
|
202
|
+
Dots per inch (default 150).
|
|
203
|
+
default_hint : str
|
|
204
|
+
Default ``render_hint`` for grob nodes (``"auto"`` | ``"svg"`` | ``"canvas"``).
|
|
205
|
+
theme : str
|
|
206
|
+
CSS theme class (``"light"`` or ``"dark"``).
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
width: float = 7.0,
|
|
212
|
+
height: float = 5.0,
|
|
213
|
+
dpi: float = 150.0,
|
|
214
|
+
default_hint: str = "auto",
|
|
215
|
+
theme: str = "light",
|
|
216
|
+
) -> None:
|
|
217
|
+
device_w = width * dpi
|
|
218
|
+
device_h = height * dpi
|
|
219
|
+
super().__init__(width, height, dpi,
|
|
220
|
+
device_width=device_w, device_height=device_h)
|
|
221
|
+
self._default_hint = default_hint
|
|
222
|
+
self._theme = theme
|
|
223
|
+
|
|
224
|
+
# Font metrics backend
|
|
225
|
+
self._font_backend: FontMetricsBackend = get_font_backend()
|
|
226
|
+
|
|
227
|
+
# Scene graph
|
|
228
|
+
self._id_gen = _IdGenerator()
|
|
229
|
+
self._defs = DefsCollection()
|
|
230
|
+
self._scene_root = ViewportNode(
|
|
231
|
+
node_id=self._id_gen.next("vp"),
|
|
232
|
+
node_type="viewport",
|
|
233
|
+
name="ROOT",
|
|
234
|
+
transform={"x0": 0.0, "y0": 0.0, "w": device_w, "h": device_h},
|
|
235
|
+
clip=False,
|
|
236
|
+
)
|
|
237
|
+
self._node_stack: List[SceneNode] = [self._scene_root]
|
|
238
|
+
|
|
239
|
+
# Path collection buffer
|
|
240
|
+
self._path_buffer: List[SceneNode] = []
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def _current_parent(self) -> SceneNode:
|
|
244
|
+
return self._node_stack[-1]
|
|
245
|
+
|
|
246
|
+
# ===================================================================== #
|
|
247
|
+
# Viewport management (augments base class) #
|
|
248
|
+
# ===================================================================== #
|
|
249
|
+
|
|
250
|
+
def push_viewport(self, vp: Any) -> None:
|
|
251
|
+
super().push_viewport(vp)
|
|
252
|
+
x0, y0, pw, ph = self.get_viewport_bounds()
|
|
253
|
+
clip_active = bool(self._clip_stack and self._clip_stack[-1])
|
|
254
|
+
|
|
255
|
+
clip_id: Optional[str] = None
|
|
256
|
+
if clip_active:
|
|
257
|
+
clip_id = self._id_gen.next("clip")
|
|
258
|
+
self._defs.clip_paths.append({
|
|
259
|
+
"id": clip_id,
|
|
260
|
+
"x": x0, "y": y0, "w": pw, "h": ph,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
vp_node = ViewportNode(
|
|
264
|
+
node_id=self._id_gen.next("vp"),
|
|
265
|
+
node_type="viewport",
|
|
266
|
+
name=getattr(vp, "name", "") or getattr(vp, "_name", "") or "",
|
|
267
|
+
transform={"x0": x0, "y0": y0, "w": pw, "h": ph},
|
|
268
|
+
clip=clip_active,
|
|
269
|
+
clip_id=clip_id,
|
|
270
|
+
)
|
|
271
|
+
self._current_parent.children.append(vp_node)
|
|
272
|
+
self._node_stack.append(vp_node)
|
|
273
|
+
|
|
274
|
+
def pop_viewport(self) -> None:
|
|
275
|
+
super().pop_viewport()
|
|
276
|
+
if len(self._node_stack) > 1:
|
|
277
|
+
self._node_stack.pop()
|
|
278
|
+
|
|
279
|
+
# ===================================================================== #
|
|
280
|
+
# Clipping (abstract implementations) #
|
|
281
|
+
# ===================================================================== #
|
|
282
|
+
|
|
283
|
+
def _apply_clip_rect(self, x0: float, y0: float, w: float, h: float) -> None:
|
|
284
|
+
pass # Clip is recorded via ViewportNode.clip_id in push_viewport
|
|
285
|
+
|
|
286
|
+
def _restore_clip(self) -> None:
|
|
287
|
+
pass # SVG clip-path / Canvas ctx.restore handled by JS runtime
|
|
288
|
+
|
|
289
|
+
# ===================================================================== #
|
|
290
|
+
# State save/restore #
|
|
291
|
+
# ===================================================================== #
|
|
292
|
+
|
|
293
|
+
def save_state(self) -> None:
|
|
294
|
+
pass # No graphics state to save in scene graph mode
|
|
295
|
+
|
|
296
|
+
def restore_state(self) -> None:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
# ===================================================================== #
|
|
300
|
+
# Path collection #
|
|
301
|
+
# ===================================================================== #
|
|
302
|
+
|
|
303
|
+
def begin_path_collect(self, rule: str = "winding") -> None:
|
|
304
|
+
self._path_collecting = True
|
|
305
|
+
self._path_buffer = []
|
|
306
|
+
self._path_rule = rule
|
|
307
|
+
|
|
308
|
+
def end_path_stroke(self, gp: Optional[Any] = None) -> None:
|
|
309
|
+
self._path_collecting = False
|
|
310
|
+
node = GrobNode(
|
|
311
|
+
node_id=self._id_gen.next("grob"),
|
|
312
|
+
node_type="compound_stroke",
|
|
313
|
+
props={"rule": getattr(self, "_path_rule", "winding"),
|
|
314
|
+
"sub_paths": [n.to_dict() for n in self._path_buffer]},
|
|
315
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
316
|
+
render_hint="svg",
|
|
317
|
+
)
|
|
318
|
+
self._current_parent.children.append(node)
|
|
319
|
+
self._path_buffer = []
|
|
320
|
+
|
|
321
|
+
def end_path_fill(self, gp: Optional[Any] = None) -> None:
|
|
322
|
+
self._path_collecting = False
|
|
323
|
+
node = GrobNode(
|
|
324
|
+
node_id=self._id_gen.next("grob"),
|
|
325
|
+
node_type="compound_fill",
|
|
326
|
+
props={"rule": getattr(self, "_path_rule", "winding"),
|
|
327
|
+
"sub_paths": [n.to_dict() for n in self._path_buffer]},
|
|
328
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
329
|
+
render_hint="svg",
|
|
330
|
+
)
|
|
331
|
+
self._current_parent.children.append(node)
|
|
332
|
+
self._path_buffer = []
|
|
333
|
+
|
|
334
|
+
def end_path_fill_stroke(self, gp: Optional[Any] = None) -> None:
|
|
335
|
+
self._path_collecting = False
|
|
336
|
+
node = GrobNode(
|
|
337
|
+
node_id=self._id_gen.next("grob"),
|
|
338
|
+
node_type="compound_fill_stroke",
|
|
339
|
+
props={"rule": getattr(self, "_path_rule", "winding"),
|
|
340
|
+
"sub_paths": [n.to_dict() for n in self._path_buffer]},
|
|
341
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
342
|
+
render_hint="svg",
|
|
343
|
+
)
|
|
344
|
+
self._current_parent.children.append(node)
|
|
345
|
+
self._path_buffer = []
|
|
346
|
+
|
|
347
|
+
# ===================================================================== #
|
|
348
|
+
# Drawing primitives — append to scene graph #
|
|
349
|
+
# ===================================================================== #
|
|
350
|
+
|
|
351
|
+
def _append_node(self, node: SceneNode) -> None:
|
|
352
|
+
# Attach grob metadata if available (for tooltip data)
|
|
353
|
+
if self._current_grob_metadata and isinstance(node, GrobNode):
|
|
354
|
+
node.metadata = dict(self._current_grob_metadata)
|
|
355
|
+
if self._path_collecting:
|
|
356
|
+
self._path_buffer.append(node)
|
|
357
|
+
else:
|
|
358
|
+
self._current_parent.children.append(node)
|
|
359
|
+
|
|
360
|
+
# All draw_* methods receive device coordinates (pixels) from _draw.py.
|
|
361
|
+
# CairoRenderer uses top-left origin with y increasing downward —
|
|
362
|
+
# resolve_y() already applies the Y-flip, so coordinates are in
|
|
363
|
+
# the same space as SVG (top-left origin). No further transform needed.
|
|
364
|
+
|
|
365
|
+
def draw_rect(self, x: float, y: float, w: float, h: float,
|
|
366
|
+
hjust: float = 0.5, vjust: float = 0.5,
|
|
367
|
+
gp: Optional[Any] = None) -> None:
|
|
368
|
+
x0 = x - w * hjust
|
|
369
|
+
y0 = y - h * (1.0 - vjust)
|
|
370
|
+
node = GrobNode(
|
|
371
|
+
node_id=self._id_gen.next("grob"),
|
|
372
|
+
node_type="rect",
|
|
373
|
+
props={"x": x0, "y": y0, "w": w, "h": h},
|
|
374
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
375
|
+
render_hint=self._default_hint,
|
|
376
|
+
)
|
|
377
|
+
self._append_node(node)
|
|
378
|
+
|
|
379
|
+
def draw_circle(self, x: float, y: float, r: float,
|
|
380
|
+
gp: Optional[Any] = None) -> None:
|
|
381
|
+
node = GrobNode(
|
|
382
|
+
node_id=self._id_gen.next("grob"),
|
|
383
|
+
node_type="circle",
|
|
384
|
+
props={"x": x, "y": y, "r": r},
|
|
385
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
386
|
+
render_hint=self._default_hint,
|
|
387
|
+
)
|
|
388
|
+
self._append_node(node)
|
|
389
|
+
|
|
390
|
+
def draw_line(self, x: "np.ndarray", y: "np.ndarray",
|
|
391
|
+
gp: Optional[Any] = None) -> None:
|
|
392
|
+
n = max(len(x), len(y))
|
|
393
|
+
if n < 2:
|
|
394
|
+
return
|
|
395
|
+
if len(x) < n:
|
|
396
|
+
x = np.resize(x, n)
|
|
397
|
+
if len(y) < n:
|
|
398
|
+
y = np.resize(y, n)
|
|
399
|
+
node = GrobNode(
|
|
400
|
+
node_id=self._id_gen.next("grob"),
|
|
401
|
+
node_type="polyline",
|
|
402
|
+
props={"x": [float(v) for v in x], "y": [float(v) for v in y]},
|
|
403
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
404
|
+
render_hint=self._default_hint,
|
|
405
|
+
)
|
|
406
|
+
self._append_node(node)
|
|
407
|
+
|
|
408
|
+
def draw_polyline(self, x: "np.ndarray", y: "np.ndarray",
|
|
409
|
+
id_: Optional["np.ndarray"] = None,
|
|
410
|
+
gp: Optional[Any] = None) -> None:
|
|
411
|
+
if id_ is None:
|
|
412
|
+
self.draw_line(x, y, gp)
|
|
413
|
+
return
|
|
414
|
+
groups = []
|
|
415
|
+
for uid in np.unique(id_):
|
|
416
|
+
mask = id_ == uid
|
|
417
|
+
px = x[mask]
|
|
418
|
+
py = y[mask]
|
|
419
|
+
if len(px) < 2:
|
|
420
|
+
continue
|
|
421
|
+
groups.append({
|
|
422
|
+
"x": [float(v) for v in px],
|
|
423
|
+
"y": [float(v) for v in py],
|
|
424
|
+
})
|
|
425
|
+
node = GrobNode(
|
|
426
|
+
node_id=self._id_gen.next("grob"),
|
|
427
|
+
node_type="polyline",
|
|
428
|
+
props={"groups": groups},
|
|
429
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
430
|
+
render_hint=self._default_hint,
|
|
431
|
+
)
|
|
432
|
+
self._append_node(node)
|
|
433
|
+
|
|
434
|
+
def draw_segments(self, x0: "np.ndarray", y0: "np.ndarray",
|
|
435
|
+
x1: "np.ndarray", y1: "np.ndarray",
|
|
436
|
+
gp: Optional[Any] = None) -> None:
|
|
437
|
+
n = min(len(x0), len(y0), len(x1), len(y1))
|
|
438
|
+
node = GrobNode(
|
|
439
|
+
node_id=self._id_gen.next("grob"),
|
|
440
|
+
node_type="segments",
|
|
441
|
+
props={
|
|
442
|
+
"x0": [float(x0[i]) for i in range(n)],
|
|
443
|
+
"y0": [float(y0[i]) for i in range(n)],
|
|
444
|
+
"x1": [float(x1[i]) for i in range(n)],
|
|
445
|
+
"y1": [float(y1[i]) for i in range(n)],
|
|
446
|
+
},
|
|
447
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
448
|
+
render_hint=self._default_hint,
|
|
449
|
+
)
|
|
450
|
+
self._append_node(node)
|
|
451
|
+
|
|
452
|
+
def draw_polygon(self, x: "np.ndarray", y: "np.ndarray",
|
|
453
|
+
gp: Optional[Any] = None) -> None:
|
|
454
|
+
if len(x) < 3:
|
|
455
|
+
return
|
|
456
|
+
node = GrobNode(
|
|
457
|
+
node_id=self._id_gen.next("grob"),
|
|
458
|
+
node_type="polygon",
|
|
459
|
+
props={"x": [float(v) for v in x], "y": [float(v) for v in y]},
|
|
460
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
461
|
+
render_hint=self._default_hint,
|
|
462
|
+
)
|
|
463
|
+
self._append_node(node)
|
|
464
|
+
|
|
465
|
+
def draw_path(self, x: "np.ndarray", y: "np.ndarray",
|
|
466
|
+
path_id: "np.ndarray", rule: str = "winding",
|
|
467
|
+
gp: Optional[Any] = None) -> None:
|
|
468
|
+
groups = []
|
|
469
|
+
for pid in np.unique(path_id):
|
|
470
|
+
mask = path_id == pid
|
|
471
|
+
px = x[mask]
|
|
472
|
+
py = y[mask]
|
|
473
|
+
if len(px) < 2:
|
|
474
|
+
continue
|
|
475
|
+
groups.append({
|
|
476
|
+
"x": [float(v) for v in px],
|
|
477
|
+
"y": [float(v) for v in py],
|
|
478
|
+
})
|
|
479
|
+
node = GrobNode(
|
|
480
|
+
node_id=self._id_gen.next("grob"),
|
|
481
|
+
node_type="path",
|
|
482
|
+
props={"groups": groups, "rule": rule},
|
|
483
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
484
|
+
render_hint=self._default_hint,
|
|
485
|
+
)
|
|
486
|
+
self._append_node(node)
|
|
487
|
+
|
|
488
|
+
def draw_text(self, x: float, y: float, label: str,
|
|
489
|
+
rot: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
|
|
490
|
+
gp: Optional[Any] = None) -> None:
|
|
491
|
+
node = GrobNode(
|
|
492
|
+
node_id=self._id_gen.next("grob"),
|
|
493
|
+
node_type="text",
|
|
494
|
+
props={
|
|
495
|
+
"x": x, "y": y,
|
|
496
|
+
"label": str(label),
|
|
497
|
+
"rot": rot, "hjust": hjust, "vjust": vjust,
|
|
498
|
+
},
|
|
499
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
500
|
+
render_hint="svg", # Text always SVG
|
|
501
|
+
)
|
|
502
|
+
self._append_node(node)
|
|
503
|
+
|
|
504
|
+
def draw_points(self, x: "np.ndarray", y: "np.ndarray",
|
|
505
|
+
size: float = 1.0, pch: Any = 19,
|
|
506
|
+
gp: Optional[Any] = None) -> None:
|
|
507
|
+
node = GrobNode(
|
|
508
|
+
node_id=self._id_gen.next("grob"),
|
|
509
|
+
node_type="points",
|
|
510
|
+
props={
|
|
511
|
+
"x": [float(v) for v in x], "y": [float(v) for v in y],
|
|
512
|
+
"size": float(size),
|
|
513
|
+
"pch": int(pch) if not isinstance(pch, (list, tuple, np.ndarray)) else [int(p) for p in pch],
|
|
514
|
+
},
|
|
515
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
516
|
+
render_hint=self._default_hint,
|
|
517
|
+
)
|
|
518
|
+
self._append_node(node)
|
|
519
|
+
|
|
520
|
+
def draw_raster(self, image: Any, x: float, y: float,
|
|
521
|
+
w: float, h: float, interpolate: bool = True) -> None:
|
|
522
|
+
data_uri = _array_to_data_uri(image)
|
|
523
|
+
node = GrobNode(
|
|
524
|
+
node_id=self._id_gen.next("grob"),
|
|
525
|
+
node_type="raster",
|
|
526
|
+
props={
|
|
527
|
+
"x": x, "y": y,
|
|
528
|
+
"w": w, "h": h,
|
|
529
|
+
"src": data_uri,
|
|
530
|
+
"interpolate": interpolate,
|
|
531
|
+
},
|
|
532
|
+
gpar={},
|
|
533
|
+
render_hint="svg",
|
|
534
|
+
)
|
|
535
|
+
self._append_node(node)
|
|
536
|
+
|
|
537
|
+
def draw_roundrect(self, x: float, y: float, w: float, h: float,
|
|
538
|
+
r: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
|
|
539
|
+
gp: Optional[Any] = None) -> None:
|
|
540
|
+
x0 = x - w * hjust
|
|
541
|
+
y0 = y - h * (1.0 - vjust)
|
|
542
|
+
dr = min(r, w / 2, h / 2) if r > 0 else 0.0
|
|
543
|
+
node = GrobNode(
|
|
544
|
+
node_id=self._id_gen.next("grob"),
|
|
545
|
+
node_type="roundrect",
|
|
546
|
+
props={"x": x0, "y": y0, "w": w, "h": h, "r": dr},
|
|
547
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
548
|
+
render_hint=self._default_hint,
|
|
549
|
+
)
|
|
550
|
+
self._append_node(node)
|
|
551
|
+
|
|
552
|
+
def move_to(self, x: float, y: float) -> None:
|
|
553
|
+
self._pen_x = x
|
|
554
|
+
self._pen_y = y
|
|
555
|
+
|
|
556
|
+
def line_to(self, x: float, y: float,
|
|
557
|
+
gp: Optional[Any] = None) -> None:
|
|
558
|
+
x0 = getattr(self, "_pen_x", 0.0)
|
|
559
|
+
y0 = getattr(self, "_pen_y", 0.0)
|
|
560
|
+
node = GrobNode(
|
|
561
|
+
node_id=self._id_gen.next("grob"),
|
|
562
|
+
node_type="polyline",
|
|
563
|
+
props={
|
|
564
|
+
"x": [x0, x],
|
|
565
|
+
"y": [y0, y],
|
|
566
|
+
},
|
|
567
|
+
gpar=_serialise_gpar(gp, self._defs, self._id_gen),
|
|
568
|
+
render_hint=self._default_hint,
|
|
569
|
+
)
|
|
570
|
+
self._append_node(node)
|
|
571
|
+
self._pen_x = x
|
|
572
|
+
self._pen_y = y
|
|
573
|
+
|
|
574
|
+
# ===================================================================== #
|
|
575
|
+
# Clipping (explicit push/pop) #
|
|
576
|
+
# ===================================================================== #
|
|
577
|
+
|
|
578
|
+
def push_clip(self, x0: float, y0: float, x1: float, y1: float) -> None:
|
|
579
|
+
clip_id = self._id_gen.next("clip")
|
|
580
|
+
dx0 = min(x0, x1)
|
|
581
|
+
dy0 = min(y0, y1)
|
|
582
|
+
dw = abs(x1 - x0)
|
|
583
|
+
dh = abs(y1 - y0)
|
|
584
|
+
self._defs.clip_paths.append({
|
|
585
|
+
"id": clip_id, "x": dx0, "y": dy0, "w": dw, "h": dh,
|
|
586
|
+
})
|
|
587
|
+
# Wrap subsequent children in a clipped group
|
|
588
|
+
clip_group = ViewportNode(
|
|
589
|
+
node_id=self._id_gen.next("vp"),
|
|
590
|
+
node_type="viewport",
|
|
591
|
+
name="__clip__",
|
|
592
|
+
transform=self._current_parent.transform
|
|
593
|
+
if isinstance(self._current_parent, ViewportNode) else {},
|
|
594
|
+
clip=True,
|
|
595
|
+
clip_id=clip_id,
|
|
596
|
+
)
|
|
597
|
+
self._current_parent.children.append(clip_group)
|
|
598
|
+
self._node_stack.append(clip_group)
|
|
599
|
+
|
|
600
|
+
def pop_clip(self) -> None:
|
|
601
|
+
if len(self._node_stack) > 1:
|
|
602
|
+
self._node_stack.pop()
|
|
603
|
+
|
|
604
|
+
# ===================================================================== #
|
|
605
|
+
# Text metrics #
|
|
606
|
+
# ===================================================================== #
|
|
607
|
+
|
|
608
|
+
def text_extents(self, text: str, gp: Optional[Any] = None) -> Dict[str, float]:
|
|
609
|
+
return self._font_backend.measure(text, gp)
|
|
610
|
+
|
|
611
|
+
# ===================================================================== #
|
|
612
|
+
# Masking #
|
|
613
|
+
# ===================================================================== #
|
|
614
|
+
|
|
615
|
+
def render_mask(self, mask_grob: Any) -> Any:
|
|
616
|
+
mask_id = self._id_gen.next("mask")
|
|
617
|
+
sub = WebRenderer(
|
|
618
|
+
width=self.width_in, height=self.height_in,
|
|
619
|
+
dpi=self.dpi, default_hint="svg",
|
|
620
|
+
)
|
|
621
|
+
from ._draw import grid_draw
|
|
622
|
+
from ._state import get_state
|
|
623
|
+
state = get_state()
|
|
624
|
+
orig = state._renderer
|
|
625
|
+
state._renderer = sub
|
|
626
|
+
try:
|
|
627
|
+
grid_draw(mask_grob, recording=False)
|
|
628
|
+
finally:
|
|
629
|
+
state._renderer = orig
|
|
630
|
+
self._defs.masks.append({
|
|
631
|
+
"id": mask_id,
|
|
632
|
+
"content": sub._scene_root.to_dict(),
|
|
633
|
+
})
|
|
634
|
+
return mask_id
|
|
635
|
+
|
|
636
|
+
def apply_mask(self, mask_surface: Any, mask_type: str = "alpha") -> None:
|
|
637
|
+
if isinstance(mask_surface, str):
|
|
638
|
+
# mask_surface is actually a mask_id
|
|
639
|
+
if isinstance(self._current_parent, ViewportNode):
|
|
640
|
+
self._current_parent.mask_id = mask_surface
|
|
641
|
+
self._current_parent.mask_type = mask_type
|
|
642
|
+
|
|
643
|
+
# ===================================================================== #
|
|
644
|
+
# Output #
|
|
645
|
+
# ===================================================================== #
|
|
646
|
+
|
|
647
|
+
def to_scene_dict(self) -> dict:
|
|
648
|
+
"""Return the scene graph as a plain dict (JSON-serialisable)."""
|
|
649
|
+
return SceneGraph(
|
|
650
|
+
width=self.width_in * self.dpi,
|
|
651
|
+
height=self.height_in * self.dpi,
|
|
652
|
+
dpi=self.dpi,
|
|
653
|
+
root=self._scene_root,
|
|
654
|
+
defs=self._defs,
|
|
655
|
+
).to_dict()
|
|
656
|
+
|
|
657
|
+
def to_scene_json(self, indent: Optional[int] = None) -> str:
|
|
658
|
+
"""Return the scene graph as a JSON string."""
|
|
659
|
+
return json.dumps(self.to_scene_dict(), indent=indent)
|
|
660
|
+
|
|
661
|
+
def to_html(self, interactive: bool = True, cdn: bool = True,
|
|
662
|
+
inline_d3: bool = False) -> str:
|
|
663
|
+
"""Generate a self-contained HTML file.
|
|
664
|
+
|
|
665
|
+
Parameters
|
|
666
|
+
----------
|
|
667
|
+
interactive : bool
|
|
668
|
+
Enable D3 interactions (zoom, pan, tooltip).
|
|
669
|
+
cdn : bool
|
|
670
|
+
Load D3 from CDN (for ``save()``). Ignored when *inline_d3* is True.
|
|
671
|
+
inline_d3 : bool
|
|
672
|
+
Embed D3 source directly in the HTML. Use this when the output
|
|
673
|
+
must work without network access (e.g. Jupyter iframe).
|
|
674
|
+
"""
|
|
675
|
+
scene_json = self.to_scene_json()
|
|
676
|
+
css = _load_resource("gridpy.css")
|
|
677
|
+
runtime_js = _load_resource("gridpy.js")
|
|
678
|
+
if inline_d3:
|
|
679
|
+
d3_block = "<script>" + _load_resource("d3.v7.min.js") + "</script>"
|
|
680
|
+
elif cdn:
|
|
681
|
+
d3_block = f'<script src="{_D3_CDN_URL}"></script>'
|
|
682
|
+
else:
|
|
683
|
+
d3_block = ""
|
|
684
|
+
return _HTML_TEMPLATE.format(
|
|
685
|
+
css=css,
|
|
686
|
+
d3_block=d3_block,
|
|
687
|
+
runtime_js=runtime_js,
|
|
688
|
+
scene_json=scene_json,
|
|
689
|
+
interactive="true" if interactive else "false",
|
|
690
|
+
theme=self._theme,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def _repr_html_(self) -> str:
|
|
694
|
+
"""Jupyter notebook display integration.
|
|
695
|
+
|
|
696
|
+
Uses an ``<iframe srcdoc="...">`` so that the embedded ``<script>``
|
|
697
|
+
tags execute normally (``innerHTML`` injection silently drops them).
|
|
698
|
+
D3 is inlined to avoid CSP / sandbox restrictions on external scripts.
|
|
699
|
+
"""
|
|
700
|
+
full_html = self.to_html(interactive=True, inline_d3=True)
|
|
701
|
+
# Escape for embedding inside the srcdoc="..." attribute
|
|
702
|
+
escaped = full_html.replace("&", "&").replace('"', """)
|
|
703
|
+
w = int(self.width_in * self.dpi)
|
|
704
|
+
h = int(self.height_in * self.dpi)
|
|
705
|
+
return (
|
|
706
|
+
f'<iframe srcdoc="{escaped}" '
|
|
707
|
+
f'width="{w}" height="{h}" '
|
|
708
|
+
f'style="border:none;" '
|
|
709
|
+
f'sandbox="allow-scripts"></iframe>'
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def save(self, filename: str) -> None:
|
|
713
|
+
"""Save to an HTML file."""
|
|
714
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
715
|
+
f.write(self.to_html())
|
|
716
|
+
|
|
717
|
+
def new_page(self, bg: Any = "white") -> None:
|
|
718
|
+
"""Reset the scene graph for a new page."""
|
|
719
|
+
self._id_gen.reset()
|
|
720
|
+
self._defs = DefsCollection()
|
|
721
|
+
dw = self.width_in * self.dpi
|
|
722
|
+
dh = self.height_in * self.dpi
|
|
723
|
+
self._scene_root = ViewportNode(
|
|
724
|
+
node_id=self._id_gen.next("vp"),
|
|
725
|
+
node_type="viewport",
|
|
726
|
+
name="ROOT",
|
|
727
|
+
transform={"x0": 0.0, "y0": 0.0, "w": dw, "h": dh},
|
|
728
|
+
clip=False,
|
|
729
|
+
)
|
|
730
|
+
self._node_stack = [self._scene_root]
|
|
731
|
+
# Re-init base class viewport stack
|
|
732
|
+
from ._vp_calc import calc_root_transform
|
|
733
|
+
root_vtr = calc_root_transform(self.width_in * 2.54, self.height_in * 2.54)
|
|
734
|
+
self._vp_transform_stack = [root_vtr]
|
|
735
|
+
self._vp_obj_stack = [None]
|
|
736
|
+
self._layout_stack = []
|
|
737
|
+
self._layout_depth_stack = []
|
|
738
|
+
self._clip_stack = []
|
|
739
|
+
self._path_collecting = False
|
|
740
|
+
|
|
741
|
+
def finish(self) -> None:
|
|
742
|
+
pass # No resources to release
|
|
743
|
+
|
|
744
|
+
def get_surface(self) -> Any:
|
|
745
|
+
"""WebRenderer has no Cairo surface; return ``None``."""
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
def write_to_png(self, filename: str) -> None:
|
|
749
|
+
"""Not supported — WebRenderer produces HTML, not PNG.
|
|
750
|
+
|
|
751
|
+
Use :meth:`save` for HTML output.
|
|
752
|
+
"""
|
|
753
|
+
raise NotImplementedError(
|
|
754
|
+
"WebRenderer produces HTML, not PNG. Use save() or to_html()."
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
def to_png_bytes(self) -> bytes:
|
|
758
|
+
"""Not supported — WebRenderer produces HTML, not PNG.
|
|
759
|
+
|
|
760
|
+
Use :meth:`to_scene_json` or :meth:`to_html`.
|
|
761
|
+
"""
|
|
762
|
+
raise NotImplementedError(
|
|
763
|
+
"WebRenderer produces HTML, not PNG. Use to_scene_json() or to_html()."
|
|
764
|
+
)
|