plotpress 0.23.2__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.
plotpress/vega.py ADDED
@@ -0,0 +1,1324 @@
1
+ """Export a Figure as a real Vega (not Vega-Lite) JSON specification.
2
+
3
+ Vega's marks sit close to plotpress's own pixel-space primitives (a `path`
4
+ mark takes a literal SVG path string per datum, an `image` mark takes an
5
+ explicit x/y/width/height + URL) -- much closer than Vega-Lite's declarative,
6
+ scale-driven encoding wants to be. That match is what this module leans on:
7
+ most artist kinds reuse ``primitives.artist_to_prims`` (the same pixel-space
8
+ conversion ``svg.py``/``raster.py`` already share) and translate each prim
9
+ into the matching frozen-pixel Vega mark, rather than re-deriving geometry
10
+ from data + scales the way a from-scratch Vega-Lite exporter would have to.
11
+
12
+ That's a deliberate trade-off, not an oversight: a mark built this way is
13
+ visually exact but *not* reactive to Vega's own zoom/pan signals or a
14
+ runtime domain change, the way a real ``field``/``scale``-encoded mark would
15
+ be. Line/scatter/bar charts -- the common case, and the one most likely to
16
+ actually get re-scaled by something downstream -- get genuine ``field`` +
17
+ ``scale`` encoding instead, referencing real per-axes Vega ``scales``. Only
18
+ the primitive-reuse path gives up that reactivity, and it does so for marks
19
+ (filled regions, reference lines, meshes) that are as related to it -- glued
20
+ to Vega's raw drawing primitives, exactly the way plotpress's own raster
21
+ mesh path is glued to pixels -- as they would be in any other real Vega
22
+ spec that embeds a precomputed image or path.
23
+
24
+ One :class:`~plotpress.figure.Axes` becomes one Vega ``group`` mark with its
25
+ own local ``scales``/``axes``/``marks`` -- a closer structural fit for an
26
+ arbitrary subplot grid than Vega-Lite's ``hconcat``/``vconcat``/``facet``
27
+ composition, which wants homogeneous, data-driven faceting. A twin/secondary
28
+ axes is simply another group at the same pixel rect, the same way it
29
+ overlays in every other backend. That outer group is deliberately *not*
30
+ clipped -- its axis ticks/labels/title are Vega child marks drawn outside
31
+ the plot rectangle by design, the same way svg.py's own tick/label ``<g>``
32
+ sits outside its separate clip-pathed zoom ``<g>``. Only a nested inner
33
+ group, matching the outer one's size and holding just the data marks, is
34
+ clipped -- confirmed by actually rendering a spec through ``vg2png``, since
35
+ a clipped-away mark is simply absent from the output, not an error visible
36
+ from the JSON alone. :meth:`~plotpress.figure.Figure.group`'s own labeled
37
+ boxes are a different, figure-pixel-space case -- they can span several
38
+ axes at once, so each becomes a top-level ``rect``+``text`` mark pair,
39
+ sibling to the axes groups rather than nested in any one of them.
40
+
41
+ Not carried over, honestly: plotpress's own interactive toolbar (Pan/Zoom,
42
+ Point Picking, Annotate, Extract) has no Vega equivalent -- a Vega render of
43
+ this export is a static picture unless the caller wires up Vega's own
44
+ ``signals``. 3-D has no Vega grammar either, but that is moot: plotpress has
45
+ none itself (see the ``Removed`` changelog entry). An artist kind with no
46
+ mapping here yet (``BoxPlot``, ``Violin``, ``Quiver``, ``Contour``,
47
+ ``EventPlot``, ``Barbs``, ``Table``) is skipped with a ``UserWarning`` naming
48
+ it and the axes it was on, not silently dropped and not a hard failure for
49
+ the rest of the figure -- the same "degrade a part, not the whole" choice
50
+ ``pick_data()`` already makes for an oversized series. A legend (axes- or
51
+ figure-level) is the same story -- it needs real layout (``svg.py``'s
52
+ ``figure_legend_layout()``/``draw_legend()``) this module doesn't build --
53
+ and warns the same way, naming which axes or the figure has one.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import math
59
+ import warnings
60
+
61
+ import numpy as np
62
+
63
+ from .artists import (
64
+ Bars, ErrorBar, Line2D, Pie, QuadMesh, ScatterCollection, Stem, Text,
65
+ Annotation, _VECTOR_CELL_LIMIT,
66
+ )
67
+ from .colors import Normalize, to_hex
68
+ from .png import png_data_uri
69
+ from .primitives import artist_to_prims
70
+ from .primitives import ImagePrim as PImage
71
+ from .primitives import Line as PLine
72
+ from .primitives import Markers as PMarkers
73
+ from .primitives import Path as PPath
74
+ from .primitives import PolygonBatch as PPolyBatch
75
+ from .primitives import Rect as PRect
76
+ from .primitives import Segments as PSegments
77
+ from .svg import (
78
+ _axes_fraction_xy, _bbox_pad, _DASH, _effective_rect, _group_axes_extra,
79
+ _group_colorbar_extra, _group_colorbars, _pixel_rect, leader_anchor, text_box,
80
+ )
81
+ from .transform import LinearTransform
82
+
83
+ _SCHEMA = "https://vega.github.io/schema/vega/v5.json"
84
+
85
+
86
+ def figure_to_vega(fig, mesh_data: bool = False) -> dict:
87
+ """Build a Vega v5 spec (a plain ``dict`` -- ``json.dumps(...)`` it, or
88
+ hand it straight to a Vega runtime that already accepts a Python object,
89
+ e.g. IPython's ``vega`` MIME renderer) for ``fig``.
90
+
91
+ One axes becomes one Vega ``group`` mark, positioned at that axes' own
92
+ pixel rect within the figure (the same rect ``svg.py`` itself resolves,
93
+ honoring ``set_aspect``/``set_box_aspect``) -- see the module docstring
94
+ for what is and isn't drawn faithfully, and what "faithfully" means here
95
+ (frozen pixel geometry for most marks, real scale-driven encoding for
96
+ line/scatter/bar).
97
+
98
+ ``mesh_data=True`` opts a ``pcolormesh``/mesh-backed ``imshow`` into
99
+ real per-cell ``rect`` marks with a genuine field+scale color encoding,
100
+ instead of the default rasterized ``image`` mark -- reactive and
101
+ queryable, but only offered for meshes small/simple enough to stay
102
+ cheap and unambiguous (see :func:`_mesh_data_reason`); everything else
103
+ still gets the image mark, with a ``UserWarning`` naming why when
104
+ ``mesh_data=True`` was requested but couldn't be honored for that mesh.
105
+ """
106
+ dpi = fig.style.dpi
107
+ W = fig.figsize[0] * dpi
108
+ H = fig.figsize[1] * dpi
109
+ # Marker/line-width sizes are stored in points; every other backend
110
+ # converts to pixels via this same dpi/72 factor before treating them as
111
+ # pixel measurements (svg.py:1054, raster.py:427) -- to_vega() must too,
112
+ # or every marked figure exports markers ~1.4x too small at the default
113
+ # dpi=100 (worse at higher dpi).
114
+ size_scale = dpi / 72.0
115
+ groups = []
116
+ legend_axes = []
117
+ for i, ax in enumerate(fig.axes):
118
+ if ax._is_colorbar or not ax._visible:
119
+ continue
120
+ groups.append(_axes_to_group(ax, i, W, H, size_scale, fig.style, mesh_data))
121
+ if ax._show_legend:
122
+ legend_axes.append(i)
123
+ # Figure-level chrome and Figure.group()'s labeled boxes are both
124
+ # figure-pixel-space marks, siblings of the per-axes groups rather than
125
+ # nested in any one of them -- drawn in the same order svg.py's own body
126
+ # list uses (_render_figtexts, then _render_figure_legend, then
127
+ # _render_groups, all after every _render_axes() call).
128
+ groups.extend(_figtexts_to_vega_marks(fig, W, H))
129
+ if legend_axes or fig._figure_legend is not None:
130
+ # A legend needs real layout (handle geometry per artist kind,
131
+ # column wrapping, an anchor box relative to loc/bbox_to_anchor) --
132
+ # svg.py's figure_legend_layout()/draw_legend() -- that this module
133
+ # doesn't build yet. Warning, not silently dropping, matches the
134
+ # policy every other unmapped case in this file already follows.
135
+ where = (["the figure"] if fig._figure_legend is not None else []) + \
136
+ [f"axes {i}" for i in legend_axes]
137
+ warnings.warn(
138
+ f"figure_to_vega(): {', '.join(where)} has a legend, which "
139
+ "to_vega() does not export yet -- skipped.",
140
+ UserWarning, stacklevel=2,
141
+ )
142
+ groups.extend(_groups_to_vega_marks(fig, W, H))
143
+ spec = {
144
+ "$schema": _SCHEMA,
145
+ "width": round(float(W), 2),
146
+ "height": round(float(H), 2),
147
+ "padding": 0,
148
+ "background": to_hex(fig.style.facecolor),
149
+ "marks": groups,
150
+ }
151
+ return _finalize(spec)
152
+
153
+
154
+ def _vega_has_content(spec) -> bool:
155
+ """True if ``spec`` (from :func:`figure_to_vega`) has at least one real
156
+ data mark somewhere in it.
157
+
158
+ A figure built entirely from artist kinds this module has no mapping
159
+ for yet (a lone ``boxplot()``/``violinplot()``/etc. example, say) still
160
+ produces a spec -- axis chrome, a title -- with nothing behind it; the
161
+ docs build (``docs/conf.py``) uses this to skip linking to a Vega
162
+ export page for exactly those figures, per the module docstring's
163
+ "skip a part, not the whole" policy for unsupported artists.
164
+ """
165
+ for m in spec.get("marks", []):
166
+ if m.get("type") != "group":
167
+ continue # a Figure.group() box, not an axes -- never on its own
168
+ for inner in m.get("marks", []):
169
+ if inner.get("marks"):
170
+ return True
171
+ return False
172
+
173
+
174
+ def _axis_def(orient, scale_name, label, grid, custom_ticks, custom_labels, scales):
175
+ """One entry for ``_axes_to_group``'s ``axes`` list.
176
+
177
+ Also appends an ordinal label-lookup scale to ``scales`` (in place)
178
+ when custom tick *labels* are set -- Vega's axis grammar has no direct
179
+ "arbitrary string per tick position" property; mapping tick value to
180
+ label string via a small ordinal scale, referenced from
181
+ ``encode.labels``, is the standard way to express it.
182
+ """
183
+ axis = {"orient": orient, "scale": scale_name, "title": label or None,
184
+ "grid": bool(grid), "domain": True}
185
+ if custom_ticks is not None:
186
+ ticks = list(custom_ticks)
187
+ axis["values"] = [float(t) for t in ticks]
188
+ # Custom *labels* only line up against custom tick *positions* --
189
+ # resolving them against svg.py's own nice_ticks()/log_ticks()
190
+ # auto-generated positions would mean reimplementing that ticking
191
+ # algorithm here just to know which position gets which label, so
192
+ # this only fires for the (overwhelmingly common) paired case.
193
+ if custom_labels is not None:
194
+ # svg.py's own _resolve_tick_labels() truncate/pad convention:
195
+ # extra labels are dropped, missing ones render blank.
196
+ labels = list(custom_labels)[:len(ticks)]
197
+ labels += [""] * (len(ticks) - len(labels))
198
+ label_scale = f"{scale_name}_labels"
199
+ scales.append({"name": label_scale, "type": "ordinal",
200
+ "domain": [float(t) for t in ticks], "range": labels})
201
+ axis["encode"] = {"labels": {"update": {
202
+ "text": {"scale": label_scale, "field": "value"},
203
+ }}}
204
+ return axis
205
+
206
+
207
+ def _axes_to_group(ax, i, W, H, size_scale, st, mesh_data=False):
208
+ alloc = _pixel_rect(ax, W, H)
209
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
210
+ px_left, px_top, px_w, px_h = _effective_rect(ax, *alloc, (xmin, xmax), (ymin, ymax))
211
+ xlim_t = (xmax, xmin) if ax._xinverted else (xmin, xmax)
212
+ ylim_t = (ymax, ymin) if ax._yinverted else (ymin, ymax)
213
+ # Local origin (0, 0) -- artist_to_prims()' own pixel output becomes the
214
+ # group's own local marks, positioned relative to the group itself
215
+ # (encode.enter.x/y below), not the whole figure.
216
+ tr_local = LinearTransform(xlim_t, ylim_t, (0.0, 0.0, px_w, px_h),
217
+ xscale=ax._xscale, yscale=ax._yscale)
218
+ x_name, y_name = f"x{i}", f"y{i}"
219
+
220
+ px_w, px_h = max(px_w, 0.0), max(px_h, 0.0)
221
+ scales = [
222
+ # Ascending domain, direction encoded in `range` instead -- Vega
223
+ # scale domains are not guaranteed to preserve a manually descending
224
+ # order (some scale types silently re-sort), so flipping the
225
+ # *range* array is the unambiguous way to get a high-at-top y-axis
226
+ # (and a high-at-left x-axis, if inverted).
227
+ {"name": x_name, "type": "log" if ax._xscale == "log" else "linear",
228
+ "domain": [xmin, xmax],
229
+ "range": [px_w, 0] if ax._xinverted else [0, px_w], "zero": False},
230
+ {"name": y_name, "type": "log" if ax._yscale == "log" else "linear",
231
+ "domain": [ymin, ymax],
232
+ "range": [0, px_h] if ax._yinverted else [px_h, 0], "zero": False},
233
+ ]
234
+ # Vega paints marks in list order, same as SVG paints elements in
235
+ # document order -- draw by zorder (ties broken by insertion order),
236
+ # exactly svg.py's own draw_order, or an artist added later but drawn
237
+ # "underneath" (ax.fill_between(..., zorder=1) after a zorder=3 line)
238
+ # would incorrectly paint over what it's meant to sit behind. `k` stays
239
+ # each artist's ORIGINAL index (not its draw position) since primitives
240
+ # builds series_id as f"s{ai}_{k}" -- the same id convention every other
241
+ # backend uses. `scales` is built above (not after, the way it reads
242
+ # more naturally) specifically so a mesh_data=True QuadMesh can append
243
+ # its own color scale into it here, the same mutate-in-place convention
244
+ # _axis_def already uses for a custom tick-label scale.
245
+ marks = []
246
+ draw_order = sorted(enumerate(ax.artists), key=lambda ka: (ka[1].zorder, ka[0]))
247
+ for k, art in draw_order:
248
+ marks.extend(_artist_to_vega_marks(art, tr_local, i, k, x_name, y_name,
249
+ size_scale, st, mesh_data, scales, ax))
250
+ # ax.pie() calls set_axis_off() (axes.py) precisely because a pie has no
251
+ # x/y axis to show -- svg.py gates ticks/grid on this same flag
252
+ # (svg.py:1001/1005). Omitting it entirely wrapped every pie (and any
253
+ # ax.axis("off") panel) in a spurious 0..1 tick frame no to_svg() output
254
+ # ever shows.
255
+ axes_defs = [] if ax._axis_off else [
256
+ _axis_def("bottom", x_name, ax._xlabel, ax._grid, ax._xticks, ax._xticklabels, scales),
257
+ _axis_def("left", y_name, ax._ylabel, ax._grid, ax._yticks, ax._yticklabels, scales),
258
+ ]
259
+ return {
260
+ "type": "group",
261
+ "name": f"axes{i}",
262
+ # NOT clipped: axis ticks/labels/titles and this group's own title
263
+ # are Vega child marks drawn *outside* the plot rectangle by design
264
+ # (the same way svg.py's own tick/label <g> sits outside its
265
+ # separate clip-pathed zoom <g>) -- clipping this outer group cut
266
+ # all of that away, discovered by actually rendering a spec with
267
+ # every axis decoration through vg2png, not by inspecting the JSON.
268
+ # `fill` paints the axes' own background (ax.get_facecolor()) --
269
+ # a group's fill always renders under both its axes and its marks,
270
+ # matching where svg.py paints this same rect (svg.py:987-991,
271
+ # before ticks/grid/data are drawn at all). A twin/secondary axes
272
+ # occupies the EXACT SAME pixel rect as its parent (twinx()/twiny()/
273
+ # secondary_xaxis()/secondary_yaxis() all copy it verbatim) and is
274
+ # drawn AFTER its parent in fig.axes order, so its own opaque
275
+ # background would paint directly over the parent's already-drawn
276
+ # content -- svg.py skips this rect entirely for exactly that
277
+ # reason ("twins/secondaries overlay their parent, so neither draws
278
+ # one", svg.py:985-987); this group must too, or a figure's
279
+ # primary curve/bars vanish behind the twin's own blank background,
280
+ # confirmed by actually rendering a twinx() figure through vg2png.
281
+ "encode": {"enter": {
282
+ "x": {"value": round(float(px_left), 2)},
283
+ "y": {"value": round(float(px_top), 2)},
284
+ "width": {"value": round(float(px_w), 2)},
285
+ "height": {"value": round(float(px_h), 2)},
286
+ **({} if (ax._twin_of is not None or ax._secondary_of is not None)
287
+ else {"fill": {"value": _color(ax.get_facecolor(), "#ffffff")}}),
288
+ }},
289
+ "scales": scales,
290
+ "axes": axes_defs,
291
+ "title": ax._title or None,
292
+ # The data marks alone live in an inner, clipped group at the same
293
+ # local size -- clipping *just* this layer keeps a zoomed/panned or
294
+ # inverted-limit series from overflowing into a neighboring axes,
295
+ # without touching the chrome above, which resolves "x{i}"/"y{i}"
296
+ # up through this nesting exactly like a promoted __data__ source
297
+ # resolves up to the spec's top-level `data` (see _finalize).
298
+ "marks": [{
299
+ "type": "group",
300
+ "name": f"axes{i}_data",
301
+ "encode": {"enter": {
302
+ "x": {"value": 0}, "y": {"value": 0},
303
+ "width": {"value": round(float(px_w), 2)},
304
+ "height": {"value": round(float(px_h), 2)},
305
+ "clip": {"value": True},
306
+ }},
307
+ "marks": marks,
308
+ }],
309
+ }
310
+
311
+
312
+ # ---- artist -> Vega marks ---------------------------------------------
313
+
314
+ def _artist_to_vega_marks(art, tr, ai, k, x_name, y_name, size_scale, st,
315
+ mesh_data=False, scales=None, ax=None):
316
+ # Real field/scale encoding for the common, high-value cases -- these
317
+ # are the ones most worth staying reactive to a downstream domain
318
+ # change, not just visually correct at export time. A Line2D with its
319
+ # own per-vertex markers falls through to artist_to_prims() instead --
320
+ # its marker-drawing already lives there, not worth reimplementing.
321
+ if isinstance(art, ScatterCollection):
322
+ return _scatter_marks(art, x_name, y_name, size_scale)
323
+ if isinstance(art, Line2D) and art.marker is None:
324
+ return _line_marks(art, x_name, y_name)
325
+ if isinstance(art, Bars):
326
+ return _bars_marks(art, x_name, y_name)
327
+ if isinstance(art, ErrorBar):
328
+ return _errorbar_marks(art, x_name, y_name, size_scale)
329
+ if isinstance(art, Stem):
330
+ return _stem_marks(art, x_name, y_name, size_scale, st)
331
+ if isinstance(art, Pie):
332
+ return _pie_marks(art, tr)
333
+ if isinstance(art, (Text, Annotation)):
334
+ return _text_marks(art, tr, st)
335
+ if isinstance(art, QuadMesh):
336
+ reason = _mesh_data_reason(art, mesh_data, ax)
337
+ if reason is None:
338
+ return _mesh_data_marks(art, x_name, y_name, scales)
339
+ if mesh_data:
340
+ warnings.warn(
341
+ f"figure_to_vega(): axes {ai} requested mesh_data=True, but "
342
+ f"this mesh has {reason} -- falling back to a rasterized "
343
+ "image mark for it instead.",
344
+ UserWarning, stacklevel=4,
345
+ )
346
+ # fall through to the generic artist_to_prims()/image-mark path below
347
+
348
+ # Everything artist_to_prims() already shares with svg.py/raster.py --
349
+ # frozen pixel geometry, see the module docstring for why. size_scale
350
+ # (points -> pixels) matters here too: svg.py/raster.py always pass it
351
+ # (svg.py:1054, raster.py:427) so a Line2D with its own marker, a
352
+ # LineCollection, a Rug, etc. size correctly.
353
+ prims = artist_to_prims(art, tr, ai, k, size_scale=size_scale)
354
+ if prims is not None:
355
+ marks = []
356
+ for p in prims:
357
+ marks.extend(_prim_to_vega(p))
358
+ return marks
359
+
360
+ warnings.warn(
361
+ f"figure_to_vega(): axes {ai} has a {type(art).__name__} artist with "
362
+ "no Vega mapping yet (box plots, violins, quiver, contour, "
363
+ "event plots, wind barbs, and tables aren't supported) -- skipped, "
364
+ "the rest of the figure still exports.",
365
+ UserWarning, stacklevel=4,
366
+ )
367
+ return []
368
+
369
+
370
+ def _symbol_size(diameter_px):
371
+ """A Vega ``symbol`` mark's ``size`` channel is the shape's pixel *area*
372
+ (``radius = sqrt(size / pi)`` for the default circle shape), not
373
+ diameter squared -- ``diameter_px ** 2`` is the area of a *square* of
374
+ that side length, which overstates a circle's actual area by ``4/pi``
375
+ (~27%, ~13% too-large a rendered radius/diameter). This is the one
376
+ formula every symbol-sized mark in this module should go through.
377
+ """
378
+ return math.pi * (diameter_px / 2.0) ** 2
379
+
380
+
381
+ def _dash_array(linestyle):
382
+ """A Vega ``strokeDash`` array for a plotpress ``linestyle`` code, or
383
+ ``None`` for a solid line -- the same ``_DASH`` lookup svg.py's own
384
+ ``stroke-dasharray`` comes from (svg.py:37), just as a list of numbers
385
+ (Vega's own array form) instead of a comma-joined SVG attribute string.
386
+ """
387
+ dash = _DASH.get(linestyle)
388
+ return [float(n) for n in dash.split(",")] if dash else None
389
+
390
+
391
+ def _color(c, fallback="#1f77b4"):
392
+ """Resolve a color to ``#rrggbb`` -- a name/hex string (``to_hex``), or a
393
+ raw RGB(A) array/tuple (``apply_colormap``'s own output, e.g. hexbin's
394
+ per-cell facecolors) that ``to_hex`` passes through unchanged since it
395
+ only handles strings. ``if c`` alone raises on a >1-element array
396
+ (numpy's ambiguous-truth-value error), hence the explicit None check.
397
+ """
398
+ if c is None:
399
+ return fallback
400
+ if isinstance(c, str):
401
+ return to_hex(c) or fallback
402
+ c = np.asarray(c).ravel()
403
+ if c.size not in (3, 4):
404
+ return fallback
405
+ rgb = c[:3]
406
+ rgb = (rgb * 255).round() if rgb.dtype.kind == "f" and rgb.max() <= 1.0 else rgb
407
+ r, g, b = (int(v) for v in rgb)
408
+ return f"#{r:02x}{g:02x}{b:02x}"
409
+
410
+
411
+ # ---- opt-in raw per-cell mesh data (small meshes only) --------------------
412
+ #
413
+ # A QuadMesh (pcolormesh/imshow) normally exports as a single rasterized
414
+ # `image` mark (see _prim_to_vega below, and vega_lite.py's _mesh_layer) --
415
+ # a picture of the data, not the data itself: nothing downstream can read a
416
+ # cell's real value, and the colors are frozen at whatever domain/scheme
417
+ # existed at export time. `to_vega(mesh_data=True)`/`to_vega_lite(mesh_data=
418
+ # True)` opt into real per-cell `rect` marks with a genuine field+scale
419
+ # color encoding instead -- reactive, and queryable -- for meshes small
420
+ # enough that this stays cheap. Above that size (or for anything this can't
421
+ # faithfully represent as named per-cell rows: a curvilinear grid, a non-
422
+ # linear color norm, an unrecognized colormap, or a plain Image rather than
423
+ # a scalar QuadMesh) it silently isn't offered -- callers fall back to the
424
+ # rasterized path with a caveat/warning naming why, never a wrong-colored
425
+ # or misshapen mesh. `_VECTOR_CELL_LIMIT` (~2000) is the same threshold
426
+ # axes.py's own pcolormesh(rasterized=None) auto-mode already uses for
427
+ # "how many discrete cells is reasonable to draw individually" -- the exact
428
+ # same tradeoff, reused rather than re-invented.
429
+ _MESH_DATA_CELL_LIMIT = _VECTOR_CELL_LIMIT
430
+
431
+ # plotpress colormap name (lowercased, `_r` suffix stripped separately) ->
432
+ # Vega/Vega-Lite's own built-in continuous scheme name. Only colormaps
433
+ # confirmed to share an exact scheme name are listed -- silently guessing a
434
+ # "close enough" scheme for anything else risks a mesh that LOOKS like
435
+ # real data but is colored wrong, which is worse than just not offering
436
+ # this path for that colormap.
437
+ _MESH_SCHEME_MAP = {
438
+ "viridis": "viridis", "plasma": "plasma", "inferno": "inferno",
439
+ "magma": "magma", "cividis": "cividis", "turbo": "turbo",
440
+ "blues": "blues", "greens": "greens", "oranges": "oranges",
441
+ "reds": "reds", "purples": "purples", "gray": "greys", "grey": "greys",
442
+ }
443
+
444
+
445
+ def _mesh_scheme(cmap):
446
+ """``(scheme_name, reverse)`` for a plotpress colormap name/LUT, or
447
+ ``(None, False)`` if it isn't one of the small set of colormaps known to
448
+ share an exact Vega/Vega-Lite scheme name.
449
+ """
450
+ if not isinstance(cmap, str):
451
+ return None, False # a raw LUT array, not a name -- no scheme to map to
452
+ name, reverse = cmap, False
453
+ if name.endswith("_r"):
454
+ name, reverse = name[:-2], True
455
+ # Vega's built-in "greys" scheme follows ColorBrewer's convention
456
+ # (light = low, dark = high) -- the SAME direction viridis/plasma/.../
457
+ # Blues/Greens/Oranges/Reds/Purples already use, which is why the
458
+ # generic `_r`-suffix handling above is correct for all of them. But
459
+ # plotpress's own "gray" colormap follows matplotlib's literal-
460
+ # luminance convention (black = low, white = high) -- the OPPOSITE
461
+ # direction -- so "gray" needs reverse=True and "gray_r" needs
462
+ # reverse=False, backwards from every other entry in this table.
463
+ # Confirmed by sampling actual rendered pixel colors: cmap="gray_r" on
464
+ # binary data rendered value=0 cells near-black instead of white.
465
+ if name.lower() in ("gray", "grey"):
466
+ reverse = not reverse
467
+ return _MESH_SCHEME_MAP.get(name.lower()), reverse
468
+
469
+
470
+ def _mesh_data_reason(art, mesh_data, ax=None):
471
+ """Why raw per-cell data can't be used for ``art`` (a QuadMesh/Image),
472
+ or ``None`` if it can. Checked in the same order a caller would want
473
+ explained: "did I even ask for this" first, then genuine capability
474
+ gaps.
475
+ """
476
+ if not mesh_data:
477
+ return "mesh_data=False (the default)"
478
+ if not isinstance(art, QuadMesh):
479
+ return "Image (raw pixel data has no per-cell colormap to encode)"
480
+ if art.curvilinear:
481
+ return "a curvilinear (non-rectilinear) grid, which needs per-cell polygons, not axis-aligned rects"
482
+ if art.n_cells is not None and art.n_cells > _MESH_DATA_CELL_LIMIT:
483
+ return f"{art.n_cells} cells, over the {_MESH_DATA_CELL_LIMIT}-cell mesh_data limit"
484
+ if type(art.norm) is not Normalize:
485
+ return f"a {type(art.norm).__name__} color norm (only a plain linear Normalize is supported)"
486
+ if _mesh_scheme(art.cmap_name)[0] is None:
487
+ return f"colormap {art.cmap_name!r} (not in the small set of colormaps with a matching Vega scheme)"
488
+ # Unlike every other mark, a raw per-cell rect defers its x/y transform
489
+ # to the SAME shared scale every other mark on the axes uses, computed
490
+ # at render time by the Vega/Vega-Lite runtime itself -- not
491
+ # pre-clamped in pixel space via transform.py the way the rasterized
492
+ # image path (and every other backend) already is. A cell edge of
493
+ # exactly 0 (an ordinary edge-based grid starting at the origin) fed
494
+ # into a log-typed scale evaluates to NaN there, silently breaking
495
+ # that cell -- confirmed by actually running the spec through the real
496
+ # `vega` JS runtime. Excluding a log-scaled axis outright is simpler
497
+ # and safer than trying to pre-clamp per-edge the way transform.py
498
+ # does, and this path is meant to stay a narrow, unambiguous opt-in.
499
+ if (ax is not None and (ax._xscale == "log" or ax._yscale == "log")):
500
+ return "a log-scaled axis (a cell edge at or below zero would evaluate to NaN in a log scale)"
501
+ if not np.isfinite(art.C).any():
502
+ return "no finite cell values (nothing to draw)"
503
+ return None
504
+
505
+
506
+ def _mesh_cell_rows(mesh):
507
+ """One ``{x0, x1, y0, y1, value}`` row per finite cell of a rectilinear
508
+ ``QuadMesh`` -- shared by :func:`figure_to_vega` and
509
+ :func:`plotpress.vega_lite.figure_to_vega_lite`'s raw-data mesh path.
510
+ """
511
+ xe, ye = mesh.cell_edges()
512
+ ny, nx = mesh.C.shape
513
+ rows = []
514
+ for i in range(ny):
515
+ for j in range(nx):
516
+ v = mesh.C[i, j]
517
+ if not np.isfinite(v):
518
+ continue
519
+ rows.append({"x0": float(xe[j]), "x1": float(xe[j + 1]),
520
+ "y0": float(ye[i]), "y1": float(ye[i + 1]),
521
+ "value": float(v)})
522
+ return rows
523
+
524
+
525
+ def _mesh_data_marks(art, x_name, y_name, scales):
526
+ """Real per-cell ``rect`` marks with a field+scale color encoding for a
527
+ QuadMesh eligible for ``mesh_data=True`` (see :func:`_mesh_data_reason`)
528
+ -- reuses :func:`_mesh_cell_rows` for the per-cell rows and appends a
529
+ matching named-scheme color scale into ``scales`` in place, the same
530
+ mutate-in-place convention :func:`_axis_def` already uses for a custom
531
+ tick-label scale.
532
+ """
533
+ rows = _mesh_cell_rows(art)
534
+ if not rows:
535
+ return []
536
+ data_name = f"data_{id(art):x}"
537
+ scheme, reverse = _mesh_scheme(art.cmap_name)
538
+ color_scale = f"{data_name}_color"
539
+ scales.append({
540
+ "name": color_scale, "type": "linear",
541
+ "domain": [float(art.norm.vmin), float(art.norm.vmax)],
542
+ "range": {"scheme": scheme}, "reverse": reverse, "zero": False,
543
+ })
544
+ mark = {
545
+ "type": "rect",
546
+ "from": {"data": data_name},
547
+ "encode": {"enter": {
548
+ "x": {"scale": x_name, "field": "x0"}, "x2": {"scale": x_name, "field": "x1"},
549
+ "y": {"scale": y_name, "field": "y0"}, "y2": {"scale": y_name, "field": "y1"},
550
+ "fill": {"scale": color_scale, "field": "value"},
551
+ "fillOpacity": {"value": float(art.alpha)},
552
+ }},
553
+ }
554
+ return [{"__data__": (data_name, rows)}, mark]
555
+
556
+
557
+ def _line_marks(art, x_name, y_name):
558
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
559
+ finite = np.isfinite(x) & np.isfinite(y)
560
+ if not finite.any() or art.linestyle == "none":
561
+ return []
562
+ data_name = f"data_{id(art):x}"
563
+ # A non-finite point must not be dropped outright -- that would bridge
564
+ # the gap with a spurious straight line across missing data (the
565
+ # standard "y[50:60] = np.nan" idiom). Keep every point and let Vega's
566
+ # own `defined` encoding channel break the line there instead of
567
+ # connecting through it (Vega/D3 line semantics: false in `defined`
568
+ # splits the path into separate segments) -- the same outcome as
569
+ # svg.py's _line_path_d, which explicitly emits separate M...L...
570
+ # subpaths on the same non-finite points. The x/y placeholder for an
571
+ # undefined point is never rendered, so any finite number is fine.
572
+ values = [
573
+ {"x": float(xv) if fv else 0.0, "y": float(yv) if fv else 0.0, "valid": bool(fv)}
574
+ for xv, yv, fv in zip(x, y, finite)
575
+ ]
576
+ dash = _dash_array(art.linestyle)
577
+ enter = {
578
+ "x": {"scale": x_name, "field": "x"},
579
+ "y": {"scale": y_name, "field": "y"},
580
+ "defined": {"field": "valid"},
581
+ "stroke": {"value": _color(art.color)},
582
+ "strokeWidth": {"value": float(art.linewidth)},
583
+ "strokeOpacity": {"value": float(art.alpha)},
584
+ "interpolate": {"value": "linear"},
585
+ }
586
+ if dash:
587
+ enter["strokeDash"] = {"value": dash}
588
+ mark = {
589
+ "type": "line",
590
+ "from": {"data": data_name},
591
+ "encode": {"enter": enter},
592
+ }
593
+ return [{"__data__": (data_name, values)}, mark]
594
+
595
+
596
+ def _scatter_marks(art, x_name, y_name, size_scale):
597
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
598
+ finite = np.isfinite(x) & np.isfinite(y)
599
+ if not finite.any():
600
+ return []
601
+ data_name = f"data_{id(art):x}"
602
+ fc = art.face_colors()
603
+ colors = fc if fc is not None else [_color(art.color)] * x.size
604
+ # art.s is a diameter in points (matching every other backend's
605
+ # convention) -- size_scale converts to pixels before _symbol_size
606
+ # converts that pixel diameter to the area Vega's size channel wants.
607
+ s = np.broadcast_to(np.asarray(art.s, float), x.shape) * size_scale
608
+ values = [
609
+ {"x": float(xv), "y": float(yv), "size": _symbol_size(sv), "color": cv}
610
+ for xv, yv, sv, cv in zip(x[finite], y[finite], s[finite],
611
+ np.asarray(colors, dtype=object)[finite])
612
+ ]
613
+ enter = {
614
+ "x": {"scale": x_name, "field": "x"},
615
+ "y": {"scale": y_name, "field": "y"},
616
+ "size": {"field": "size"},
617
+ "fill": {"field": "color"},
618
+ "fillOpacity": {"value": float(art.alpha)},
619
+ }
620
+ # primitives.py's own ScatterCollection -> Markers conversion (used by
621
+ # every other backend) only sets an outline when linewidths is truthy --
622
+ # mirrored here since _scatter_marks bypasses that shared path entirely.
623
+ if art.linewidths:
624
+ enter["stroke"] = {"value": _color(art.edgecolor)}
625
+ enter["strokeWidth"] = {"value": float(art.linewidths) * size_scale}
626
+ mark = {
627
+ "type": "symbol",
628
+ "from": {"data": data_name},
629
+ "encode": {"enter": enter},
630
+ }
631
+ return [{"__data__": (data_name, values)}, mark]
632
+
633
+
634
+ def _bars_marks(art, x_name, y_name):
635
+ if art.pos.size == 0:
636
+ return []
637
+ data_name = f"data_{id(art):x}"
638
+ # Bars.__init__ always normalizes color to a per-item list via
639
+ # _as_colors(), regardless of what was passed in -- no fallback needed.
640
+ colors = art.colors
641
+ vals = []
642
+ for pos, length, thick, base, color in zip(art.pos, art.length, art.thickness,
643
+ art.base, colors):
644
+ if art.orientation == "vertical":
645
+ vals.append({"x0": pos - thick / 2, "x1": pos + thick / 2,
646
+ "y0": base, "y1": base + length, "color": _color(color)})
647
+ else:
648
+ vals.append({"x0": base, "x1": base + length,
649
+ "y0": pos - thick / 2, "y1": pos + thick / 2, "color": _color(color)})
650
+ enter = {
651
+ "x": {"scale": x_name, "field": "x0"}, "x2": {"scale": x_name, "field": "x1"},
652
+ "y": {"scale": y_name, "field": "y0"}, "y2": {"scale": y_name, "field": "y1"},
653
+ "fill": {"field": "color"}, "fillOpacity": {"value": float(art.alpha)},
654
+ }
655
+ # svg.py only draws a bar edge at all when edgecolor is set (svg.py:1493)
656
+ # -- no edge, not a zero-width one, is the "unset" state to match.
657
+ if art.edgecolor:
658
+ enter["stroke"] = {"value": _color(art.edgecolor)}
659
+ enter["strokeWidth"] = {"value": float(art.linewidth)}
660
+ mark = {
661
+ "type": "rect",
662
+ "from": {"data": data_name},
663
+ "encode": {"enter": enter},
664
+ }
665
+ return [{"__data__": (data_name, vals)}, mark]
666
+
667
+
668
+ def _errorbar_marks(art, x_name, y_name, size_scale):
669
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
670
+ finite = np.isfinite(x) & np.isfinite(y)
671
+ if not finite.any():
672
+ return []
673
+ data_name = f"data_{id(art):x}"
674
+ marks = []
675
+ # svg.py's cap length (art.capsize) is used as-is, in raw pixels, with
676
+ # no size_scale applied -- matched here rather than "corrected", since
677
+ # the goal is fidelity to what to_svg() actually draws, not a separate
678
+ # opinion about what capsize should mean.
679
+ cap = float(art.capsize)
680
+ if art.yerr is not None:
681
+ yerr = np.asarray(art.yerr, float)
682
+ whiskers = [{"x": float(xv), "y0": float(yv - e), "y1": float(yv + e)}
683
+ for xv, yv, e, fv in zip(x, y, yerr, finite) if fv]
684
+ wdata = f"{data_name}_yerr"
685
+ marks.append({"__data__": (wdata, whiskers)})
686
+ marks.append({
687
+ "type": "rule", "from": {"data": wdata},
688
+ "encode": {"enter": {
689
+ "x": {"scale": x_name, "field": "x"}, "x2": {"scale": x_name, "field": "x"},
690
+ "y": {"scale": y_name, "field": "y0"}, "y2": {"scale": y_name, "field": "y1"},
691
+ "stroke": {"value": _color(art.ecolor)},
692
+ "strokeWidth": {"value": float(art.elinewidth)},
693
+ }},
694
+ })
695
+ # One row per whisker END (not per whisker) -- a cap is a short
696
+ # perpendicular tick at each tip, svg.py:1555-1556.
697
+ caps = ([{"x": w["x"], "y": w["y0"]} for w in whiskers]
698
+ + [{"x": w["x"], "y": w["y1"]} for w in whiskers])
699
+ cdata = f"{data_name}_ycap"
700
+ marks.append({"__data__": (cdata, caps)})
701
+ marks.append({
702
+ "type": "rule", "from": {"data": cdata},
703
+ "encode": {"enter": {
704
+ "y": {"scale": y_name, "field": "y"}, "y2": {"scale": y_name, "field": "y"},
705
+ "x": {"scale": x_name, "field": "x", "offset": -cap},
706
+ "x2": {"scale": x_name, "field": "x", "offset": cap},
707
+ "stroke": {"value": _color(art.ecolor)},
708
+ "strokeWidth": {"value": float(art.capthick)},
709
+ }},
710
+ })
711
+ if art.xerr is not None:
712
+ xerr = np.asarray(art.xerr, float)
713
+ whiskers = [{"y": float(yv), "x0": float(xv - e), "x1": float(xv + e)}
714
+ for xv, yv, e, fv in zip(x, y, xerr, finite) if fv]
715
+ wdata = f"{data_name}_xerr"
716
+ marks.append({"__data__": (wdata, whiskers)})
717
+ marks.append({
718
+ "type": "rule", "from": {"data": wdata},
719
+ "encode": {"enter": {
720
+ "y": {"scale": y_name, "field": "y"}, "y2": {"scale": y_name, "field": "y"},
721
+ "x": {"scale": x_name, "field": "x0"}, "x2": {"scale": x_name, "field": "x1"},
722
+ "stroke": {"value": _color(art.ecolor)},
723
+ "strokeWidth": {"value": float(art.elinewidth)},
724
+ }},
725
+ })
726
+ caps = ([{"y": w["y"], "x": w["x0"]} for w in whiskers]
727
+ + [{"y": w["y"], "x": w["x1"]} for w in whiskers])
728
+ cdata = f"{data_name}_xcap"
729
+ marks.append({"__data__": (cdata, caps)})
730
+ marks.append({
731
+ "type": "rule", "from": {"data": cdata},
732
+ "encode": {"enter": {
733
+ "x": {"scale": x_name, "field": "x"}, "x2": {"scale": x_name, "field": "x"},
734
+ "y": {"scale": y_name, "field": "y", "offset": -cap},
735
+ "y2": {"scale": y_name, "field": "y", "offset": cap},
736
+ "stroke": {"value": _color(art.ecolor)},
737
+ "strokeWidth": {"value": float(art.capthick)},
738
+ }},
739
+ })
740
+ # The line connecting the points -- svg.py:1540-1546 draws it (through
741
+ # _line_path_d, the same non-finite-splitting helper _line_marks
742
+ # mirrors above) whenever linestyle isn't None/"none"; today's export
743
+ # dropped it unconditionally.
744
+ if art.linestyle and art.linestyle != "none":
745
+ lvals = [{"x": float(xv) if fv else 0.0, "y": float(yv) if fv else 0.0, "valid": bool(fv)}
746
+ for xv, yv, fv in zip(x, y, finite)]
747
+ ldata = f"{data_name}_line"
748
+ lenter = {
749
+ "x": {"scale": x_name, "field": "x"}, "y": {"scale": y_name, "field": "y"},
750
+ "defined": {"field": "valid"},
751
+ "stroke": {"value": _color(art.color)},
752
+ "strokeWidth": {"value": float(art.linewidth)},
753
+ "strokeOpacity": {"value": float(art.alpha)},
754
+ "interpolate": {"value": "linear"},
755
+ }
756
+ dash = _dash_array(art.linestyle)
757
+ if dash:
758
+ lenter["strokeDash"] = {"value": dash}
759
+ marks.append({"__data__": (ldata, lvals)})
760
+ marks.append({"type": "line", "from": {"data": ldata}, "encode": {"enter": lenter}})
761
+ values = [{"x": float(xv), "y": float(yv)} for xv, yv in zip(x[finite], y[finite])]
762
+ marks.append({"__data__": (data_name, values)})
763
+ marks.append({
764
+ "type": "symbol", "from": {"data": data_name},
765
+ "encode": {"enter": {
766
+ "x": {"scale": x_name, "field": "x"}, "y": {"scale": y_name, "field": "y"},
767
+ "size": {"value": _symbol_size(float(art.markersize) * size_scale)},
768
+ "fill": {"value": _color(art.color)},
769
+ "fillOpacity": {"value": float(art.alpha)},
770
+ }},
771
+ })
772
+ return marks
773
+
774
+
775
+ def _stem_marks(art, x_name, y_name, size_scale, st):
776
+ # Stem has no markersize of its own -- svg.py:1531 uses the *figure
777
+ # style's* st.marker_size (dpi/72-scaled), not a per-artist field, for
778
+ # the tip dot. size_scale is exactly that conversion, already computed.
779
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
780
+ finite = np.isfinite(x) & np.isfinite(y)
781
+ if not finite.any():
782
+ return []
783
+ data_name = f"data_{id(art):x}"
784
+ values = [{"x": float(xv), "y0": art.baseline, "y1": float(yv)}
785
+ for xv, yv in zip(x[finite], y[finite])]
786
+ marks = [
787
+ {"__data__": (data_name, values)},
788
+ {"type": "rule", "from": {"data": data_name},
789
+ "encode": {"enter": {
790
+ "x": {"scale": x_name, "field": "x"}, "x2": {"scale": x_name, "field": "x"},
791
+ "y": {"scale": y_name, "field": "y0"}, "y2": {"scale": y_name, "field": "y1"},
792
+ "stroke": {"value": _color(art.linecolor)},
793
+ "strokeWidth": {"value": 1.2},
794
+ }}},
795
+ ]
796
+ # The reference line the stems sit on -- svg.py:1526-1530 always draws
797
+ # it, spanning just the data's own x-range (not the full axes width);
798
+ # dropping it silently loses the value (e.g. ax.stem(x, y, bottom=2))
799
+ # a stem plot exists to show.
800
+ if x[finite].size:
801
+ xlo, xhi = float(x[finite].min()), float(x[finite].max())
802
+ marks.append({
803
+ "type": "rule",
804
+ "encode": {"enter": {
805
+ "x": {"scale": x_name, "value": xlo}, "x2": {"scale": x_name, "value": xhi},
806
+ "y": {"scale": y_name, "value": art.baseline},
807
+ "y2": {"scale": y_name, "value": art.baseline},
808
+ "stroke": {"value": st.spine_color},
809
+ "strokeWidth": {"value": 0.8},
810
+ }}},
811
+ )
812
+ marks.append({
813
+ "type": "symbol", "from": {"data": data_name},
814
+ "encode": {"enter": {
815
+ "x": {"scale": x_name, "field": "x"}, "y": {"scale": y_name, "field": "y1"},
816
+ "size": {"value": _symbol_size(st.marker_size * size_scale)},
817
+ "fill": {"value": _color(art.markercolor)},
818
+ }}},
819
+ )
820
+ return marks
821
+
822
+
823
+ def _pie_marks(art, tr):
824
+ # Pie draws in its own axes-pixel space already (see artists.Pie) -- the
825
+ # same fixed circle regardless of x/y scales, so this is frozen pixel
826
+ # geometry like the primitives.py-backed marks, not scale-driven.
827
+ # Center/radius mirror svg.py's _render_pie exactly (tr is built with a
828
+ # local (0, 0, px_w, px_h) rect -- see _axes_to_group -- so px_left/
829
+ # px_top are already 0 here, same as that function's cx/cy math).
830
+ cx = tr.px_left + tr.px_w / 2.0
831
+ cy = tr.px_top + tr.px_h / 2.0
832
+ R = 0.42 * min(tr.px_w, tr.px_h) * art.radius
833
+ data_name = f"data_{id(art):x}"
834
+ # art.fracs (not art.values) -- Pie.__init__ already turns an all-zero
835
+ # total into equal fractions rather than dividing by zero; feeding Vega's
836
+ # own pie transform art.values directly would NaN every angle for that
837
+ # edge case instead.
838
+ values = [{"value": float(v), "color": _color(c)}
839
+ for v, c in zip(art.fracs, art.colors)]
840
+ # Vega's `pie` transform is a *data* transform (it belongs in a data
841
+ # entry's own "transform", not on the mark -- marks have no such
842
+ # property and silently ignore one), and needs startAngle/endAngle
843
+ # fields computed before the arc mark can read them via "field", not
844
+ # "value" -- confirmed by rendering the earlier (broken) version through
845
+ # vg2png: putting "transform" on the mark produced zero wedges, since
846
+ # startAngle stayed 0 and endAngle was never set at all (defaults to 0).
847
+ # Vega's own angle convention (0 at 12 o'clock, clockwise-positive) is
848
+ # converted from plotpress's (0 at 3 o'clock, degrees, matplotlib-style
849
+ # counterclockwise) the same way svg.py's `ang = radians(startangle)`
850
+ # plus its clockwise sweep (`a1 = ang - sweep`, then y negated) works
851
+ # out to: both sweep clockwise on screen, just measured from a
852
+ # different zero point.
853
+ transform = [{"type": "pie", "field": "value",
854
+ "startAngle": math.radians(90.0 - art.startangle)}]
855
+ marks = [
856
+ {"__data__": (data_name, values, transform)},
857
+ {"type": "arc", "from": {"data": data_name},
858
+ "encode": {"enter": {
859
+ "x": {"value": round(float(cx), 2)}, "y": {"value": round(float(cy), 2)},
860
+ "startAngle": {"field": "startAngle"},
861
+ "endAngle": {"field": "endAngle"},
862
+ "innerRadius": {"value": 0},
863
+ "outerRadius": {"value": round(float(R), 2)},
864
+ "fill": {"field": "color"},
865
+ "fillOpacity": {"value": float(art.alpha)},
866
+ "stroke": {"value": "#ffffff"},
867
+ "strokeWidth": {"value": 1.5},
868
+ }}},
869
+ ]
870
+ # Wedge label / autopct%% text -- svg.py:1596-1610 draws both at fixed
871
+ # points, one wedge-midpoint angle at a time; Vega's `pie` data
872
+ # transform only computes angles for the arc mark that reads it (there's
873
+ # no equivalent auto-placement for a *text* mark), so the same
874
+ # fracs/startangle walk svg.py does is repeated here in Python to get a
875
+ # literal x/y per label -- frozen pixel positions, matching how the arc
876
+ # itself is frozen pixel geometry (see the module docstring).
877
+ if art.labels is not None or art.autopct is not None:
878
+ ang = math.radians(art.startangle)
879
+ label_rows, pct_rows = [], []
880
+ for frac in art.fracs:
881
+ sweep = frac * 2 * math.pi
882
+ a0, a1 = ang, ang - sweep
883
+ am = (a0 + a1) / 2.0
884
+ ang = a1
885
+ label_rows.append({"x": cx + 1.15 * R * math.cos(am), "y": cy - 1.15 * R * math.sin(am),
886
+ "anchor": "start" if math.cos(am) >= 0 else "end"})
887
+ pct_rows.append({"x": cx + 0.6 * R * math.cos(am), "y": cy - 0.6 * R * math.sin(am)})
888
+ if art.labels is not None:
889
+ lvals = [{"x": round(float(r["x"]), 2), "y": round(float(r["y"]), 2),
890
+ "text": str(lbl), "align": r["anchor"]}
891
+ for r, lbl in zip(label_rows, art.labels)]
892
+ ldata = f"{data_name}_labels"
893
+ marks.append({"__data__": (ldata, lvals)})
894
+ marks.append({
895
+ "type": "text", "from": {"data": ldata},
896
+ "encode": {"enter": {
897
+ "x": {"field": "x"}, "y": {"field": "y"}, "text": {"field": "text"},
898
+ "align": {"field": "align"}, "baseline": {"value": "middle"},
899
+ "fontSize": {"value": 10},
900
+ }},
901
+ })
902
+ if art.autopct is not None:
903
+ pvals = [{"x": round(float(r["x"]), 2), "y": round(float(r["y"]), 2), "text": pct}
904
+ for r, frac in zip(pct_rows, art.fracs)
905
+ for pct in [art.pct_text(frac)] if pct is not None]
906
+ if pvals:
907
+ pdata = f"{data_name}_pct"
908
+ marks.append({"__data__": (pdata, pvals)})
909
+ marks.append({
910
+ "type": "text", "from": {"data": pdata},
911
+ "encode": {"enter": {
912
+ "x": {"field": "x"}, "y": {"field": "y"}, "text": {"field": "text"},
913
+ "align": {"value": "center"}, "baseline": {"value": "middle"},
914
+ "fontSize": {"value": 10},
915
+ }},
916
+ })
917
+ return marks
918
+
919
+
920
+ # plotpress va -> Vega text-mark baseline. svg.py's own _VA maps to SVG's
921
+ # dominant-baseline vocabulary instead; Vega's baseline property uses a
922
+ # different (if overlapping) set of names.
923
+ _VEGA_VA = {"baseline": "alphabetic", "bottom": "bottom", "center": "middle", "top": "top"}
924
+
925
+
926
+ def _text_marks(art, tr, st):
927
+ if isinstance(art, Annotation):
928
+ x, y = art.xytext if art.xytext is not None else art.xy
929
+ else:
930
+ x, y = art.x, art.y
931
+ if art.axes_fraction:
932
+ px, py = (float(v) for v in _axes_fraction_xy(tr, x, y))
933
+ else:
934
+ px, py = float(tr.x(x)), float(tr.y(y))
935
+ # `or 11` would treat a legitimate size=0 (e.g. a deliberately hidden
936
+ # label) the same as "missing" and silently draw it at 11pt instead --
937
+ # svg.py's own text renderers pass size straight through with no such
938
+ # fallback, so this only substitutes when the attribute is truly absent.
939
+ size = getattr(art, "size", None)
940
+ size = 11.0 if size is None else float(size)
941
+ ha = getattr(art, "ha", "left")
942
+ va = getattr(art, "va", "baseline")
943
+ bold = bool(getattr(art, "bold", False))
944
+ italic = bool(getattr(art, "italic", False))
945
+ alpha = float(getattr(art, "alpha", 1.0))
946
+ color = _color(getattr(art, "color", None), "#000000")
947
+ marks = []
948
+
949
+ # A bbox= background box, measured with the same font metrics svg.py's
950
+ # own text_box() uses (reused directly, not re-derived) -- so the box
951
+ # actually wraps the glyphs it's drawn behind, matching svg.py:1851-1855.
952
+ box = None
953
+ bbox = getattr(art, "bbox", None)
954
+ if bbox is not None:
955
+ box = _bbox_pad(text_box(px, py, art.text, size, ha, va, st, bold=bold, italic=italic), bbox)
956
+ x0, y0, x1, y1 = box
957
+ rx = min(8.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0) if bbox["boxstyle"] == "round" else 0.0
958
+ rect_enter = {
959
+ "x": {"value": round(float(x0), 2)}, "y": {"value": round(float(y0), 2)},
960
+ "width": {"value": round(float(x1 - x0), 2)}, "height": {"value": round(float(y1 - y0), 2)},
961
+ "cornerRadius": {"value": round(float(rx), 2)},
962
+ "fill": {"value": _color(bbox["facecolor"])},
963
+ }
964
+ if bbox["alpha"] < 1:
965
+ rect_enter["fillOpacity"] = {"value": float(bbox["alpha"])}
966
+ if bbox["edgecolor"] not in (None, "none"):
967
+ rect_enter["stroke"] = {"value": _color(bbox["edgecolor"])}
968
+ rect_enter["strokeWidth"] = {"value": float(bbox["linewidth"])}
969
+ marks.append({"type": "rect", "encode": {"enter": rect_enter}})
970
+
971
+ # Annotation's leader line + arrowhead -- svg.py:1873-1898. leader_anchor
972
+ # (reused, not re-derived) picks the nearest box edge midpoint rather
973
+ # than the bare text anchor, so the line doesn't cut through the words
974
+ # it's pointing away from.
975
+ if isinstance(art, Annotation) and art.arrowprops is not None:
976
+ if art.axes_fraction:
977
+ tx, ty = (float(v) for v in _axes_fraction_xy(tr, *art.xy))
978
+ else:
979
+ tx, ty = float(tr.x(art.xy[0])), float(tr.y(art.xy[1]))
980
+ arrow_color = _color(
981
+ art.arrowprops.get("color", art.color) if isinstance(art.arrowprops, dict) else art.color)
982
+ arrow_alpha = (art.arrowprops.get("alpha", 1.0)
983
+ if isinstance(art.arrowprops, dict) else 1.0)
984
+ anchor_box = box if box is not None else text_box(px, py, art.text, size, ha, va, st,
985
+ bold=bold, italic=italic)
986
+ sx, sy = leader_anchor(anchor_box, (tx, ty))
987
+ ang = math.atan2(ty - sy, tx - sx)
988
+ hl = 7.0
989
+ h1 = (tx - hl * math.cos(ang - 0.4), ty - hl * math.sin(ang - 0.4))
990
+ h2 = (tx - hl * math.cos(ang + 0.4), ty - hl * math.sin(ang + 0.4))
991
+ d = (f"M{sx:.2f},{sy:.2f} L{tx:.2f},{ty:.2f} "
992
+ f"M{tx:.2f},{ty:.2f} L{h1[0]:.2f},{h1[1]:.2f} "
993
+ f"M{tx:.2f},{ty:.2f} L{h2[0]:.2f},{h2[1]:.2f}")
994
+ arrow_enter = {
995
+ "path": {"value": d}, "stroke": {"value": arrow_color}, "strokeWidth": {"value": 1.2},
996
+ }
997
+ if arrow_alpha < 1:
998
+ arrow_enter["strokeOpacity"] = {"value": float(arrow_alpha)}
999
+ marks.append({"type": "path", "encode": {"enter": arrow_enter}})
1000
+
1001
+ text_enter = {
1002
+ "x": {"value": round(px, 2)}, "y": {"value": round(py, 2)},
1003
+ "text": {"value": art.text},
1004
+ "fill": {"value": color},
1005
+ "fontSize": {"value": size},
1006
+ "align": {"value": ha if ha in ("left", "right", "center") else "left"},
1007
+ "baseline": {"value": _VEGA_VA.get(va, "alphabetic")},
1008
+ }
1009
+ rotation = float(getattr(art, "rotation", 0.0) or 0.0)
1010
+ if rotation:
1011
+ # plotpress's own rotation is counterclockwise-positive (matplotlib
1012
+ # convention); Vega's `angle`, like SVG's rotate(), is
1013
+ # clockwise-positive -- svg.py:1686 negates for the same reason.
1014
+ text_enter["angle"] = {"value": -rotation}
1015
+ if bold:
1016
+ text_enter["fontWeight"] = {"value": "bold"}
1017
+ if italic:
1018
+ text_enter["fontStyle"] = {"value": "italic"}
1019
+ if alpha < 1:
1020
+ text_enter["fillOpacity"] = {"value": alpha}
1021
+ marks.append({"type": "text", "encode": {"enter": text_enter}})
1022
+ return marks
1023
+
1024
+
1025
+ # ---- primitive -> Vega marks (frozen pixel geometry) -------------------
1026
+
1027
+ def _prim_to_vega(p):
1028
+ if isinstance(p, PMarkers):
1029
+ pts = p.points
1030
+ finite = np.isfinite(pts).all(axis=1)
1031
+ if not finite.any():
1032
+ return []
1033
+ data_name = f"prim_{id(p):x}"
1034
+ diam = np.broadcast_to(p.diameters, (pts.shape[0],))
1035
+ # p.colors is already one entry per point regardless of
1036
+ # single_color -- that flag is only a hint for a backend that wants
1037
+ # to skip redundant per-node color attributes, not a shorter list.
1038
+ colors = np.asarray(p.colors, dtype=object)
1039
+ values = [
1040
+ {"x": float(pt[0]), "y": float(pt[1]), "size": _symbol_size(float(d)), "color": _color(c)}
1041
+ for pt, d, c in zip(pts[finite], diam[finite], colors[finite])
1042
+ ]
1043
+ enter = {
1044
+ "x": {"field": "x"}, "y": {"field": "y"}, "size": {"field": "size"},
1045
+ "fill": {"field": "color"}, "fillOpacity": {"value": float(p.alpha)},
1046
+ }
1047
+ # p.edgewidth == 0 is Markers' own "no outline" convention (see its
1048
+ # docstring) -- matches every other backend drawing nothing rather
1049
+ # than a zero-width stroke.
1050
+ if p.edgewidth:
1051
+ enter["stroke"] = {"value": _color(p.edgecolor)}
1052
+ enter["strokeWidth"] = {"value": float(p.edgewidth)}
1053
+ return [{"__data__": (data_name, values)}, {
1054
+ "type": "symbol", "from": {"data": data_name},
1055
+ "encode": {"enter": enter},
1056
+ }]
1057
+
1058
+ if isinstance(p, PLine):
1059
+ enter = {
1060
+ "x": {"value": float(p.p0[0])}, "y": {"value": float(p.p0[1])},
1061
+ "x2": {"value": float(p.p1[0])}, "y2": {"value": float(p.p1[1])},
1062
+ "stroke": {"value": _color(p.stroke)},
1063
+ "strokeWidth": {"value": float(p.stroke_width)},
1064
+ "strokeOpacity": {"value": float(p.stroke_opacity)},
1065
+ }
1066
+ dash = _dash_array(p.linestyle)
1067
+ if dash:
1068
+ enter["strokeDash"] = {"value": dash}
1069
+ return [{"type": "rule", "encode": {"enter": enter}}]
1070
+
1071
+ if isinstance(p, PRect):
1072
+ return [{
1073
+ "type": "rect",
1074
+ "encode": {"enter": {
1075
+ "x": {"value": float(p.x)}, "y": {"value": float(p.y)},
1076
+ "width": {"value": float(p.w)}, "height": {"value": float(p.h)},
1077
+ "fill": {"value": _color(p.fill)},
1078
+ "fillOpacity": {"value": float(p.fill_opacity)},
1079
+ }},
1080
+ }]
1081
+
1082
+ if isinstance(p, PSegments):
1083
+ if p.segs.size == 0:
1084
+ return []
1085
+ data_name = f"prim_{id(p):x}"
1086
+ values = [{"x0": float(s[0]), "y0": float(s[1]), "x1": float(s[2]), "y1": float(s[3])}
1087
+ for s in p.segs]
1088
+ enter = {
1089
+ "x": {"field": "x0"}, "y": {"field": "y0"},
1090
+ "x2": {"field": "x1"}, "y2": {"field": "y1"},
1091
+ "stroke": {"value": _color(p.stroke)},
1092
+ "strokeWidth": {"value": float(p.stroke_width)},
1093
+ "strokeOpacity": {"value": float(p.stroke_opacity)},
1094
+ }
1095
+ dash = _dash_array(p.linestyle)
1096
+ if dash:
1097
+ enter["strokeDash"] = {"value": dash}
1098
+ return [{"__data__": (data_name, values)}, {
1099
+ "type": "rule", "from": {"data": data_name},
1100
+ "encode": {"enter": enter},
1101
+ }]
1102
+
1103
+ if isinstance(p, PPath):
1104
+ marks = []
1105
+ for sub in p.subpaths:
1106
+ finite_sub = sub[np.isfinite(sub).all(axis=1)]
1107
+ if finite_sub.shape[0] < 2:
1108
+ continue
1109
+ d = _path_string(finite_sub, p.closed)
1110
+ enter = {"path": {"value": d}}
1111
+ if p.fill:
1112
+ enter["fill"] = {"value": _color(p.fill)}
1113
+ enter["fillOpacity"] = {"value": float(p.fill_opacity)}
1114
+ if p.stroke:
1115
+ enter["stroke"] = {"value": _color(p.stroke)}
1116
+ enter["strokeWidth"] = {"value": float(p.stroke_width)}
1117
+ enter["strokeOpacity"] = {"value": float(p.stroke_opacity)}
1118
+ dash = _dash_array(p.linestyle)
1119
+ if dash:
1120
+ enter["strokeDash"] = {"value": dash}
1121
+ marks.append({"type": "path", "encode": {"enter": enter}})
1122
+ return marks
1123
+
1124
+ if isinstance(p, PPolyBatch):
1125
+ marks = []
1126
+ for poly, fill in zip(p.polys, p.fills):
1127
+ finite_poly = poly[np.isfinite(poly).all(axis=1)]
1128
+ if finite_poly.shape[0] < 2:
1129
+ continue
1130
+ enter = {
1131
+ "path": {"value": _path_string(finite_poly, True)},
1132
+ "fill": {"value": _color(fill)},
1133
+ "fillOpacity": {"value": float(p.alpha)},
1134
+ }
1135
+ if p.edge:
1136
+ enter["stroke"] = {"value": _color(p.edge)}
1137
+ enter["strokeWidth"] = {"value": float(p.edge_width)}
1138
+ marks.append({"type": "path", "encode": {"enter": enter}})
1139
+ return marks
1140
+
1141
+ if isinstance(p, PImage):
1142
+ return [{
1143
+ "type": "image",
1144
+ "encode": {"enter": {
1145
+ "x": {"value": float(p.x)}, "y": {"value": float(p.y)},
1146
+ "width": {"value": float(p.w)}, "height": {"value": float(p.h)},
1147
+ "url": {"value": png_data_uri(p.rgba)},
1148
+ "smooth": {"value": bool(p.smooth)},
1149
+ "aspect": {"value": False},
1150
+ }},
1151
+ }]
1152
+
1153
+ return []
1154
+
1155
+
1156
+ # Vertical-alignment y-offset fractions for fig.text(), matching svg.py's
1157
+ # own _render_fig_text approximation exactly (not true font-metric centering).
1158
+ _VA_DY = {"top": 0.8, "center": 0.35, "bottom": 0.0, "baseline": 0.0}
1159
+
1160
+
1161
+ def _figtexts_to_vega_marks(fig, W, H):
1162
+ """``fig.suptitle()``/``supxlabel()``/``supylabel()``/``text()`` as
1163
+ top-level Vega ``text`` marks -- direct ports of ``svg.py``'s
1164
+ ``_render_figtexts`` (same position math, same fallback sizes), the
1165
+ same figure-pixel-space, sibling-of-the-axes-groups treatment
1166
+ :func:`_groups_to_vega_marks` already gives ``Figure.group()`` boxes.
1167
+ """
1168
+ st = fig.style
1169
+ marks = []
1170
+ if fig._suptitle:
1171
+ t = fig._suptitle
1172
+ size = t.get("size") or st.title_size * 1.5
1173
+ marks.append({"type": "text", "encode": {"enter": {
1174
+ "x": {"value": round(W / 2.0, 2)}, "y": {"value": round(size + 6, 2)},
1175
+ "text": {"value": t["text"]}, "align": {"value": "center"},
1176
+ "fontSize": {"value": float(size)}, "fontWeight": {"value": "bold"},
1177
+ "fill": {"value": _color(st.text_color)},
1178
+ }}})
1179
+ if fig._supxlabel:
1180
+ t = fig._supxlabel
1181
+ size = t.get("size") or st.label_size * 1.2
1182
+ marks.append({"type": "text", "encode": {"enter": {
1183
+ "x": {"value": round(W / 2.0, 2)}, "y": {"value": round(H - 6, 2)},
1184
+ "text": {"value": t["text"]}, "align": {"value": "center"},
1185
+ "fontSize": {"value": float(size)}, "fill": {"value": _color(st.text_color)},
1186
+ }}})
1187
+ if fig._supylabel:
1188
+ t = fig._supylabel
1189
+ size = t.get("size") or st.label_size * 1.2
1190
+ x, y = size + 4, H / 2.0
1191
+ marks.append({"type": "text", "encode": {"enter": {
1192
+ "x": {"value": round(float(x), 2)}, "y": {"value": round(float(y), 2)},
1193
+ "text": {"value": t["text"]}, "align": {"value": "center"},
1194
+ "angle": {"value": -90},
1195
+ "fontSize": {"value": float(size)}, "fill": {"value": _color(st.text_color)},
1196
+ }}})
1197
+ for t in fig._fig_texts:
1198
+ size = t["size"] or st.font_size
1199
+ x = t["x"] * W
1200
+ y = (1.0 - t["y"]) * H + _VA_DY.get(t["va"], 0.0) * size
1201
+ align = {"left": "left", "center": "center", "right": "right"}.get(t["ha"], "left")
1202
+ enter = {
1203
+ "x": {"value": round(float(x), 2)}, "y": {"value": round(float(y), 2)},
1204
+ "text": {"value": t["s"]}, "align": {"value": align},
1205
+ "fontSize": {"value": float(size)},
1206
+ "fill": {"value": _color(t["color"] or st.text_color)},
1207
+ }
1208
+ if t.get("alpha", 1.0) < 1:
1209
+ enter["fillOpacity"] = {"value": float(t["alpha"])}
1210
+ marks.append({"type": "text", "encode": {"enter": enter}})
1211
+ return marks
1212
+
1213
+
1214
+ def _groups_to_vega_marks(fig, W, H):
1215
+ """``Figure.group()``'s labeled boxes as top-level Vega ``rect``+``text``
1216
+ marks -- a direct port of ``svg.py``'s ``_render_groups`` (same box
1217
+ geometry, same clearance/pad math, same title placement) onto Vega's
1218
+ mark model instead of raw SVG elements.
1219
+ """
1220
+ st = fig.style
1221
+ marks = []
1222
+ for gi, g in enumerate(fig._groups):
1223
+ members = g["axes"] + _group_colorbars(g["axes"], fig)
1224
+ rects = [_pixel_rect(ax, W, H) for ax in members]
1225
+ extras = [_group_colorbar_extra(ax, st) if ax._is_colorbar
1226
+ else _group_axes_extra(ax, st) for ax in members]
1227
+ pad_l, pad_r, pad_t, pad_b = g["pad"]
1228
+ x0 = min(r[0] - e[2] for r, e in zip(rects, extras)) - pad_l
1229
+ y0 = min(r[1] - e[0] for r, e in zip(rects, extras)) - pad_t
1230
+ x1 = max(r[0] + r[2] + e[3] for r, e in zip(rects, extras)) + pad_r
1231
+ y1 = max(r[1] + r[3] + e[1] for r, e in zip(rects, extras)) + pad_b
1232
+
1233
+ if g["linestyle"] == "none":
1234
+ stroke_enter = {"stroke": {"value": None}}
1235
+ else:
1236
+ dash = _DASH.get(g["linestyle"])
1237
+ stroke_enter = {
1238
+ "stroke": {"value": g["color"]},
1239
+ "strokeWidth": {"value": g["linewidth"]},
1240
+ }
1241
+ if dash:
1242
+ stroke_enter["strokeDash"] = {"value": [float(n) for n in dash.split(",")]}
1243
+ marks.append({
1244
+ "type": "rect",
1245
+ "name": f"group{gi}",
1246
+ "encode": {"enter": {
1247
+ "x": {"value": round(float(x0), 2)},
1248
+ "y": {"value": round(float(y0), 2)},
1249
+ "width": {"value": round(float(x1 - x0), 2)},
1250
+ "height": {"value": round(float(y1 - y0), 2)},
1251
+ "fill": {"value": None},
1252
+ **stroke_enter,
1253
+ }},
1254
+ })
1255
+
1256
+ size = g["fontsize"] or st.title_size
1257
+ pos = g["title_position"]
1258
+ if pos == "top":
1259
+ tx, ty, align = (x0 + x1) / 2, y0 - 6, "center"
1260
+ elif pos == "bottom":
1261
+ tx, ty, align = (x0 + x1) / 2, y1 + size + 2, "center"
1262
+ elif pos == "left":
1263
+ tx, ty, align = x0 - 6, (y0 + y1) / 2 + 0.35 * size, "right"
1264
+ else:
1265
+ tx, ty, align = x1 + 6, (y0 + y1) / 2 + 0.35 * size, "left"
1266
+ marks.append({
1267
+ "type": "text",
1268
+ "name": f"group{gi}_title",
1269
+ "encode": {"enter": {
1270
+ "x": {"value": round(float(tx), 2)},
1271
+ "y": {"value": round(float(ty), 2)},
1272
+ "text": {"value": g["title"]},
1273
+ "align": {"value": align},
1274
+ "fontSize": {"value": size},
1275
+ "fontWeight": {"value": "bold"},
1276
+ "fill": {"value": g["color"]},
1277
+ }},
1278
+ })
1279
+ return marks
1280
+
1281
+
1282
+ def _path_string(pts, closed):
1283
+ parts = [f"M{pts[0, 0]:.2f},{pts[0, 1]:.2f}"]
1284
+ parts.extend(f"L{x:.2f},{y:.2f}" for x, y in pts[1:])
1285
+ if closed:
1286
+ parts.append("Z")
1287
+ return "".join(parts)
1288
+
1289
+
1290
+ # ---- assemble the final spec: promote inline __data__ markers ----------
1291
+
1292
+ def _finalize(spec):
1293
+ """Walk the marks tree, pulling every ``{"__data__": (name, values)}``
1294
+ (or ``(name, values, transform)``, for a data source that needs a real
1295
+ Vega data transform -- e.g. the ``pie`` transform computing
1296
+ ``startAngle``/``endAngle`` -- rather than raw literal rows) placeholder
1297
+ up into the spec's own top-level ``data`` array and dropping the
1298
+ placeholder itself -- lets every mark-building function above just emit
1299
+ its data inline next to the mark that reads it, rather than threading a
1300
+ shared ``data`` list through every one of them.
1301
+ """
1302
+ data = []
1303
+ seen = set()
1304
+
1305
+ def walk(marks):
1306
+ out = []
1307
+ for m in marks:
1308
+ if "__data__" in m:
1309
+ name, values, *rest = m["__data__"]
1310
+ if name not in seen:
1311
+ seen.add(name)
1312
+ entry = {"name": name, "values": values}
1313
+ if rest and rest[0]:
1314
+ entry["transform"] = rest[0]
1315
+ data.append(entry)
1316
+ continue
1317
+ if m.get("type") == "group":
1318
+ m["marks"] = walk(m["marks"])
1319
+ out.append(m)
1320
+ return out
1321
+
1322
+ spec["marks"] = walk(spec["marks"])
1323
+ spec["data"] = data
1324
+ return spec