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/png.py ADDED
@@ -0,0 +1,93 @@
1
+ """Minimal PNG encoder built only on the standard library (``zlib``).
2
+
3
+ Used to rasterize ``pcolormesh`` / image layers into a single ``<image>``
4
+ element embedded in the SVG as a base64 data URI. PNG's container format is
5
+ simple enough that no third-party dependency is needed; the heavy lifting is a
6
+ single vectorized ``zlib.compress`` call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import struct
13
+ import zlib
14
+
15
+ import numpy as np
16
+
17
+
18
+ def _chunk(tag: bytes, data: bytes) -> bytes:
19
+ out = struct.pack(">I", len(data)) + tag + data
20
+ crc = zlib.crc32(tag + data) & 0xFFFFFFFF
21
+ return out + struct.pack(">I", crc)
22
+
23
+
24
+ _SIG = b"\x89PNG\r\n\x1a\n"
25
+
26
+
27
+ def encode_png(rgba: np.ndarray) -> bytes:
28
+ """Encode an ``(H, W, 3|4)`` uint8 array as 8-bit PNG bytes.
29
+
30
+ Colormapped output is emitted as **indexed** colour when it fits in 256
31
+ entries, which every mesh does: the colours come from a 256-entry colormap
32
+ LUT, so a field of any size still draws from at most 256 distinct RGBA
33
+ values. That stores one byte per pixel plus a small palette instead of four
34
+ bytes per pixel, and the whole raster travels inside the interactive HTML,
35
+ so the saving lands directly on the file a reader downloads. Anything with
36
+ more colours -- a Gouraud mesh interpolates between nodes, so it does --
37
+ falls back to RGBA.
38
+ """
39
+ arr = np.ascontiguousarray(rgba)
40
+ if arr.dtype != np.uint8:
41
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
42
+ h, w = arr.shape[:2]
43
+ if arr.shape[2] == 3:
44
+ arr = np.concatenate([arr, np.full((h, w, 1), 255, np.uint8)], axis=2)
45
+
46
+ indexed = _encode_indexed(arr, h, w)
47
+ return indexed if indexed is not None else _encode_rgba(arr, h, w)
48
+
49
+
50
+ def _encode_rgba(arr: np.ndarray, h: int, w: int) -> bytes:
51
+ # Prepend a per-scanline filter byte (0 = None).
52
+ raw = np.empty((h, 1 + w * 4), dtype=np.uint8)
53
+ raw[:, 0] = 0
54
+ raw[:, 1:] = arr.reshape(h, w * 4)
55
+ ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) # 8-bit RGBA
56
+ return (_SIG + _chunk(b"IHDR", ihdr)
57
+ + _chunk(b"IDAT", zlib.compress(raw.tobytes(), level=6))
58
+ + _chunk(b"IEND", b""))
59
+
60
+
61
+ def _encode_indexed(arr: np.ndarray, h: int, w: int):
62
+ """Indexed-colour PNG, or ``None`` if the image needs more than 256 entries."""
63
+ flat = arr.reshape(-1, 4)
64
+ # One uint32 per pixel makes the unique/inverse pass cheap; the byte order
65
+ # only has to be self-consistent, since the palette is rebuilt from it.
66
+ keys = flat.view(np.uint32).reshape(-1) if flat.flags["C_CONTIGUOUS"] \
67
+ else np.ascontiguousarray(flat).view(np.uint32).reshape(-1)
68
+ palette_keys, index = np.unique(keys, return_inverse=True)
69
+ if palette_keys.size > 256:
70
+ return None
71
+
72
+ palette = palette_keys.view(np.uint8).reshape(-1, 4)
73
+ raw = np.empty((h, 1 + w), dtype=np.uint8)
74
+ raw[:, 0] = 0
75
+ raw[:, 1:] = index.astype(np.uint8).reshape(h, w)
76
+
77
+ chunks = [_chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 3, 0, 0, 0)),
78
+ _chunk(b"PLTE", palette[:, :3].tobytes())]
79
+ alpha = palette[:, 3]
80
+ if np.any(alpha != 255):
81
+ # tRNS for indexed PNG is one alpha byte per palette entry; trailing
82
+ # opaque entries may be omitted, so stop after the last transparent one.
83
+ last = int(np.nonzero(alpha != 255)[0].max())
84
+ chunks.append(_chunk(b"tRNS", alpha[:last + 1].tobytes()))
85
+ chunks.append(_chunk(b"IDAT", zlib.compress(raw.tobytes(), level=6)))
86
+ chunks.append(_chunk(b"IEND", b""))
87
+ return _SIG + b"".join(chunks)
88
+
89
+
90
+ def png_data_uri(rgba: np.ndarray) -> str:
91
+ """Return a ``data:image/png;base64,...`` URI for the given RGBA array."""
92
+ b64 = base64.b64encode(encode_png(rgba)).decode("ascii")
93
+ return "data:image/png;base64," + b64
plotpress/polar.py ADDED
@@ -0,0 +1,240 @@
1
+ """Polar axes: (theta, r) plotting on top of the Cartesian core.
2
+
3
+ A :class:`PolarAxes` is an ordinary :class:`~plotpress.axes.Axes` running with
4
+ equal aspect and the rectangular frame turned off. It projects ``(theta, r)``
5
+ data to ``(x, y) = (r cos theta, r sin theta)`` before handing it to the normal
6
+ plotting methods, and it builds its own polar frame -- radial grid circles,
7
+ angular spokes, and tick labels -- entirely out of existing ``plot``/``text``
8
+ artists. Nothing in the SVG or raster renderer needs to know polar exists, in
9
+ the same spirit as ``violinplot``'s inner marks.
10
+
11
+ Supported so far: ``plot``, ``scatter``, ``fill`` (the plot types that make
12
+ sense on a polar grid), plus ``set_rmax``/``set_rlim``/``set_rticks``,
13
+ ``set_thetagrids``, ``set_theta_direction`` and ``set_theta_zero_location``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from .axes import Axes
21
+ from .ticker import nice_ticks
22
+
23
+ _ZERO_LOC = {"E": 0.0, "N": np.pi / 2, "W": np.pi, "S": -np.pi / 2}
24
+
25
+
26
+ def _fmt_r(v):
27
+ return ("%g" % v)
28
+
29
+
30
+ class PolarAxes(Axes):
31
+ def __init__(self, figure, rect):
32
+ super().__init__(figure, rect)
33
+ self._is_polar = True
34
+ self.set_aspect("equal")
35
+ self.set_axis_off()
36
+ self._theta_direction = 1 # +1 counter-clockwise (matplotlib default)
37
+ self._theta_offset = 0.0 # radians; where theta=0 points
38
+ self._rmax = None # None -> autoscale from data
39
+ self._rmin = 0.0
40
+ self._rticks = None # None -> "nice" ticks
41
+ self._thetagrids = None # None -> 8 evenly spaced spokes
42
+ self._rdata = [] # r arrays seen, for autoscale
43
+ self._frame_artists = [] # rebuilt whenever data or limits change
44
+
45
+ # -- projection ---------------------------------------------------------
46
+ def _project(self, theta, r):
47
+ theta = np.asarray(theta, float) * self._theta_direction + self._theta_offset
48
+ r = np.asarray(r, float)
49
+ return r * np.cos(theta), r * np.sin(theta)
50
+
51
+ def _track_r(self, r):
52
+ r = np.asarray(r, float)
53
+ if r.size:
54
+ self._rdata.append(r)
55
+
56
+ def _auto_rmax(self):
57
+ vals = [np.nanmax(np.abs(r)) for r in self._rdata
58
+ if r.size and np.isfinite(r).any()]
59
+ return max(vals) if vals else None
60
+
61
+ # -- plotting (project, then delegate to the Cartesian core) ------------
62
+ def plot(self, theta, r, **kwargs):
63
+ """Plot ``r`` versus ``theta`` (radians) as a polar line."""
64
+ theta = np.asarray(theta, dtype=float)
65
+ r = np.asarray(r, dtype=float)
66
+ x, y = self._project(theta, r)
67
+ self._track_r(r)
68
+ # Carried as pick_values so point-picking reports the polar
69
+ # coordinates the caller actually plotted, not the Cartesian (x, y)
70
+ # they got projected to for drawing -- which is what the interactive
71
+ # HTML reports for anything else built on the Cartesian core, and
72
+ # meaningless read back on a polar chart.
73
+ values = {"theta": theta, "r": r}
74
+ values.update(kwargs.pop("values", None) or {})
75
+ line = super().plot(x, y, values=values, **kwargs)
76
+ self._rebuild_frame()
77
+ return line
78
+
79
+ def scatter(self, theta, r, **kwargs):
80
+ """Scatter ``r`` versus ``theta`` (radians)."""
81
+ theta = np.asarray(theta, dtype=float)
82
+ r = np.asarray(r, dtype=float)
83
+ x, y = self._project(theta, r)
84
+ self._track_r(r)
85
+ values = {"theta": theta, "r": r}
86
+ values.update(kwargs.pop("values", None) or {})
87
+ coll = super().scatter(x, y, values=values, **kwargs)
88
+ self._rebuild_frame()
89
+ return coll
90
+
91
+ def fill(self, theta, r, **kwargs):
92
+ """Fill the polygon traced by ``(theta, r)``."""
93
+ x, y = self._project(theta, r)
94
+ self._track_r(r)
95
+ poly = super().fill(x, y, **kwargs)
96
+ self._rebuild_frame()
97
+ return poly
98
+
99
+ def set_xscale(self, scale):
100
+ """Only ``'linear'`` -- the polar projection has no log-axis support."""
101
+ if scale != "linear":
102
+ raise NotImplementedError(
103
+ "PolarAxes does not support non-linear scales (got %r)" % scale)
104
+ self._xscale = scale
105
+
106
+ def set_yscale(self, scale):
107
+ """Only ``'linear'`` -- the polar projection has no log-axis support."""
108
+ if scale != "linear":
109
+ raise NotImplementedError(
110
+ "PolarAxes does not support non-linear scales (got %r)" % scale)
111
+ self._yscale = scale
112
+
113
+ # -- polar limits / grid API -------------------------------------------
114
+ def set_rmax(self, rmax):
115
+ self._rmax = float(rmax)
116
+ self._rebuild_frame()
117
+ return self
118
+
119
+ def set_rlim(self, rmin=None, rmax=None):
120
+ if rmin is not None:
121
+ self._rmin = float(rmin)
122
+ if rmax is not None:
123
+ self._rmax = float(rmax)
124
+ self._rebuild_frame()
125
+ return self
126
+
127
+ def set_rticks(self, ticks):
128
+ self._rticks = None if ticks is None else [float(t) for t in ticks]
129
+ self._rebuild_frame()
130
+ return self
131
+
132
+ def set_thetagrids(self, angles):
133
+ """Place angular gridlines at ``angles`` (degrees), or an int count."""
134
+ if angles is None or np.ndim(angles) == 0:
135
+ self._thetagrids = None if angles is None else int(angles)
136
+ else:
137
+ self._thetagrids = [float(a) for a in angles]
138
+ self._rebuild_frame()
139
+ return self
140
+
141
+ def set_theta_direction(self, direction):
142
+ """``+1``/``'counterclockwise'`` or ``-1``/``'clockwise'``."""
143
+ if direction in (-1, "clockwise", "cw"):
144
+ self._theta_direction = -1
145
+ else:
146
+ self._theta_direction = 1
147
+ self._reproject()
148
+ return self
149
+
150
+ def set_theta_zero_location(self, loc):
151
+ """Point ``theta=0`` at compass location ``'N'``/``'E'``/``'S'``/``'W'``."""
152
+ self._theta_offset = _ZERO_LOC[loc]
153
+ self._reproject()
154
+ return self
155
+
156
+ def set_theta_offset(self, offset):
157
+ """Set the angle (radians) at which ``theta=0`` is drawn."""
158
+ self._theta_offset = float(offset)
159
+ self._reproject()
160
+ return self
161
+
162
+ # -- frame construction -------------------------------------------------
163
+ def _reproject(self):
164
+ # Changing orientation after data exists would need re-projecting every
165
+ # artist; keep it simple and supported by requiring orientation to be set
166
+ # before plotting.
167
+ if self._rdata:
168
+ raise RuntimeError(
169
+ "set orientation (theta direction/zero location/offset) before "
170
+ "plotting into a polar axes"
171
+ )
172
+ self._rebuild_frame()
173
+
174
+ def _theta_positions(self):
175
+ if isinstance(self._thetagrids, list):
176
+ return np.radians(self._thetagrids)
177
+ n = self._thetagrids if isinstance(self._thetagrids, int) else 8
178
+ return np.arange(n) * (2 * np.pi / n)
179
+
180
+ def _frame_add(self, artist):
181
+ self._frame_artists.append(artist)
182
+ return artist
183
+
184
+ def _rebuild_frame(self):
185
+ """Recompute the polar frame from current data/limit state.
186
+
187
+ Frame artists are stripped and rebuilt each call, then moved ahead of the
188
+ data artists so the grid sits behind the plot (matplotlib z-order).
189
+ """
190
+ frame_ids = set(map(id, self._frame_artists))
191
+ self.artists = [a for a in self.artists if id(a) not in frame_ids]
192
+ self._frame_artists = []
193
+
194
+ rmax = self._rmax if self._rmax is not None else self._auto_rmax()
195
+ if not rmax or not np.isfinite(rmax) or rmax <= 0:
196
+ return
197
+
198
+ ticks = (self._rticks if self._rticks is not None
199
+ else nice_ticks(0.0, rmax))
200
+ ticks = [t for t in ticks if 0 < t <= rmax * 1.0001]
201
+
202
+ ang = np.linspace(0.0, 2 * np.pi, 120)
203
+ cos_a, sin_a = np.cos(ang), np.sin(ang)
204
+ grid_c, edge_c, txt_c = "#d0d0d0", "#a0a0a0", "#555555"
205
+
206
+ # radial grid circles + outer boundary
207
+ for t in ticks:
208
+ self._frame_add(Axes.plot(self, t * cos_a, t * sin_a,
209
+ color=grid_c, linewidth=0.8))
210
+ self._frame_add(Axes.plot(self, rmax * cos_a, rmax * sin_a,
211
+ color=edge_c, linewidth=1.0))
212
+
213
+ # angular spokes + degree labels
214
+ size = self.style.tick_label_size
215
+ for th in self._theta_positions():
216
+ a = th * self._theta_direction + self._theta_offset
217
+ ca, sa = np.cos(a), np.sin(a)
218
+ self._frame_add(Axes.plot(self, [0.0, rmax * ca], [0.0, rmax * sa],
219
+ color=grid_c, linewidth=0.8))
220
+ deg = int(round(np.degrees(th))) % 360
221
+ self._frame_add(self.text(rmax * 1.12 * ca, rmax * 1.12 * sa,
222
+ f"{deg}°", ha="center", va="center",
223
+ fontsize=size, color=txt_c))
224
+
225
+ # radial tick labels, along a lightly off-axis spoke so they clear it
226
+ la = 0.12 + self._theta_offset
227
+ for t in ticks:
228
+ self._frame_add(self.text(t * np.cos(la), t * np.sin(la), _fmt_r(t),
229
+ ha="center", va="center", fontsize=size,
230
+ color=txt_c))
231
+
232
+ # move the frame behind the data, and fix an equal, symmetric view
233
+ fid = set(map(id, self._frame_artists))
234
+ data = [a for a in self.artists if id(a) not in fid]
235
+ frame = [a for a in self.artists if id(a) in fid]
236
+ self.artists = frame + data
237
+
238
+ pad = rmax * 1.25
239
+ Axes.set_xlim(self, -pad, pad)
240
+ Axes.set_ylim(self, -pad, pad)
@@ -0,0 +1,335 @@
1
+ """Backend-agnostic drawing primitives in **pixel space**.
2
+
3
+ The geometry of an artist (applying the transform, splitting on NaN, decimating
4
+ huge lines, computing quad corners) is computed *once* here, producing a small
5
+ fixed vocabulary of primitives. Each backend (:mod:`plotpress.svg`,
6
+ :mod:`plotpress.raster`) then only needs to know how to draw those few
7
+ primitive types -- so a new artist that emits, say, a filled polygon needs no
8
+ new code in either backend.
9
+
10
+ ``artist_to_prims(artist, tr, ai, k)`` returns the primitives for a migrated
11
+ artist, or ``None`` if the artist still uses its legacy per-backend renderer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import List, Optional
18
+
19
+ import numpy as np
20
+
21
+ from .artists import (
22
+ AxLine, FillBetween, HLine, Image, Line2D, LineCollection, Polygon,
23
+ PolyCollection, QuadMesh, Rug, ScatterCollection, Span, VLine,
24
+ )
25
+
26
+ # Lines longer than this are min/max-decimated per pixel column before drawing
27
+ # (monotonic x only), the pure-Python analogue of matplotlib's path
28
+ # simplification: huge time-series lines draw ~7x faster and ~40x smaller with
29
+ # no visible change, keeping the output vector.
30
+ _DECIMATE_MIN_POINTS = 5000
31
+
32
+
33
+ def _is_monotonic(x: np.ndarray) -> bool:
34
+ d = np.diff(x)
35
+ return bool(np.all(d >= 0) or np.all(d <= 0))
36
+
37
+
38
+ def _decimate_minmax(x, y, ncols):
39
+ """Keep first/last/min-y/max-y per pixel column (monotonic x only)."""
40
+ n = x.size
41
+ if n <= 4 * ncols or ncols < 1:
42
+ return x, y
43
+ x0, x1 = float(x[0]), float(x[-1])
44
+ span = (x1 - x0) or 1.0
45
+ col = np.clip(((x - x0) / span * ncols).astype(np.intp), 0, ncols - 1)
46
+ keep = np.zeros(n, bool)
47
+ runstart = np.empty(n, bool); runstart[0] = True; runstart[1:] = col[1:] != col[:-1]
48
+ runend = np.empty(n, bool); runend[-1] = True; runend[:-1] = col[1:] != col[:-1]
49
+ keep |= runstart | runend
50
+ order = np.lexsort((y, col))
51
+ sc = col[order]
52
+ lo = np.empty(n, bool); lo[0] = True; lo[1:] = sc[1:] != sc[:-1]
53
+ hi = np.empty(n, bool); hi[-1] = True; hi[:-1] = sc[1:] != sc[:-1]
54
+ keep[order[lo]] = True
55
+ keep[order[hi]] = True
56
+ idx = np.flatnonzero(keep)
57
+ return x[idx], y[idx]
58
+
59
+
60
+ def _finite_subpaths(pts):
61
+ """Split an (N,2) pixel array into contiguous finite runs."""
62
+ mask = np.isfinite(pts).all(axis=1)
63
+ if mask.all():
64
+ return [pts] if len(pts) else []
65
+ out = []
66
+ n = len(pts)
67
+ i = 0
68
+ while i < n:
69
+ if not mask[i]:
70
+ i += 1
71
+ continue
72
+ j = i
73
+ while j < n and mask[j]:
74
+ j += 1
75
+ out.append(pts[i:j])
76
+ i = j
77
+ return out
78
+
79
+
80
+ # -- primitive vocabulary ---------------------------------------------------
81
+ @dataclass
82
+ class Path:
83
+ """A stroked and/or filled path of one or more subpaths (pixel coords)."""
84
+ subpaths: List[np.ndarray]
85
+ closed: bool = False
86
+ element: str = "path" # "path" or "polygon" (single closed subpath)
87
+ stroke: Optional[str] = None
88
+ stroke_width: float = 1.0
89
+ linestyle: str = "-"
90
+ stroke_opacity: float = 1.0
91
+ stroke_round: bool = False
92
+ fill: Optional[str] = None
93
+ fill_opacity: float = 1.0
94
+ series_id: Optional[str] = None
95
+ label: str = ""
96
+
97
+
98
+ @dataclass
99
+ class Line:
100
+ """A single straight segment (axvline / axhline / axline)."""
101
+ p0: tuple
102
+ p1: tuple
103
+ stroke: str
104
+ stroke_width: float
105
+ linestyle: str = "-"
106
+ stroke_opacity: float = 1.0
107
+ label: str = ""
108
+
109
+
110
+ @dataclass
111
+ class Rect:
112
+ """An axis-aligned filled rectangle (axhspan / axvspan)."""
113
+ x: float
114
+ y: float
115
+ w: float
116
+ h: float
117
+ fill: str
118
+ fill_opacity: float = 1.0
119
+ label: str = ""
120
+
121
+
122
+ @dataclass
123
+ class Segments:
124
+ """A batch of independent line segments (hlines / vlines)."""
125
+ segs: np.ndarray # (N, 4): x0, y0, x1, y1
126
+ stroke: str
127
+ stroke_width: float
128
+ linestyle: str = "-"
129
+ stroke_opacity: float = 1.0
130
+ label: str = ""
131
+
132
+
133
+ @dataclass
134
+ class PolygonBatch:
135
+ """Many filled polygons with per-polygon colors (hexbin)."""
136
+ polys: List[np.ndarray] # list of (k, 2)
137
+ fills: list # per-poly color (str or rgb triple)
138
+ edge: Optional[str] = None
139
+ edge_width: float = 0.4
140
+ alpha: float = 1.0
141
+ label: str = ""
142
+
143
+
144
+ @dataclass
145
+ class ImagePrim:
146
+ """An RGBA raster placed at a pixel rect (pcolormesh / imshow / contourf)."""
147
+ rgba: np.ndarray # (H, W, 4) uint8
148
+ x: float
149
+ y: float
150
+ w: float
151
+ h: float
152
+ smooth: bool = False # False = image-rendering:pixelated (the default)
153
+ label: str = "" # legend/toggle label, same as every other series
154
+
155
+
156
+ @dataclass
157
+ class Markers:
158
+ """A batch of round point markers (scatter), constant-size in pixels."""
159
+ points: np.ndarray # (N, 2) pixel centers (may contain NaN)
160
+ diameters: np.ndarray # (N,) pixel diameters
161
+ colors: list # per-point color strings (len N)
162
+ single_color: bool = True # all points share one color (fewer nodes)
163
+ alpha: float = 1.0
164
+ series_id: Optional[str] = None
165
+ label: str = ""
166
+ edgecolor: Optional[str] = None # one outline color for the whole batch
167
+ edgewidth: float = 0.0 # pixel outline width; 0 draws no outline
168
+
169
+
170
+ # -- artist -> primitives ---------------------------------------------------
171
+ def artist_to_prims(artist, tr, ai, k, size_scale=1.0):
172
+ """Primitives for a migrated artist, or None to use its legacy renderer.
173
+
174
+ ``size_scale`` converts marker sizes from points to this backend's pixels
175
+ (``dpi/72`` for SVG, ``dpi/72 * S`` for the supersampled raster).
176
+ """
177
+ a = artist
178
+ lbl = a.label or "" if getattr(a, "label", None) else ""
179
+
180
+ if isinstance(a, ScatterCollection):
181
+ pts = tr.xy(a.x, a.y)
182
+ diam = np.broadcast_to(np.asarray(a.s, float), a.x.shape).astype(float) * size_scale
183
+ fc = a.face_colors()
184
+ colors = fc if fc is not None else [a.color] * a.x.size
185
+ return [Markers(pts, diam, list(colors), single_color=(fc is None),
186
+ alpha=a.alpha, series_id=f"s{ai}_{k}", label=lbl,
187
+ edgecolor=a.edgecolor,
188
+ edgewidth=(a.linewidths or 0.0) * size_scale)]
189
+
190
+ if isinstance(a, Line2D):
191
+ x, y = a.x, a.y
192
+ if x.size > _DECIMATE_MIN_POINTS and _is_monotonic(x):
193
+ x, y = _decimate_minmax(x, y, int(round(tr.px_w)))
194
+ subs = _finite_subpaths(tr.xy(x, y))
195
+ if not subs:
196
+ return []
197
+ # linestyle="none" (matplotlib's "markers only" idiom) means no
198
+ # connecting line at all -- omitting the Path prim here, once, is
199
+ # what makes every backend honor that, the same way errorbar()'s own
200
+ # renderers already special-case "none" to skip their line.
201
+ prims = ([] if a.linestyle == "none" else
202
+ [Path(subpaths=subs, stroke=a.color, stroke_width=a.linewidth,
203
+ linestyle=a.linestyle, stroke_opacity=a.alpha,
204
+ stroke_round=True, series_id=f"s{ai}_{k}", label=lbl)])
205
+ if a.marker:
206
+ # Reuses the exact same constant-pixel-size Markers primitive
207
+ # scatter() already draws with -- a line's own markers are just
208
+ # a dot at each vertex, no new rendering needed in either
209
+ # backend. Ordinarily no series_id: these are the same points
210
+ # the Path above already made pickable, not a second series of
211
+ # their own -- except when linestyle="none" omitted that Path
212
+ # entirely (markers-only), in which case this Markers prim is
213
+ # the *only* element for the series and has to carry the id
214
+ # itself, or nothing in the output identifies it as series k.
215
+ pts = tr.xy(x, y)
216
+ diam = np.full(x.shape, (a.markersize or 6.0) * size_scale, dtype=float)
217
+ face = a.markerfacecolor or a.color
218
+ # One color per point, not one total -- the raster backend zips
219
+ # colors against points/diameters and silently truncates to
220
+ # whichever is shortest (see ScatterCollection's own [a.color] *
221
+ # a.x.size just above, the same reason it does this).
222
+ prims.append(Markers(
223
+ pts, diam, [face] * x.size, single_color=True, alpha=a.alpha,
224
+ label=lbl,
225
+ series_id=(f"s{ai}_{k}" if a.linestyle == "none" else None)))
226
+ return prims
227
+
228
+ # VLine/HLine/AxLine are pure reference lines -- no marker, no other
229
+ # geometry -- so linestyle="none" leaves genuinely nothing to draw for
230
+ # any of the three, the same "omit the prim" fix as Line2D above.
231
+ if isinstance(a, VLine):
232
+ if a.linestyle == "none":
233
+ return []
234
+ x = float(tr.x(a.x))
235
+ return [Line((x, tr.px_top), (x, tr.px_top + tr.px_h), a.color,
236
+ a.linewidth, a.linestyle, a.alpha, lbl)]
237
+
238
+ if isinstance(a, HLine):
239
+ if a.linestyle == "none":
240
+ return []
241
+ y = float(tr.y(a.y))
242
+ return [Line((tr.px_left, y), (tr.px_left + tr.px_w, y), a.color,
243
+ a.linewidth, a.linestyle, a.alpha, lbl)]
244
+
245
+ if isinstance(a, AxLine):
246
+ if a.linestyle == "none":
247
+ return []
248
+ if not np.isfinite(a.slope):
249
+ x = float(tr.x(a.x1))
250
+ p0, p1 = (x, tr.px_top), (x, tr.px_top + tr.px_h)
251
+ else:
252
+ xmin, xmax = tr.xmin, tr.xmax
253
+ y0 = a.y1 + a.slope * (xmin - a.x1)
254
+ y1 = a.y1 + a.slope * (xmax - a.x1)
255
+ p0 = (float(tr.x(xmin)), float(tr.y(y0)))
256
+ p1 = (float(tr.x(xmax)), float(tr.y(y1)))
257
+ return [Line(p0, p1, a.color, a.linewidth, a.linestyle, a.alpha, lbl)]
258
+
259
+ if isinstance(a, Span):
260
+ if a.orientation == "vertical":
261
+ p, q = float(tr.x(a.lo)), float(tr.x(a.hi))
262
+ x, w, yy, h = min(p, q), abs(q - p), tr.px_top, tr.px_h
263
+ else:
264
+ p, q = float(tr.y(a.lo)), float(tr.y(a.hi))
265
+ yy, h, x, w = min(p, q), abs(q - p), tr.px_left, tr.px_w
266
+ return [Rect(x, yy, w, h, a.color, a.alpha, lbl)]
267
+
268
+ if isinstance(a, FillBetween):
269
+ top = tr.xy(a.x, a.y1)
270
+ bot = tr.xy(a.x[::-1], a.y2[::-1])
271
+ pts = np.vstack([top, bot])
272
+ return [Path(subpaths=[pts], closed=True, fill=a.color,
273
+ fill_opacity=a.alpha, stroke=a.edgecolor,
274
+ stroke_width=a.linewidth, series_id=f"s{ai}_{k}", label=lbl)]
275
+
276
+ if isinstance(a, Polygon):
277
+ pts = tr.xy(a.x, a.y)
278
+ return [Path(subpaths=[pts], closed=True, element="polygon",
279
+ fill=a.color, fill_opacity=a.alpha,
280
+ stroke=a.edgecolor, stroke_width=a.linewidth,
281
+ series_id=f"s{ai}_{k}", label=lbl)]
282
+
283
+ if isinstance(a, LineCollection):
284
+ # hlines()/vlines()' own bare segments -- linestyle="none" leaves
285
+ # nothing to draw, same as the reference-line artists above.
286
+ if a.linestyle == "none":
287
+ return []
288
+ segs = np.column_stack([
289
+ tr.x(a.segments[:, 0]), tr.y(a.segments[:, 1]),
290
+ tr.x(a.segments[:, 2]), tr.y(a.segments[:, 3]),
291
+ ])
292
+ return [Segments(segs, a.color, a.linewidth, a.linestyle, a.alpha, lbl)]
293
+
294
+ if isinstance(a, Rug):
295
+ n = a.x.size
296
+ if n == 0:
297
+ return []
298
+ # Anchored in pixel space, so the tick length is a fraction of the axes
299
+ # rather than of the data range.
300
+ if a.side == "left":
301
+ y = tr.y(a.x)
302
+ x0 = np.full(n, tr.px_left)
303
+ segs = np.column_stack([x0, y, x0 + a.height * tr.px_w, y])
304
+ else:
305
+ x = tr.x(a.x)
306
+ y0 = np.full(n, tr.px_top + tr.px_h)
307
+ segs = np.column_stack([x, y0, x, y0 - a.height * tr.px_h])
308
+ return [Segments(segs, a.color, a.linewidth, "-", a.alpha, lbl)]
309
+
310
+ if isinstance(a, PolyCollection):
311
+ polys = [tr.xy(v[:, 0], v[:, 1]) for v in a.verts]
312
+ return [PolygonBatch(polys, list(a.facecolors), a.edgecolor,
313
+ 0.4, a.alpha, lbl)]
314
+
315
+ if isinstance(a, (QuadMesh, Image)):
316
+ xmin, xmax, ymin, ymax = a.extent()
317
+ # Pixel corners of the data extent. On an inverted axis the transform
318
+ # reverses these, which used to yield a negative width or height --
319
+ # an error in SVG, so the browser dropped the element and the mesh
320
+ # simply did not appear. Normalize the rect and mirror the raster to
321
+ # match, which keeps every pixel over the data it came from.
322
+ x0, x1 = float(tr.x(xmin)), float(tr.x(xmax))
323
+ y0, y1 = float(tr.y(ymax)), float(tr.y(ymin))
324
+ rgba = a.rgba().astype(np.uint8)
325
+ if x1 < x0:
326
+ x0, x1 = x1, x0
327
+ rgba = rgba[:, ::-1]
328
+ if y1 < y0:
329
+ y0, y1 = y1, y0
330
+ rgba = rgba[::-1, :]
331
+ smooth = getattr(a, "interpolation", "nearest") != "nearest"
332
+ return [ImagePrim(np.ascontiguousarray(rgba), x0, y0, x1 - x0, y1 - y0, smooth,
333
+ label=a.label or "")]
334
+
335
+ return None