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/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
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
|