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_lite.py ADDED
@@ -0,0 +1,1199 @@
1
+ """Export a Figure as a real Vega-Lite v5 JSON specification.
2
+
3
+ Vega-Lite is a stricter, more declarative grammar than Vega -- a closed mark
4
+ vocabulary (``line``/``bar``/``point``/``arc``/``errorbar``/``area``/``rule``/
5
+ ``text``/... -- no raw path-with-literal-d mark the way Vega has) and a
6
+ grid-like composition model (``hconcat``/``vconcat``), not Vega's arbitrary
7
+ pixel-positioned ``group`` marks. Both are real, structural mismatches with
8
+ plotpress's own ``Figure``/``Axes`` model, not just missing artist-type
9
+ coverage the way most of :mod:`plotpress.vega`'s gaps were -- so this module
10
+ is honest about three separate kinds of gap, not one:
11
+
12
+ **Tier 1 -- native mark, direct field mapping.** ``Line2D``, ``ScatterCollection``,
13
+ ``Bars``, ``ErrorBar`` (Vega-Lite's own ``errorbar`` mark, with precomputed
14
+ ``yError``/``xError`` fields -- genuinely simpler than :mod:`plotpress.vega`'s
15
+ hand-built whisker/cap geometry), ``Pie`` (Vega-Lite's ``arc`` mark
16
+ auto-stacks ``theta``, no manual per-wedge trig), a monotonic-x two-boundary
17
+ ``FillBetween`` (Vega-Lite's ``area`` mark), and ``QuadMesh``/``Image``
18
+ (``pcolormesh``/``imshow``) as a real ``image`` mark, reusing the same
19
+ rasterized RGBA + data extent every other backend already computes rather
20
+ than re-deriving anything. Log/inverted/custom-tick axis properties map
21
+ onto Vega-Lite's own ``encoding.<channel>.scale``/``.axis``.
22
+
23
+ **Tier 2 -- a layered workaround within Vega-Lite's own vocabulary,** more
24
+ code, more fragile: reference lines/spans (``VLine``/``HLine``/``AxLine``/
25
+ ``Span``) and ``LineCollection`` (``hlines()``/``vlines()``, violin inner
26
+ quartile/whisker lines, ``acorr()``/``xcorr()``) via a tiny inline literal
27
+ dataset, since Vega-Lite has no scale-independent constant shorthand the way
28
+ Vega's ``{"value": ...}`` is; ``Rug`` the same way, plus a literal *pixel*
29
+ value (not a data scale) for its fixed-fraction tick length; ``Stem`` (three
30
+ layers: stems, baseline, tips); dashed lines (a plain ``strokeDash`` mark
31
+ property); simple ``Text``/``Annotation`` with no ``bbox``/arrow; custom tick
32
+ *labels* via ``axis.labelExpr`` (capped at ~12 ticks -- fragile past that);
33
+ pie wedge labels/``autopct`` (frozen per-wedge positions via a real Vega-Lite
34
+ ``area`` mark, and an explicit arc center/radius the labels are positioned
35
+ against, rather than trusting Vega-Lite's own undocumented auto-sizing --
36
+ same trig walk :mod:`plotpress.vega`'s ``_pie_marks`` uses); ``Polygon``
37
+ (``fill()``, and critically ``fill_betweenx()`` -- built the same
38
+ forward-one-boundary-back-the-other way as ``fill_between()`` internally,
39
+ see ``axes.py``) as a real ``area`` mark, but *only* when the polygon
40
+ actually has that two-boundary-strip shape -- detected structurally, not
41
+ by which call built it.
42
+
43
+ **Tier 3 -- no reasonable mapping, warns and skips** (the same
44
+ "degrade a part, not the whole" policy :func:`plotpress.vega.figure_to_vega`
45
+ already follows): everything already unsupported there (``BoxPlot``,
46
+ ``Violin``, ``Quiver``, ``Contour``, ``EventPlot``, ``Barbs``, ``Table``,
47
+ legends), plus ``PolyCollection`` (no closed-vocabulary polygon-batch mark),
48
+ a non-monotonic ``FillBetween``, a ``Polygon`` that isn't a two-boundary
49
+ strip (an arbitrary closed ``fill()`` shape -- a filled circle, a hexbin
50
+ cell -- has no Vega-Lite closed-vocabulary mark either), annotation
51
+ *arrows* specifically (kept as text, the arrow itself has no Vega-Lite
52
+ mark), and ``Figure.group()`` boxes (no cross-panel drawing surface in
53
+ Vega-Lite's per-view composition at all).
54
+
55
+ **The figure-level composition problem** is the one Vega itself never
56
+ forced: plotpress allows arbitrary grid spans, ``add_axes()`` free rects,
57
+ ``inset_axes()``, ``twinx()``/``twiny()``, secondary axes, and colorbar
58
+ axes, none of which ``hconcat``/``vconcat`` can express as freely as a Vega
59
+ ``group`` mark's own explicit pixel position can. :func:`figure_to_vega_lite`
60
+ partitions a figure's axes into what composes cleanly into one nested
61
+ ``hconcat``/``vconcat`` grid, a twin merged into its parent's own view via
62
+ Vega-Lite's ``resolve.scale`` independence, and everything else exported as
63
+ an independent standalone spec rather than forced into a layout Vega-Lite
64
+ was never asked to represent -- see that function's own docstring for the
65
+ exact algorithm and the return shape.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import math
71
+ import warnings
72
+
73
+ import numpy as np
74
+
75
+ from .artists import (
76
+ AxLine, Bars, ErrorBar, FillBetween, HLine, Image, Line2D, LineCollection,
77
+ Pie, Polygon, QuadMesh, Rug, ScatterCollection, Span, Stem, Text,
78
+ Annotation, VLine,
79
+ )
80
+ from .png import png_data_uri
81
+ from .svg import _effective_rect, _pixel_rect
82
+ from .vega import (
83
+ _color, _dash_array, _mesh_cell_rows, _mesh_data_reason, _mesh_scheme,
84
+ _symbol_size,
85
+ )
86
+
87
+ _SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json"
88
+
89
+ # Artist kinds with a Tier-1/Tier-2 mapping below; anything else falls
90
+ # through to the generic "no Vega-Lite mapping yet" warning, mirroring
91
+ # plotpress.vega's own artist_to_prims() fallback -- except this module has
92
+ # no shared pixel-space fallback layer to reuse (Vega-Lite's mark
93
+ # vocabulary is closed), so unmapped here really does mean unmapped.
94
+ _UNSUPPORTED_NAMES = (
95
+ "box plots, violins, quiver, contour, event plots, wind barbs, tables, "
96
+ "and arbitrary polygon batches (e.g. hexbin)"
97
+ )
98
+
99
+ # The one aggregate structural warning figure_to_vega_lite() emits, exposed
100
+ # so a caller (docs/conf.py's own Vega-Lite page) can recognize and skip it
101
+ # when it's already showing the same `caveats` list individually, rather
102
+ # than duplicating every entry as one more warning-derived bullet.
103
+ _STRUCTURAL_WARNING_PREFIX = (
104
+ "figure_to_vega_lite(): this figure's structure isn't fully "
105
+ "captured in the Vega-Lite result -- "
106
+ )
107
+
108
+
109
+ def figure_to_vega_lite(fig, mesh_data: bool = False) -> tuple[dict, list[str]]:
110
+ """Build a Vega-Lite v5 spec for ``fig``.
111
+
112
+ Returns ``(result, caveats)`` -- **not** a bare ``dict`` the way
113
+ :meth:`~plotpress.figure.Figure.to_vega`/``to_svg``/``to_html`` are, a
114
+ deliberate, documented asymmetry (see :meth:`~plotpress.figure.Figure.to_vega_lite`).
115
+ ``result`` is ``{"grid": <spec> | None, "standalone": [<spec>, ...]}``:
116
+
117
+ - ``"grid"`` is one combined ``hconcat``/``vconcat`` spec covering every
118
+ axes that shares one consistent grid shape (see
119
+ :func:`_is_cleanly_composable`), or ``None`` if no such grid exists
120
+ (e.g. a single-axes figure, or a figure whose axes don't share one
121
+ shape at all).
122
+ - ``"standalone"`` is a list of independent specs for everything that
123
+ couldn't join the grid: a single axes with nothing to grid against,
124
+ axes from a mismatched-shape grid, and any axes Vega-Lite's own
125
+ composition model has no slot for at all (``add_axes()`` free rects,
126
+ ``inset_axes()``, secondary axes) -- a **twin** (``twinx``/``twiny``)
127
+ is the one exception, merged into its parent's own spec as an extra
128
+ ``layer`` with an independent scale on the shared channel
129
+ (``resolve.scale.y: "independent"`` for ``twinx``, ``.x`` for
130
+ ``twiny`` -- whichever axis the twin doesn't share) instead, since
131
+ dropping an entire overlaid series is the worst of the fallbacks
132
+ available. A colorbar axes has no artists of its own to export at
133
+ all (``fig.colorbar()`` draws it through a separate path ``svg.py``
134
+ reads, not ``ax.artists``) and no Vega-Lite gradient-legend mark to
135
+ stand in for it, so it is simply dropped, with a caveat -- it never
136
+ reaches ``"standalone"``.
137
+
138
+ ``caveats`` lists every structural compromise made building the result
139
+ (a dropped entangled axes, a grid-shape mismatch forcing the standalone
140
+ list, a polar axes losing its aspect lock) -- data for a caller
141
+ deciding what to do with a partially-composed figure, not just console
142
+ noise. Every entry is also re-emitted as a ``UserWarning`` (one
143
+ aggregate message), so a caller who ignores the tuple return still sees
144
+ the same warning :func:`~plotpress.vega.figure_to_vega` callers already
145
+ rely on. Per-artist-type and per-legend gaps (see the module docstring's
146
+ Tier 3) warn individually instead, matching that function's own
147
+ convention exactly.
148
+ """
149
+ dpi = fig.style.dpi
150
+ size_scale = dpi / 72.0
151
+ caveats: list[str] = []
152
+
153
+ # A colorbar axes is never a grid/span member (it steals its rect from
154
+ # an existing axes, not a fresh grid cell) -- it falls into
155
+ # entangled_axes below like add_axes()/inset_axes(), NOT dropped
156
+ # outright, so it still gets a standalone spec and a caveat.
157
+ visible = [(i, ax) for i, ax in enumerate(fig.axes) if ax._visible]
158
+ grid_axes, span_axes, entangled_axes = [], [], []
159
+ for i, ax in visible:
160
+ if _is_cleanly_composable(ax):
161
+ grid_axes.append((i, ax))
162
+ elif (ax._subplotspec is not None and ax._twin_of is None
163
+ and ax._secondary_of is None and ax._inset_parent is None
164
+ and not ax._is_colorbar):
165
+ span_axes.append((i, ax))
166
+ else:
167
+ entangled_axes.append((i, ax))
168
+
169
+ standalone: list[dict] = []
170
+ grid_spec = None
171
+
172
+ composable = grid_axes + span_axes
173
+ if composable:
174
+ shapes = {(ax._subplotspec.nrows, ax._subplotspec.ncols) for _, ax in composable}
175
+ if len(shapes) == 1:
176
+ grid_spec, span_caveats = _build_grid(composable, fig, size_scale, mesh_data)
177
+ caveats.extend(span_caveats)
178
+ else:
179
+ caveats.append(
180
+ f"{len(composable)} axes span {len(shapes)} different grid shapes "
181
+ "(mixed add_subplot() calls on one figure) -- Vega-Lite's "
182
+ "hconcat/vconcat composition needs one consistent shape, so "
183
+ "each axes was exported as its own independent spec instead "
184
+ "of one combined grid."
185
+ )
186
+ for _, ax in composable:
187
+ spec, axcav = _axes_to_vl_spec(ax, size_scale, mesh_data)
188
+ caveats.extend(axcav)
189
+ if spec is not None:
190
+ standalone.append(spec)
191
+
192
+ # Twins merge into their parent's own spec (already built above, inside
193
+ # a grid cell or a standalone spec) as an extra layer -- find the
194
+ # parent's spec and append rather than dropping the whole series.
195
+ twins = [(i, ax) for i, ax in entangled_axes if ax._twin_of is not None]
196
+ other_entangled = [(i, ax) for i, ax in entangled_axes if ax._twin_of is None]
197
+ for i, ax in twins:
198
+ merged = _merge_twin(ax, grid_spec, standalone, size_scale, mesh_data)
199
+ if not merged:
200
+ caveats.append(
201
+ f"axes {i} is a twinx()/twiny() overlay whose parent axes "
202
+ "wasn't found in the composed output (an unusual layout) -- "
203
+ "exported as its own independent spec instead of merged."
204
+ )
205
+ spec, axcav = _axes_to_vl_spec(ax, size_scale, mesh_data)
206
+ caveats.extend(axcav)
207
+ if spec is not None:
208
+ standalone.append(spec)
209
+
210
+ for i, ax in other_entangled:
211
+ if ax._is_colorbar:
212
+ # A colorbar axes has no artists of its own (fig.colorbar()
213
+ # draws it via a separate _cbar_source/_cbar_parents-reading
214
+ # path, not ax.artists -- see svg.py's _render_colorbar), so
215
+ # _axes_to_vl_spec always finds nothing exportable here. Say so
216
+ # plainly rather than claim it was "exported independently"
217
+ # when nothing actually was.
218
+ caveats.append(
219
+ f"axes {i} is a colorbar -- Vega-Lite has no standalone "
220
+ "gradient-legend mark to export it as, so it was dropped."
221
+ )
222
+ continue
223
+ kind = ("an inset_axes()" if ax._inset_parent is not None else
224
+ "a secondary_xaxis()/secondary_yaxis()" if ax._secondary_of is not None else
225
+ "a free-form add_axes() rect")
226
+ caveats.append(
227
+ f"axes {i} is {kind} -- Vega-Lite's hconcat/vconcat composition "
228
+ "has no way to position it relative to the other axes, so it "
229
+ "was exported as its own independent spec."
230
+ )
231
+ spec, axcav = _axes_to_vl_spec(ax, size_scale, mesh_data)
232
+ caveats.extend(axcav)
233
+ if spec is not None:
234
+ standalone.append(spec)
235
+
236
+ if caveats:
237
+ warnings.warn(
238
+ _STRUCTURAL_WARNING_PREFIX + " ".join(caveats),
239
+ UserWarning, stacklevel=2,
240
+ )
241
+ return {"grid": grid_spec, "standalone": standalone}, caveats
242
+
243
+
244
+ def _vega_lite_has_content(result) -> bool:
245
+ """True if ``result`` (the first element of :func:`figure_to_vega_lite`'s
246
+ return) has at least one spec worth a reader's click -- mirrors
247
+ :func:`plotpress.vega._vega_has_content`'s role for the plain-Vega
248
+ export's own docs page.
249
+ """
250
+ return result["grid"] is not None or bool(result["standalone"])
251
+
252
+
253
+ def _is_cleanly_composable(ax) -> bool:
254
+ """True if ``ax`` is a single, plain grid cell -- see the module
255
+ docstring's composition section. ``_subplotspec`` alone is NOT enough:
256
+ ``twinx()``/``twiny()``/``secondary_xaxis()``/``secondary_yaxis()`` all
257
+ *copy* their parent's ``_subplotspec`` verbatim (axes.py's ``twinx``/
258
+ ``twiny``/``secondary_xaxis``/``secondary_yaxis``), so a twin looks like
259
+ an ordinary grid cell by that check alone -- ``_twin_of``/
260
+ ``_secondary_of``/``_inset_parent`` must all be ``None`` too.
261
+ """
262
+ ss = ax._subplotspec
263
+ return (ss is not None and ss.row0 == ss.row1 and ss.col0 == ss.col1
264
+ and ax._twin_of is None and ax._secondary_of is None
265
+ and ax._inset_parent is None and not ax._is_colorbar)
266
+
267
+
268
+ def _build_grid(composable, fig, size_scale, mesh_data=False):
269
+ """One combined spec: an outer ``vconcat`` of rows, each an inner
270
+ ``hconcat`` of that row's cells -- ``composable`` all share one grid
271
+ shape (checked by the caller). A multi-cell span gets an explicit
272
+ pixel ``width``/``height`` covering its cells (Vega-Lite's concat was
273
+ never designed for asymmetric spans -- flagged as a caveat, not treated
274
+ as an error, since the result is still visually proportioned right).
275
+ """
276
+ nrows, ncols = composable[0][1]._subplotspec.nrows, composable[0][1]._subplotspec.ncols
277
+ dpi = fig.style.dpi
278
+ W, H = fig.figsize[0] * dpi, fig.figsize[1] * dpi
279
+ by_cell = {}
280
+ caveats = []
281
+ for i, ax in composable:
282
+ ss = ax._subplotspec
283
+ spec, axcav = _axes_to_vl_spec(ax, size_scale, mesh_data)
284
+ caveats.extend(axcav)
285
+ if spec is None:
286
+ continue
287
+ if ss.row0 != ss.row1 or ss.col0 != ss.col1:
288
+ caveats.append(
289
+ f"axes {i} spans rows {ss.row0}-{ss.row1}/cols {ss.col0}-{ss.col1} -- "
290
+ "Vega-Lite's hconcat/vconcat has no true spanning cell, so "
291
+ f"it was placed once, at its own top-left cell (row {ss.row0}, "
292
+ f"col {ss.col0}); the rest of the cells it would otherwise "
293
+ "also cover are simply left empty, given an explicit pixel "
294
+ "width/height so it's still sized right."
295
+ )
296
+ _, _, w, h = _pixel_rect(ax, W, H)
297
+ spec["width"], spec["height"] = round(float(w), 2), round(float(h), 2)
298
+ # A span is placed ONCE, at its own top-left cell -- Vega-Lite's
299
+ # hconcat/vconcat has no way to merge a cell across several rows/
300
+ # columns the way a real grid-spanning panel does, and inserting
301
+ # the SAME spec dict into every cell it covers (the original,
302
+ # buggy version of this) rendered it duplicated side by side
303
+ # instead of spanning, confirmed by an actual is/is-not identity
304
+ # check on the resulting hconcat list.
305
+ by_cell[(ss.row0, ss.col0)] = spec
306
+ rows = []
307
+ for r in range(nrows):
308
+ row_specs = [by_cell[(r, c)] for c in range(ncols) if (r, c) in by_cell]
309
+ if row_specs:
310
+ rows.append({"hconcat": row_specs} if len(row_specs) > 1 else row_specs[0])
311
+ if not rows:
312
+ return None, caveats
313
+ grid = {"vconcat": rows} if len(rows) > 1 else rows[0]
314
+ grid["$schema"] = _SCHEMA
315
+ return grid, caveats
316
+
317
+
318
+ def _merge_twin(ax, grid_spec, standalone, size_scale, mesh_data=False):
319
+ """Merge a twin axes' marks into its parent's own spec as an extra
320
+ ``layer``, with an independent scale on whichever axis the twin does
321
+ NOT share with its parent (real, documented Vega-Lite grammar for
322
+ exactly this dual-axis case) -- returns True if the parent spec was
323
+ found and merged into, False if not (caller falls back to a
324
+ standalone spec).
325
+ """
326
+ parent = ax._twin_of
327
+ target = _find_spec_for_axes(parent, grid_spec) or _find_spec_for_axes(parent, standalone)
328
+ if target is None:
329
+ return False
330
+ layers, caveats = _artist_layers(ax, size_scale, mesh_data)
331
+ if not layers:
332
+ return True
333
+ # Every spec _axes_to_vl_spec builds already has a top-level "layer"
334
+ # list (that function returns None outright rather than a spec with
335
+ # none) -- ax's own parent spec, found via _find_spec_for_axes, is
336
+ # always one of those, so there's no "not yet layered" case to handle.
337
+ target["layer"].extend(layers)
338
+ # twinx() shares x, wants an independent y; twiny() shares y, wants an
339
+ # independent x (axes.py's _twin_shared: "x" for twinx, "y" for
340
+ # twiny) -- the independent channel is the one NOT shared.
341
+ independent = "y" if ax._twin_shared == "x" else "x"
342
+ target["resolve"] = {"scale": {independent: "independent"}}
343
+ return True
344
+
345
+
346
+ def _find_spec_for_axes(ax, container):
347
+ """Locate the single-view (or already-layered) spec built for ``ax``
348
+ inside ``container`` (one spec, a ``vconcat``/``hconcat`` tree, or a
349
+ list of specs) by the ``name`` every :func:`_axes_to_vl_spec` result
350
+ carries (``f"axes{id(ax):x}"``, matching the ``f"data_{id(art):x}"``
351
+ per-object-identity naming convention :mod:`plotpress.vega` already
352
+ uses) -- the one piece of bookkeeping needed so a twin's merge target
353
+ can be found again after the grid/standalone assembly above.
354
+ """
355
+ if container is None:
356
+ return None
357
+ name = f"axes{id(ax):x}"
358
+ specs = container if isinstance(container, list) else [container]
359
+ stack = list(specs)
360
+ while stack:
361
+ node = stack.pop()
362
+ if not isinstance(node, dict):
363
+ continue
364
+ if node.get("name") == name:
365
+ return node
366
+ stack.extend(node.get("hconcat", []))
367
+ stack.extend(node.get("vconcat", []))
368
+ return None
369
+
370
+
371
+ # ---- one axes -> one Vega-Lite spec ------------------------------------
372
+
373
+ def _axes_to_vl_spec(ax, size_scale, mesh_data=False):
374
+ """One axes' own content as a single-view (or layered) Vega-Lite spec,
375
+ or ``(None, caveats)`` if nothing on it was exportable (mirrors
376
+ :func:`plotpress.vega._vega_has_content`'s role for the Vega sibling).
377
+ """
378
+ layers, caveats = _artist_layers(ax, size_scale, mesh_data)
379
+ if not layers:
380
+ return None, caveats
381
+ fig = ax.figure
382
+ W, H = fig.figsize[0] * fig.style.dpi, fig.figsize[1] * fig.style.dpi
383
+ # _effective_rect, not the raw _pixel_rect -- set_aspect("equal")/
384
+ # set_box_aspect() shrink the actually-used plotting box (centered
385
+ # within the allocated cell), the same adjustment plotpress.vega's own
386
+ # _axes_to_group already applies. Skipping it here left every
387
+ # aspect-locked axes' view sized to its full *unadjusted* cell instead,
388
+ # squashing/stretching everything drawn on it relative to what
389
+ # set_aspect asked for -- confirmed by comparing a curvilinear,
390
+ # aspect="equal" mesh's rendering against plotpress's own output,
391
+ # where the mismatch showed up as the mesh's pattern appearing shifted
392
+ # relative to its axis ticks, not just resized.
393
+ xlim, ylim = ax._resolved_limits()
394
+ _, _, w, h = _effective_rect(ax, *_pixel_rect(ax, W, H), xlim, ylim)
395
+ spec = {
396
+ "name": f"axes{id(ax):x}",
397
+ "width": round(float(w), 2), "height": round(float(h), 2),
398
+ "layer": layers,
399
+ }
400
+ if ax._title:
401
+ spec["title"] = ax._title
402
+ return spec, caveats
403
+
404
+
405
+ def _xy_axis(ax):
406
+ """The ``x``/``y`` scale+axis portion of every layer's own encoding --
407
+ repeated on each layer rather than hoisted to the spec's shared
408
+ top-level ``encoding`` (layers here often carry different field names
409
+ for the same channel, e.g. Bars' precomputed ``x0``/``x1`` vs. Line's
410
+ plain ``x``), since Vega-Lite still shares/unions same-channel scales
411
+ across layers by default regardless of where the scale properties are
412
+ declared -- explicit ``domain`` on each layer keeps every layer showing
413
+ the exact view plotpress itself already resolved, not Vega-Lite's own
414
+ independently-recomputed auto-domain.
415
+ """
416
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
417
+ caveats = []
418
+
419
+ def axis_for(scale, inverted, ticks, ticklabels, label, grid):
420
+ enc = {"type": "quantitative"}
421
+ s = {"type": "log" if scale == "log" else "linear", "zero": False}
422
+ if inverted:
423
+ s["reverse"] = True
424
+ enc["scale"] = s
425
+ a = {"grid": bool(grid), "title": label or None}
426
+ if ticks is not None:
427
+ tvals = [float(t) for t in ticks]
428
+ a["values"] = tvals
429
+ if ticklabels is not None:
430
+ if len(tvals) <= 12:
431
+ labels = list(ticklabels)[:len(tvals)]
432
+ labels += [""] * (len(tvals) - len(labels))
433
+ expr = " : ".join(
434
+ f"datum.value == {t!r} ? {lab!r}" for t, lab in zip(tvals, labels)
435
+ ) + " : ''"
436
+ a["labelExpr"] = expr
437
+ else:
438
+ caveats.append(
439
+ "custom tick labels on an axis with more than 12 ticks "
440
+ "-- Vega-Lite's per-tick labelExpr gets impractically "
441
+ "long past that, so default numeric labels were kept "
442
+ "instead."
443
+ )
444
+ enc["axis"] = None if ax._axis_off else a
445
+ return enc
446
+
447
+ x_enc = axis_for(ax._xscale, ax._xinverted, ax._xticks, ax._xticklabels,
448
+ ax._xlabel, ax._grid)
449
+ x_enc["scale"]["domain"] = [float(xmin), float(xmax)]
450
+ y_enc = axis_for(ax._yscale, ax._yinverted, ax._yticks, ax._yticklabels,
451
+ ax._ylabel, ax._grid)
452
+ y_enc["scale"]["domain"] = [float(ymin), float(ymax)]
453
+ return x_enc, y_enc, caveats
454
+
455
+
456
+ def _artist_layers(ax, size_scale, mesh_data=False):
457
+ layers, caveats = [], []
458
+ draw_order = sorted(enumerate(ax.artists), key=lambda ka: (ka[1].zorder, ka[0]))
459
+ for k, art in draw_order:
460
+ if isinstance(art, ScatterCollection):
461
+ l, c = _scatter_layer(art, ax, size_scale)
462
+ elif isinstance(art, Line2D):
463
+ l, c = _line_layer(art, ax, size_scale)
464
+ elif isinstance(art, Bars):
465
+ l, c = _bars_layer(art, ax)
466
+ elif isinstance(art, ErrorBar):
467
+ l, c = _errorbar_layers(art, ax, size_scale)
468
+ elif isinstance(art, Stem):
469
+ l, c = _stem_layers(art, ax)
470
+ elif isinstance(art, Pie):
471
+ l, c = _pie_layers(art, ax)
472
+ elif isinstance(art, (VLine, HLine, AxLine)):
473
+ l, c = _refline_layer(art, ax)
474
+ elif isinstance(art, Span):
475
+ l, c = _span_layer(art, ax)
476
+ elif isinstance(art, FillBetween):
477
+ l, c = _fillbetween_layer(art, ax)
478
+ elif isinstance(art, LineCollection):
479
+ l, c = _line_collection_layer(art, ax)
480
+ elif isinstance(art, Polygon):
481
+ l, c = _polygon_layer(art, ax)
482
+ elif isinstance(art, Rug):
483
+ l, c = _rug_layer(art, ax)
484
+ elif isinstance(art, (Text, Annotation)):
485
+ l, c = _text_layer(art, ax)
486
+ elif isinstance(art, (QuadMesh, Image)):
487
+ l, c = _mesh_layer(art, ax, mesh_data)
488
+ else:
489
+ warnings.warn(
490
+ f"figure_to_vega_lite(): axes {_axes_index(ax)} has a "
491
+ f"{type(art).__name__} artist with no Vega-Lite mapping yet "
492
+ f"({_UNSUPPORTED_NAMES}) -- skipped, the rest of the figure "
493
+ "still exports.", UserWarning, stacklevel=4,
494
+ )
495
+ l, c = [], []
496
+ layers.extend(l)
497
+ caveats.extend(c)
498
+ if ax._show_legend:
499
+ warnings.warn(
500
+ f"figure_to_vega_lite(): axes {_axes_index(ax)} has a legend, "
501
+ "which to_vega_lite() does not export yet -- skipped.",
502
+ UserWarning, stacklevel=4,
503
+ )
504
+ return layers, caveats
505
+
506
+
507
+ def _axes_index(ax):
508
+ try:
509
+ return ax.figure.axes.index(ax)
510
+ except ValueError:
511
+ return "?"
512
+
513
+
514
+ # ---- Tier 1: native mark, direct field mapping -------------------------
515
+
516
+ def _line_layer(art, ax, size_scale):
517
+ if art.linestyle == "none" and art.marker is None:
518
+ return [], []
519
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
520
+ finite = np.isfinite(x) & np.isfinite(y)
521
+ if not finite.any():
522
+ return [], []
523
+ x_enc, y_enc, caveats = _xy_axis(ax)
524
+ # linestyle="none" with a marker (matplotlib's "markers only" idiom,
525
+ # e.g. plot(x, y, marker="o", linestyle="none")) needs a `point`-only
526
+ # mark, not a `line` mark with a `point: true` sub-mark -- the latter
527
+ # still draws a solid connecting line regardless of dash settings
528
+ # (_dash_array("none") has nothing to suppress it with), confirmed by
529
+ # actually rendering: a marker-only plot came out with a spurious
530
+ # solid line joining every point.
531
+ if art.linestyle == "none":
532
+ values = [{"x": float(xv), "y": float(yv)} for xv, yv in zip(x[finite], y[finite])]
533
+ diam = float(art.markersize or 6.0) * size_scale
534
+ mark = {"type": "point", "filled": True,
535
+ "color": _color(art.markerfacecolor or art.color),
536
+ "size": _symbol_size(diam), "opacity": float(art.alpha)}
537
+ layer = {"data": {"values": values}, "mark": mark,
538
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y")}}
539
+ return [layer], caveats
540
+ # A non-finite point becomes a null field value, not a dropped row --
541
+ # Vega-Lite's own default "invalid data" handling for line/area marks
542
+ # (config.mark.invalid, default "break-paths-filter-domains") breaks
543
+ # the path at a null exactly the way plotpress.vega's `defined` channel
544
+ # does for raw Vega, so no manual gap-splitting is needed here.
545
+ values = [{"idx": i, "x": float(xv) if fv else None, "y": float(yv) if fv else None}
546
+ for i, (xv, yv, fv) in enumerate(zip(x, y, finite))]
547
+ mark = {"type": "line", "color": _color(art.color),
548
+ "strokeWidth": float(art.linewidth), "opacity": float(art.alpha)}
549
+ dash = _dash_array(art.linestyle)
550
+ if dash:
551
+ mark["strokeDash"] = dash
552
+ if art.marker is not None:
553
+ mark["point"] = True
554
+ layer = {"data": {"values": values}, "mark": mark,
555
+ "encoding": {
556
+ "x": dict(x_enc, field="x"), "y": dict(y_enc, field="y"),
557
+ # Vega-Lite's default line-mark point order sorts by the x
558
+ # field -- fine for the overwhelmingly common monotonic-x
559
+ # case, but a parametric/non-monotonic-x line (e.g.
560
+ # ax.plot(sin(t), t)) came out as a zigzag connecting
561
+ # points in x-sorted order instead of data order, confirmed
562
+ # by actually rendering one. `order` pins it back, the same
563
+ # fix already applied to _pie_layers for the analogous
564
+ # stack-order default.
565
+ "order": {"field": "idx", "type": "ordinal"},
566
+ }}
567
+ return [layer], caveats
568
+
569
+
570
+ def _scatter_layer(art, ax, size_scale):
571
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
572
+ finite = np.isfinite(x) & np.isfinite(y)
573
+ if not finite.any():
574
+ return [], []
575
+ fc = art.face_colors()
576
+ colors = fc if fc is not None else [_color(art.color)] * x.size
577
+ s = np.broadcast_to(np.asarray(art.s, float), x.shape) * size_scale
578
+ values = [
579
+ {"x": float(xv), "y": float(yv), "size": _symbol_size(sv), "color": cv}
580
+ for xv, yv, sv, cv, fv in zip(x, y, s, np.asarray(colors, dtype=object), finite)
581
+ if fv
582
+ ]
583
+ x_enc, y_enc, caveats = _xy_axis(ax)
584
+ layer = {
585
+ "data": {"values": values},
586
+ "mark": {"type": "point", "filled": True, "opacity": float(art.alpha)},
587
+ "encoding": {
588
+ "x": dict(x_enc, field="x"), "y": dict(y_enc, field="y"),
589
+ "size": {"field": "size", "type": "quantitative", "legend": None},
590
+ "color": {"field": "color", "type": "nominal", "scale": None,
591
+ "legend": {"title": None} if len(set(colors)) > 1 else None},
592
+ },
593
+ }
594
+ return [layer], caveats
595
+
596
+
597
+ def _bars_layer(art, ax):
598
+ if art.pos.size == 0:
599
+ return [], []
600
+ vals = []
601
+ for pos, length, thick, base, color in zip(art.pos, art.length, art.thickness,
602
+ art.base, art.colors):
603
+ hexc = _color(color)
604
+ if art.orientation == "vertical":
605
+ vals.append({"x0": float(pos - thick / 2), "x1": float(pos + thick / 2),
606
+ "y0": float(base), "y1": float(base + length), "color": hexc})
607
+ else:
608
+ vals.append({"x0": float(base), "x1": float(base + length),
609
+ "y0": float(pos - thick / 2), "y1": float(pos + thick / 2), "color": hexc})
610
+ x_enc, y_enc, caveats = _xy_axis(ax)
611
+ mark = {"type": "bar", "opacity": float(art.alpha)}
612
+ if art.edgecolor:
613
+ mark["stroke"] = _color(art.edgecolor)
614
+ mark["strokeWidth"] = float(art.linewidth)
615
+ # set() on the resolved hex strings in `vals`, not on art.colors
616
+ # directly -- art.colors can be a list of raw per-bar RGB(A) numpy
617
+ # arrays (Bars._as_colors() passes an array of color rows straight
618
+ # through), which are unhashable and crash set().
619
+ layer = {
620
+ "data": {"values": vals}, "mark": mark,
621
+ "encoding": {
622
+ # x2/y2 in Vega-Lite take only field/datum, no scale/axis of
623
+ # their own (they ride the x/y channel's own scale), so only
624
+ # x/y carry the full scale+axis encoding from _xy_axis().
625
+ "x": dict(x_enc, field="x0"), "x2": {"field": "x1"},
626
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"},
627
+ "color": {"field": "color", "type": "nominal", "scale": None,
628
+ "legend": {"title": None} if len({v["color"] for v in vals}) > 1 else None},
629
+ },
630
+ }
631
+ return [layer], caveats
632
+
633
+
634
+ def _errorbar_layers(art, ax, size_scale):
635
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
636
+ finite = np.isfinite(x) & np.isfinite(y)
637
+ if not finite.any():
638
+ return [], []
639
+ x_enc, y_enc, caveats = _xy_axis(ax)
640
+ layers = []
641
+ color = _color(art.color)
642
+ # capsize == 0 means no caps (matches svg.py:1547's own `cap = eb.capsize`
643
+ # convention, where a zero cap length draws a zero-length tick, visually
644
+ # nothing) -- Vega-Lite's `ticks` sub-mark defaults to *off*, and was
645
+ # previously forced on unconditionally regardless of capsize.
646
+ eb_mark = {"type": "errorbar", "color": _color(art.ecolor),
647
+ "opacity": float(art.alpha),
648
+ "rule": {"strokeWidth": float(art.elinewidth)}}
649
+ if art.capsize:
650
+ eb_mark["ticks"] = {"strokeWidth": float(art.capthick)}
651
+ if art.yerr is not None:
652
+ yerr = np.asarray(art.yerr, float)
653
+ vals = [{"x": float(xv), "y": float(yv), "yerr": float(e)}
654
+ for xv, yv, e, fv in zip(x, y, yerr, finite) if fv]
655
+ layers.append({
656
+ "data": {"values": vals}, "mark": dict(eb_mark),
657
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y"),
658
+ "yError": {"field": "yerr", "type": "quantitative"}},
659
+ })
660
+ if art.xerr is not None:
661
+ xerr = np.asarray(art.xerr, float)
662
+ vals = [{"x": float(xv), "y": float(yv), "xerr": float(e)}
663
+ for xv, yv, e, fv in zip(x, y, xerr, finite) if fv]
664
+ layers.append({
665
+ "data": {"values": vals}, "mark": dict(eb_mark),
666
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y"),
667
+ "xError": {"field": "xerr", "type": "quantitative"}},
668
+ })
669
+ if art.linestyle and art.linestyle != "none":
670
+ lvals = [{"x": float(xv) if fv else None, "y": float(yv) if fv else None}
671
+ for xv, yv, fv in zip(x, y, finite)]
672
+ mark = {"type": "line", "color": color, "strokeWidth": float(art.linewidth),
673
+ "opacity": float(art.alpha)}
674
+ dash = _dash_array(art.linestyle)
675
+ if dash:
676
+ mark["strokeDash"] = dash
677
+ layers.append({"data": {"values": lvals}, "mark": mark,
678
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y")}})
679
+ values = [{"x": float(xv), "y": float(yv)} for xv, yv in zip(x[finite], y[finite])]
680
+ layers.append({
681
+ "data": {"values": values},
682
+ "mark": {"type": "point", "filled": True, "color": color,
683
+ "size": _symbol_size(float(art.markersize) * size_scale),
684
+ "opacity": float(art.alpha)},
685
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y")},
686
+ })
687
+ return layers, caveats
688
+
689
+
690
+ def _pie_layers(art, ax):
691
+ # Pie draws in fixed axes-pixel space, independent of any x/y data
692
+ # scale (see plotpress.vega's own _pie_marks) -- Vega-Lite's `arc` mark
693
+ # is the same story: it has no x/y quantitative encoding at all, just
694
+ # theta/radius, so this deliberately does NOT call _xy_axis() the way
695
+ # every other builder does. A pie sharing an axes with an x/y-scaled
696
+ # artist (unusual -- ax.pie() calls set_axis_off(), so it is almost
697
+ # always the axes' only content) would produce a layer with no x/y
698
+ # scale mixed with ones that have one; Vega-Lite handles that by simply
699
+ # not resolving x/y for the arc layer, which is correct here since the
700
+ # arc genuinely has no data-space position to share.
701
+ values = [{"idx": i, "value": float(v), "color": _color(c)}
702
+ for i, (v, c) in enumerate(zip(art.fracs, art.colors))]
703
+ # Explicit center/radius (plotpress.vega's own _pie_marks formula,
704
+ # mirroring svg.py's _render_pie) rather than leaving Vega-Lite's own
705
+ # arc mark to auto-center/auto-size itself -- needed so the label/pct
706
+ # text below can be placed at a center and radius it actually knows,
707
+ # not one it would have to guess at from Vega-Lite's undocumented
708
+ # default arc sizing.
709
+ fig = ax.figure
710
+ W, H = fig.figsize[0] * fig.style.dpi, fig.figsize[1] * fig.style.dpi
711
+ xlim, ylim = ax._resolved_limits()
712
+ _, _, px_w, px_h = _effective_rect(ax, *_pixel_rect(ax, W, H), xlim, ylim)
713
+ cx, cy = px_w / 2.0, px_h / 2.0
714
+ R = 0.42 * min(px_w, px_h) * art.radius
715
+ layers = [{
716
+ "data": {"values": values},
717
+ "mark": {"type": "arc", "opacity": float(art.alpha), "stroke": "#ffffff",
718
+ "strokeWidth": 1.5},
719
+ "encoding": {
720
+ "theta": {"field": "value", "type": "quantitative", "stack": True},
721
+ "color": {"field": "color", "type": "nominal", "scale": None, "legend": None},
722
+ # Vega-Lite's default stack order for a nominal color field
723
+ # sorts BY that field (here, ascending hex string) rather than
724
+ # keeping row/input order -- confirmed by actually rendering:
725
+ # wedge angular sizes came out right but which wedge sat where
726
+ # was scrambled to color order, not data order, whenever the
727
+ # colors weren't already ascending-hex. `order` pins it back
728
+ # to plotpress's own wedge order (clockwise from 12 o'clock,
729
+ # same as svg.py's _render_pie).
730
+ "order": {"field": "idx", "type": "ordinal"},
731
+ # x/y/radius are literal channel VALUES (not mark-level
732
+ # properties -- Vega-Lite has no such shorthand), matching the
733
+ # arc's own explicit cx/cy/R so labels below line up with the
734
+ # actual wedges rather than guessing at Vega-Lite's own
735
+ # undocumented auto-centering/auto-sizing.
736
+ "x": {"value": round(float(cx), 2)}, "y": {"value": round(float(cy), 2)},
737
+ "radius": {"value": round(float(R), 2)},
738
+ },
739
+ }]
740
+ # Wedge label / autopct%% text: Vega-Lite's `arc` mark has no built-in
741
+ # per-wedge label placement (unlike `theta`'s auto-stacking), so the
742
+ # same wedge-midpoint trig walk plotpress.vega's own _pie_marks and
743
+ # svg.py's _render_pie both do is repeated here, in Python, to get a
744
+ # literal pixel x/y per label -- frozen positions via {"value": px}
745
+ # (bypassing any data scale entirely, the same literal-pixel idiom
746
+ # _rug_layer's tick length uses), matching the arc's own explicit
747
+ # cx/cy/R above so labels land on their actual wedges regardless of
748
+ # what Vega-Lite's own auto-sizing would have produced.
749
+ if art.labels is not None or art.autopct is not None:
750
+ ang = math.radians(art.startangle)
751
+ label_rows, pct_rows = [], []
752
+ for frac in art.fracs:
753
+ sweep = frac * 2 * math.pi
754
+ a0, a1 = ang, ang - sweep
755
+ am = (a0 + a1) / 2.0
756
+ ang = a1
757
+ label_rows.append({
758
+ "x": cx + 1.15 * R * math.cos(am), "y": cy - 1.15 * R * math.sin(am),
759
+ "align": "left" if math.cos(am) >= 0 else "right",
760
+ })
761
+ pct_rows.append({"x": cx + 0.6 * R * math.cos(am), "y": cy - 0.6 * R * math.sin(am)})
762
+ if art.labels is not None:
763
+ for row, lbl in zip(label_rows, art.labels):
764
+ layers.append({
765
+ # A layer with only literal {"value": ...} encodings and
766
+ # no "data" of its own has zero rows to instantiate the
767
+ # mark from -- and draws NOTHING, silently -- confirmed
768
+ # empirically (an isolated text-over-arc repro rendered
769
+ # blank until a trivial one-row dataset was added). Every
770
+ # other layer in this file gets its row(s) implicitly
771
+ # from a real per-point/per-cell dataset; this is the
772
+ # one case with no real data at all, so it needs an
773
+ # explicit placeholder row purely to exist.
774
+ "data": {"values": [{}]},
775
+ "mark": {"type": "text", "align": row["align"],
776
+ "baseline": "middle", "fontSize": 10},
777
+ "encoding": {
778
+ "x": {"value": round(float(row["x"]), 2)},
779
+ "y": {"value": round(float(row["y"]), 2)},
780
+ "text": {"value": str(lbl)},
781
+ },
782
+ })
783
+ if art.autopct is not None:
784
+ for row, frac in zip(pct_rows, art.fracs):
785
+ pct = art.pct_text(frac)
786
+ if pct is None:
787
+ continue
788
+ layers.append({
789
+ "data": {"values": [{}]}, # see the label loop above
790
+ "mark": {"type": "text", "align": "center",
791
+ "baseline": "middle", "fontSize": 10},
792
+ "encoding": {
793
+ "x": {"value": round(float(row["x"]), 2)},
794
+ "y": {"value": round(float(row["y"]), 2)},
795
+ "text": {"value": pct},
796
+ },
797
+ })
798
+ return layers, []
799
+
800
+
801
+ def _stem_layers(art, ax):
802
+ x, y = np.asarray(art.x, float), np.asarray(art.y, float)
803
+ finite = np.isfinite(x) & np.isfinite(y)
804
+ if not finite.any():
805
+ return [], []
806
+ x_enc, y_enc, caveats = _xy_axis(ax)
807
+ values = [{"x": float(xv), "y0": art.baseline, "y1": float(yv)}
808
+ for xv, yv in zip(x[finite], y[finite])]
809
+ stems = {
810
+ "data": {"values": values},
811
+ "mark": {"type": "rule", "color": _color(art.linecolor), "strokeWidth": 1.2},
812
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}},
813
+ }
814
+ xlo, xhi = float(x[finite].min()), float(x[finite].max())
815
+ baseline = {
816
+ "data": {"values": [{"x0": xlo, "x1": xhi, "y": art.baseline}]},
817
+ "mark": {"type": "rule", "color": ax.style.spine_color, "strokeWidth": 0.8},
818
+ "encoding": {"x": dict(x_enc, field="x0"), "x2": {"field": "x1"}, "y": dict(y_enc, field="y")},
819
+ }
820
+ tips = {
821
+ "data": {"values": values},
822
+ "mark": {"type": "point", "filled": True, "color": _color(art.markercolor)},
823
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y1")},
824
+ }
825
+ return [stems, baseline, tips], caveats
826
+
827
+
828
+ # ---- Tier 2: layered workarounds ----------------------------------------
829
+
830
+ def _refline_layer(art, ax):
831
+ if art.linestyle == "none":
832
+ return [], []
833
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
834
+ x_enc, y_enc, caveats = _xy_axis(ax)
835
+ mark = {"type": "rule", "color": _color(art.color), "strokeWidth": float(art.linewidth),
836
+ "opacity": float(art.alpha)}
837
+ dash = _dash_array(art.linestyle)
838
+ if dash:
839
+ mark["strokeDash"] = dash
840
+ xmin, xmax, ymin, ymax = float(xmin), float(xmax), float(ymin), float(ymax)
841
+ if isinstance(art, VLine):
842
+ row = {"x": art.x, "y0": ymin, "y1": ymax}
843
+ enc = {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
844
+ elif isinstance(art, HLine):
845
+ row = {"y": art.y, "x0": xmin, "x1": xmax}
846
+ enc = {"y": dict(y_enc, field="y"), "x": dict(x_enc, field="x0"), "x2": {"field": "x1"}}
847
+ else: # AxLine
848
+ if not np.isfinite(art.slope):
849
+ row = {"x": art.x1, "y0": ymin, "y1": ymax}
850
+ enc = {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
851
+ else:
852
+ y0 = float(art.y1 + art.slope * (xmin - art.x1))
853
+ y1 = float(art.y1 + art.slope * (xmax - art.x1))
854
+ row = {"x0": xmin, "x1": xmax, "y0": y0, "y1": y1}
855
+ enc = {"x": dict(x_enc, field="x0"), "x2": {"field": "x1"},
856
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
857
+ return [{"data": {"values": [row]}, "mark": mark, "encoding": enc}], caveats
858
+
859
+
860
+ def _line_collection_layer(art, ax):
861
+ """``LineCollection`` (``hlines()``/``vlines()``, violin inner
862
+ quartile/whisker lines, ``acorr()``/``xcorr()``) as a single ``rule``
863
+ mark over a literal per-segment dataset -- the same pattern
864
+ :func:`_refline_layer`/:func:`_span_layer` already use for one row,
865
+ just N of them (``art.segments`` is already an ``(N, 4)`` array of
866
+ ``x0, y0, x1, y1`` rows, one shared color/width/dash for the batch).
867
+ """
868
+ if art.linestyle == "none" or art.segments.size == 0:
869
+ return [], []
870
+ finite = np.isfinite(art.segments).all(axis=1)
871
+ if not finite.any():
872
+ return [], []
873
+ x_enc, y_enc, caveats = _xy_axis(ax)
874
+ values = [{"x0": float(s[0]), "y0": float(s[1]), "x1": float(s[2]), "y1": float(s[3])}
875
+ for s in art.segments[finite]]
876
+ mark = {"type": "rule", "color": _color(art.color), "strokeWidth": float(art.linewidth),
877
+ "opacity": float(art.alpha)}
878
+ dash = _dash_array(art.linestyle)
879
+ if dash:
880
+ mark["strokeDash"] = dash
881
+ enc = {"x": dict(x_enc, field="x0"), "x2": {"field": "x1"},
882
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
883
+ return [{"data": {"values": values}, "mark": mark, "encoding": enc}], caveats
884
+
885
+
886
+ def _rug_layer(art, ax):
887
+ """``Rug`` (seaborn-style tick marks at each observation) as a ``rule``
888
+ mark, one tick per point -- the tick's SPATIAL position rides the
889
+ shared data scale like every other mark, but its short length is a
890
+ fixed *pixel* fraction of the axes (``art.height``), independent of
891
+ the data range (see ``artists.Rug``'s own docstring) -- expressed the
892
+ same way ``primitives.py``'s own Rug branch does it (anchored in pixel
893
+ space), via a literal, unscaled ``{"value": px}`` on the channel that
894
+ carries the tick's length instead of the data scale.
895
+ """
896
+ if art.x.size == 0:
897
+ return [], []
898
+ finite = np.isfinite(art.x)
899
+ if not finite.any():
900
+ return [], []
901
+ fig = ax.figure
902
+ W, H = fig.figsize[0] * fig.style.dpi, fig.figsize[1] * fig.style.dpi
903
+ xlim, ylim = ax._resolved_limits()
904
+ _, _, px_w, px_h = _effective_rect(ax, *_pixel_rect(ax, W, H), xlim, ylim)
905
+ x_enc, y_enc, caveats = _xy_axis(ax)
906
+ color = _color(art.color, "#333333")
907
+ mark = {"type": "rule", "color": color, "strokeWidth": float(art.linewidth),
908
+ "opacity": float(art.alpha)}
909
+ values = [{"pos": float(v)} for v in art.x[finite]]
910
+ if art.side == "left":
911
+ enc = {"y": dict(y_enc, field="pos"),
912
+ "x": {"value": 0}, "x2": {"value": art.height * px_w}}
913
+ else:
914
+ enc = {"x": dict(x_enc, field="pos"),
915
+ "y": {"value": px_h}, "y2": {"value": px_h - art.height * px_h}}
916
+ return [{"data": {"values": values}, "mark": mark, "encoding": enc}], caveats
917
+
918
+
919
+ def _polygon_layer(art, ax):
920
+ """``Polygon`` (``fill()``, and critically ``fill_betweenx()`` -- the
921
+ direct sibling of the already-supported ``fill_between()``, built the
922
+ same way internally: see ``axes.py``'s ``fill_betweenx``) as a real
923
+ Vega-Lite ``area`` mark, when the polygon boundary is actually the
924
+ "monotonic two-boundary strip" shape both ``fill_between``-style calls
925
+ produce (go forward along one boundary, then back along the other) --
926
+ a general closed polygon (a filled circle, a hexbin cell, an arbitrary
927
+ ``fill()`` shape) has no such structure and no Vega-Lite closed-vocabulary
928
+ mark to fall back to, so it warns instead, the same "degrade a part, not
929
+ silently misrepresent it" policy :func:`_fillbetween_layer` already
930
+ applies to a non-monotonic `fill_between`.
931
+ """
932
+ caveat = (
933
+ "a fill()/fill_betweenx() polygon that isn't a simple two-boundary "
934
+ "strip (go forward along one edge, back along the other -- what "
935
+ "fill_between()/fill_betweenx() themselves always build) has no "
936
+ "Vega-Lite mapping -- skipped."
937
+ )
938
+ x, y = art.x, art.y
939
+ n = x.size
940
+ if n < 4 or n % 2 != 0:
941
+ return [], [caveat]
942
+ half = n // 2
943
+ # fill_between()/fill_betweenx() both close the ring as
944
+ # [forward boundary, reversed(other boundary)] -- detect that shape on
945
+ # EITHER axis (y constant-per-half-pair for a vertical strip like
946
+ # fill_between, x constant-per-half-pair for a horizontal one like
947
+ # fill_betweenx) rather than assuming which one built it.
948
+ if np.allclose(y[:half], y[half:][::-1], equal_nan=True):
949
+ # Horizontal strip (fill_betweenx-shaped): y is the shared axis,
950
+ # x0/x1 are the two boundaries.
951
+ yv, x0, x1 = y[:half], x[:half], x[half:][::-1]
952
+ finite = np.isfinite(yv) & np.isfinite(x0) & np.isfinite(x1)
953
+ if not finite.any():
954
+ return [], []
955
+ x_enc, y_enc, caveats = _xy_axis(ax)
956
+ values = [{"y": float(yy), "x0": float(a), "x1": float(b)}
957
+ for yy, a, b in zip(yv[finite], x0[finite], x1[finite])]
958
+ enc = {"y": dict(y_enc, field="y"),
959
+ "x": dict(x_enc, field="x0"), "x2": {"field": "x1"}}
960
+ elif np.allclose(x[:half], x[half:][::-1], equal_nan=True):
961
+ # Vertical strip (fill_between-shaped): x is the shared axis,
962
+ # y0/y1 are the two boundaries.
963
+ xv, y0, y1 = x[:half], y[:half], y[half:][::-1]
964
+ finite = np.isfinite(xv) & np.isfinite(y0) & np.isfinite(y1)
965
+ if not finite.any():
966
+ return [], []
967
+ x_enc, y_enc, caveats = _xy_axis(ax)
968
+ values = [{"x": float(xx), "y0": float(a), "y1": float(b)}
969
+ for xx, a, b in zip(xv[finite], y0[finite], y1[finite])]
970
+ enc = {"x": dict(x_enc, field="x"),
971
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
972
+ else:
973
+ return [], [caveat]
974
+ mark = {"type": "area", "color": _color(art.color), "opacity": float(art.alpha)}
975
+ if art.edgecolor:
976
+ mark["stroke"] = _color(art.edgecolor)
977
+ mark["strokeWidth"] = float(art.linewidth)
978
+ return [{"data": {"values": values}, "mark": mark, "encoding": enc}], caveats
979
+
980
+
981
+ def _span_layer(art, ax):
982
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
983
+ xmin, xmax, ymin, ymax = float(xmin), float(xmax), float(ymin), float(ymax)
984
+ x_enc, y_enc, caveats = _xy_axis(ax)
985
+ if art.orientation == "vertical":
986
+ row = {"x0": art.lo, "x1": art.hi, "y0": ymin, "y1": ymax}
987
+ else:
988
+ row = {"y0": art.lo, "y1": art.hi, "x0": xmin, "x1": xmax}
989
+ enc = {"x": dict(x_enc, field="x0"), "x2": {"field": "x1"},
990
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"}}
991
+ mark = {"type": "rect", "color": _color(art.color), "opacity": float(art.alpha)}
992
+ return [{"data": {"values": [row]}, "mark": mark, "encoding": enc}], caveats
993
+
994
+
995
+ def _fillbetween_layer(art, ax):
996
+ x = np.asarray(art.x, float)
997
+ finite = np.isfinite(x) & np.isfinite(art.y1) & np.isfinite(art.y2)
998
+ if not finite.any():
999
+ return [], []
1000
+ # Monotonicity is checked on the finite points only -- a NaN anywhere
1001
+ # makes every np.diff() comparison False regardless of the real x
1002
+ # order (NaN comparisons are always False), which mislabeled an
1003
+ # all-NaN series as "non-monotonic" instead of "nothing to draw".
1004
+ xf = x[finite]
1005
+ d = np.diff(xf)
1006
+ if xf.size > 1 and not (np.all(d >= 0) or np.all(d <= 0)):
1007
+ return [], [
1008
+ "a fill_between()/fill_betweenx() with non-monotonic x has no "
1009
+ "Vega-Lite mapping (its `area` mark needs a monotonic axis) -- "
1010
+ "skipped."
1011
+ ]
1012
+ x_enc, y_enc, caveats = _xy_axis(ax)
1013
+ values = [{"x": float(xv), "y1": float(y1v), "y2": float(y2v)}
1014
+ for xv, y1v, y2v in zip(x, art.y1, art.y2)
1015
+ if np.isfinite(xv) and np.isfinite(y1v) and np.isfinite(y2v)]
1016
+ if not values:
1017
+ return [], caveats
1018
+ mark = {"type": "area", "color": _color(art.color), "opacity": float(art.alpha)}
1019
+ if art.edgecolor:
1020
+ mark["stroke"] = _color(art.edgecolor)
1021
+ mark["strokeWidth"] = float(art.linewidth)
1022
+ layer = {"data": {"values": values}, "mark": mark,
1023
+ "encoding": {"x": dict(x_enc, field="x"),
1024
+ "y": dict(y_enc, field="y1"), "y2": {"field": "y2"}}}
1025
+ return [layer], caveats
1026
+
1027
+
1028
+ def _mesh_data_layer(art, ax):
1029
+ """Real per-cell ``rect`` marks with a field+scale color encoding for a
1030
+ QuadMesh eligible for ``mesh_data=True`` (see
1031
+ :func:`plotpress.vega._mesh_data_reason`) -- the Vega-Lite twin of
1032
+ :func:`plotpress.vega._mesh_data_marks`, reusing the same
1033
+ :func:`~plotpress.vega._mesh_cell_rows`/:func:`~plotpress.vega._mesh_scheme`
1034
+ helpers rather than re-deriving per-cell geometry or scheme-mapping
1035
+ logic a second time.
1036
+ """
1037
+ rows = _mesh_cell_rows(art)
1038
+ if not rows:
1039
+ return [], []
1040
+ x_enc, y_enc, caveats = _xy_axis(ax)
1041
+ scheme, reverse = _mesh_scheme(art.cmap_name)
1042
+ color_enc = {
1043
+ "field": "value", "type": "quantitative",
1044
+ "scale": {"scheme": scheme,
1045
+ "domain": [float(art.norm.vmin), float(art.norm.vmax)],
1046
+ "reverse": reverse},
1047
+ "legend": None,
1048
+ }
1049
+ layer = {
1050
+ "data": {"values": rows},
1051
+ "mark": {"type": "rect", "opacity": float(art.alpha)},
1052
+ "encoding": {
1053
+ "x": dict(x_enc, field="x0"), "x2": {"field": "x1"},
1054
+ "y": dict(y_enc, field="y0"), "y2": {"field": "y1"},
1055
+ "color": color_enc,
1056
+ },
1057
+ }
1058
+ return [layer], caveats
1059
+
1060
+
1061
+ def _mesh_layer(art, ax, mesh_data=False):
1062
+ """``QuadMesh``/``Image`` (``pcolormesh``/``imshow``) as a Vega-Lite
1063
+ ``image`` mark -- the one mark in VL's vocabulary built for exactly
1064
+ this (a URL + a data-space extent), so this reuses the same rasterized
1065
+ RGBA + data extent every other backend already computes
1066
+ (``art.rgba()``/``art.extent()``, the same pair ``artist_to_prims``'s
1067
+ own ``(QuadMesh, Image)`` branch reads) rather than re-deriving
1068
+ anything.
1069
+
1070
+ The image is placed via *data-space* x/x2/y/y2, through the same scale
1071
+ (inverted/log-aware) every other mark on this axes shares -- but unlike
1072
+ a point/line/bar mark, whose geometry is computed FROM the data at
1073
+ render time, an image mark's raster content is a fixed bitmap with its
1074
+ own baked-in "row 0 at the top of its own box" orientation; reversing
1075
+ the y-scale moves *where* the box sits, not which row of the bitmap
1076
+ ends up at which edge of it. Confirmed by actually rendering an
1077
+ inverted-y-axis mesh through vega-lite/vega: without the manual flip
1078
+ below, the raster came out upside-down relative to plotpress's own
1079
+ render, even though the box itself was correctly repositioned.
1080
+
1081
+ ``mesh_data=True`` opts into :func:`_mesh_data_layer`'s real per-cell
1082
+ ``rect`` marks instead, for meshes small/simple enough to stay
1083
+ unambiguous -- everything else (including a plain ``Image``, which has
1084
+ no per-cell colormap to encode) still gets this rasterized path, with
1085
+ a ``UserWarning`` naming why when ``mesh_data=True`` was requested but
1086
+ couldn't be honored.
1087
+ """
1088
+ reason = _mesh_data_reason(art, mesh_data, ax)
1089
+ if reason is None:
1090
+ return _mesh_data_layer(art, ax)
1091
+ if mesh_data:
1092
+ warnings.warn(
1093
+ f"figure_to_vega_lite(): axes {_axes_index(ax)} requested "
1094
+ f"mesh_data=True, but this mesh has {reason} -- falling back "
1095
+ "to a rasterized image mark for it instead.",
1096
+ UserWarning, stacklevel=4,
1097
+ )
1098
+ xmin, xmax, ymin, ymax = art.extent()
1099
+ if not all(np.isfinite(v) for v in (xmin, xmax, ymin, ymax)):
1100
+ return [], []
1101
+ rgba = art.rgba()
1102
+ if ax._yinverted:
1103
+ rgba = rgba[::-1, :]
1104
+ if ax._xinverted:
1105
+ rgba = rgba[:, ::-1]
1106
+ rgba = np.ascontiguousarray(rgba)
1107
+ url = png_data_uri(rgba.astype(np.uint8) if rgba.dtype != np.uint8 else rgba)
1108
+ x_enc, y_enc, caveats = _xy_axis(ax)
1109
+ row = {"x": float(xmin), "x2": float(xmax), "y": float(ymin), "y2": float(ymax), "url": url}
1110
+ layer = {
1111
+ "data": {"values": [row]},
1112
+ # aspect: False -- Vega-Lite's image mark defaults to *preserving*
1113
+ # the raster's own native pixel aspect ratio (here, the mesh's
1114
+ # row/col resolution) inside the x/x2/y/y2 box instead of
1115
+ # stretching to fill it exactly, which every other backend does.
1116
+ # Confirmed by actually rendering: without this, a non-square mesh
1117
+ # (e.g. a wide axes panel with square data limits) came out as a
1118
+ # small square image left-aligned in its box, with blank space
1119
+ # filling the rest -- not visible from the JSON structure alone.
1120
+ "mark": {"type": "image", "aspect": False,
1121
+ "smooth": getattr(art, "interpolation", "nearest") != "nearest"},
1122
+ "encoding": {
1123
+ "x": dict(x_enc, field="x"), "x2": {"field": "x2"},
1124
+ "y": dict(y_enc, field="y"), "y2": {"field": "y2"},
1125
+ "url": {"field": "url", "type": "nominal"},
1126
+ },
1127
+ }
1128
+ return [layer], caveats
1129
+
1130
+
1131
+ # plotpress va -> Vega-Lite text-mark baseline -- the same mapping
1132
+ # plotpress.vega's own _VEGA_VA uses (Vega-Lite marks compile to real Vega
1133
+ # marks, so the baseline vocabulary is identical).
1134
+ _VL_VA = {"baseline": "alphabetic", "bottom": "bottom", "center": "middle", "top": "top"}
1135
+
1136
+
1137
+ def _text_layer(art, ax):
1138
+ # bbox= (a background box behind the label) is out of scope for now --
1139
+ # it needs the label's own pixel-space bounding box (text_box() in
1140
+ # svg.py), which conflicts with this module's data-scale-encoded text
1141
+ # position (Vega-Lite's x/y here go through the same quantitative scale
1142
+ # every other mark on this axes uses, not a raw pixel value) without a
1143
+ # separate pixel->data inverse mapping this module doesn't build. The
1144
+ # label itself still exports; only its background box is dropped.
1145
+ if isinstance(art, Annotation):
1146
+ x, y = art.xytext if art.xytext is not None else art.xy
1147
+ else:
1148
+ x, y = art.x, art.y
1149
+ caveats = []
1150
+ if art.axes_fraction:
1151
+ # Vega-Lite has no per-mark axes-fraction escape hatch the way
1152
+ # svg.py/plotpress.vega's _axes_fraction_xy gives a fixed pixel
1153
+ # point regardless of the data scale -- approximated here by
1154
+ # converting the fraction to a literal *data-space* x/y at the
1155
+ # axes' own current resolved limits, which stays correct only
1156
+ # until the domain changes (e.g. via a Vega-Lite selection/zoom).
1157
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
1158
+ x = xmin + x * (xmax - xmin)
1159
+ y = ymin + y * (ymax - ymin)
1160
+ caveats.append(
1161
+ "an axes-fraction-positioned Text/Annotation was placed at its "
1162
+ "current data-space equivalent -- unlike plotpress's own "
1163
+ "renderers, it will not stay fixed to the view if the chart is "
1164
+ "panned/zoomed in the Vega-Lite runtime."
1165
+ )
1166
+ size = getattr(art, "size", None)
1167
+ size = 11.0 if size is None else float(size)
1168
+ ha = getattr(art, "ha", "left")
1169
+ align = ha if ha in ("left", "right", "center") else "left"
1170
+ va = getattr(art, "va", "baseline")
1171
+ x_enc, y_enc, axcav = _xy_axis(ax)
1172
+ caveats.extend(axcav)
1173
+ mark = {"type": "text", "text": art.text, "align": align,
1174
+ "baseline": _VL_VA.get(va, "alphabetic"), "fontSize": size,
1175
+ "color": _color(getattr(art, "color", None), "#000000")}
1176
+ rotation = float(getattr(art, "rotation", 0.0) or 0.0)
1177
+ if rotation:
1178
+ # plotpress's own rotation is counterclockwise-positive
1179
+ # (matplotlib convention); Vega(-Lite)'s `angle` is
1180
+ # clockwise-positive, same negation plotpress.vega's _text_marks
1181
+ # applies for the same reason.
1182
+ mark["angle"] = -rotation
1183
+ if getattr(art, "bold", False):
1184
+ mark["fontWeight"] = "bold"
1185
+ if getattr(art, "italic", False):
1186
+ mark["fontStyle"] = "italic"
1187
+ alpha = float(getattr(art, "alpha", 1.0))
1188
+ if alpha < 1:
1189
+ mark["opacity"] = alpha
1190
+ layers = [{"data": {"values": [{"x": float(x), "y": float(y)}]}, "mark": mark,
1191
+ "encoding": {"x": dict(x_enc, field="x"), "y": dict(y_enc, field="y")}}]
1192
+ if isinstance(art, Annotation) and art.arrowprops is not None:
1193
+ warnings.warn(
1194
+ f"figure_to_vega_lite(): axes {_axes_index(ax)} has an "
1195
+ "Annotation with an arrow -- Vega-Lite has no arrow-drawing "
1196
+ "mark, so the arrow was dropped (the text label itself still "
1197
+ "exports).", UserWarning, stacklevel=4,
1198
+ )
1199
+ return layers, caveats