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