diff --git a/docs/styling/themes-and-tokens.md b/docs/styling/themes-and-tokens.md
index f2b9487e..3cf64afd 100644
--- a/docs/styling/themes-and-tokens.md
+++ b/docs/styling/themes-and-tokens.md
@@ -15,6 +15,8 @@ Use this page to:
- **Set chart-wide colors and chrome.**
[Start with the theme component →](#start-with-the-theme-component)
+- **Use your own brand colors for series.**
+ [Set the series palette →](#set-the-series-palette)
- **Build and reuse a semantic palette.**
[Open the palette example →](#build-a-reusable-palette)
- **Understand which declaration wins.**
@@ -133,6 +135,39 @@ xy.area(
)
~~~
+## Set the series palette
+
+Series and categories that carry no explicit `color=` take their colors, in
+order, from the chart's palette. `xy.theme(palette=[...])` replaces it, so a
+brand palette applies to every series, every category of a categorical color
+channel, and the legend swatches — in the browser and in every export:
+
+~~~python
+BRAND = ["#ff5c00", "#111827", "#00b3a4", "#8b5cf6"]
+
+chart = xy.line_chart(
+ xy.line(weeks, north, name="north"),
+ xy.line(weeks, south, name="south"),
+ xy.line(weeks, east, name="east"),
+ xy.legend(),
+ xy.theme(palette=BRAND),
+)
+~~~
+
+Colors repeat once the list runs out, so a four-color palette gives series 5
+the color of series 1. XY's shipped default is eight colors validated for
+color-vision deficiency, and its ordering is part of that guarantee; a
+replacement palette is not checked, so confirm a brand palette stays
+distinguishable — including for readers with CVD — before shipping it.
+
+For a **continuous** scale, pass a list of colors as the colormap instead:
+`xy.scatter(..., colormap=["#0d1b2a", "#1b6ca8", "#48cae4", "#ffd166"])`.
+The colors are interpolated evenly across the color domain and drive the
+marks, the density surface, and the colorbar gradient together. Between 2 and
+256 colors are accepted, and each must be one XY can resolve to RGB (hex,
+`rgb()`, `hsl()`, or a named color) — a `var()` that only a browser cascade
+could resolve is rejected rather than silently painted a fallback.
+
## Build a reusable palette
Application tokens are useful when several marks should move together. This
@@ -352,6 +387,14 @@ browser-only adaptation.
| Native PNG | Full validated static surface | Chart-local tokens and `var()` fallbacks | Supported static chrome fields; no general slot-class cascade | No | One resolved state |
| Chromium PNG | Full | Full | Full serialized component and slot styling | Only CSS included with `custom_css` | Evaluated at capture time, then frozen |
+The palette, colormaps, and `format=` on axes, colorbars, and tooltips are
+part of the chart itself rather than the cascade, so they render identically in
+every column above. So do the legend for a categorical `color=` channel and the
+`text=` label on a reference line or band — both used to appear only in the
+browser. The one split is `theme(font_family=)`, which the native
+PNG/JPEG/WebP path cannot honor — see
+[Custom fonts and export limitations](#custom-fonts-and-export-limitations).
+
“Supported static chrome fields” means options the SVG/native renderer owns,
such as axes and the built-in legend. It does not mean arbitrary DOM CSS or
Tailwind classes are evaluated without a browser. Browser-only color
@@ -369,7 +412,36 @@ For exporter selection, engine arguments, and complete code examples, see
## Custom fonts and export limitations
-Browser charts inherit fonts from the chart root. Load the font in the host
+`xy.theme(font_family=..., font_size=...)` sets the typeface and base text size
+for the whole chart. Every text element scales from `font_size`, so tick
+labels, axis titles, the legend, and the chart title keep their proportions:
+
+~~~python
+chart = xy.line_chart(
+ xy.line(months, revenue, name="revenue"),
+ xy.y_axis(label="revenue", format="$,.0f"),
+ xy.legend(),
+ xy.theme(font_family="Georgia, 'Times New Roman', serif", font_size=15),
+ title="Quarterly revenue",
+)
+~~~
+
+Where each half applies:
+
+| Output | `font_family` | `font_size` |
+| --- | --- | --- |
+| Browser (notebook, Reflex, standalone HTML) | yes | yes |
+| SVG, PDF | yes | yes |
+| PNG, JPEG, WebP (native engine) | no — baked bitmap face | yes |
+| PNG via `engine=Engine.chromium` | yes | yes |
+
+The native raster exporter draws text with XY's own bitmap font and runs no
+browser cascade, so it cannot switch typefaces. Use
+`chart.to_image(..., engine=xy.Engine.chromium)` when a raster export must
+match the browser's text exactly, or export SVG or PDF, which carry the family
+through as a real font attribute.
+
+Browser charts also inherit fonts from the chart root. Load the font in the host
application first, then set `font_family` through chart `style=` or apply a
class that defines `font-family`. The live example uses a system serif so it
does not depend on a network font; replace that stack with the family your host
diff --git a/js/src/10_colormaps.ts b/js/src/10_colormaps.ts
index 7a93f964..e530b076 100644
--- a/js/src/10_colormaps.ts
+++ b/js/src/10_colormaps.ts
@@ -27,6 +27,9 @@ const COLORMAP_STOPS = {
};
export function colormapStops(name) {
+ // A custom colormap arrives as explicit [r,g,b] stops instead of a name
+ // (Python resolves the CSS colors, so the client never parses color text).
+ if (Array.isArray(name)) return name.length ? name : COLORMAP_STOPS.viridis;
const reversed = typeof name === "string" && name.endsWith("_r");
const base = reversed ? name.slice(0, -2) : name;
const stops = COLORMAP_STOPS[base] || COLORMAP_STOPS.viridis;
diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts
index c4c37825..9e8996d8 100644
--- a/js/src/20_theme.ts
+++ b/js/src/20_theme.ts
@@ -110,14 +110,14 @@ export function cssColor([r, g, b, a]: any) {
// --chart-badge-* / --chart-modebar-* tokens or utility classes override them.
export const XY_CHROME_CSS = `
@layer base{
-:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)}
+:where(.xy [data-xy-slot="title"]){text-align:center;font-size:1.1667em;font-weight:600;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="tooltip"]){max-width:calc(100% - 8px);max-height:calc(100% - 8px);box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;overflow:auto;background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)}
:where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)}
:where(.xy [data-xy-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
-:where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
+:where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:0.8333em}
:where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
:where(.xy [data-xy-slot="colorbar_title"]){font-weight:500}
-:where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
+:where(.xy [data-xy-slot="badge"]){gap:3px;font-size:0.9167em;line-height:1.2}
:where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)}
:where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:#fff;--xy-modebar-menu-bg:#fff;--xy-modebar-hover:#edf1f6;--xy-modebar-focus:#1b212a;--xy-modebar-text:#5c6573;--xy-modebar-text-strong:#1b212a;--xy-modebar-text-soft:#798495;--xy-modebar-text-subtle:#9aa4b2;--xy-modebar-border:rgba(27,33,42,.12);--xy-modebar-separator:rgba(27,33,42,.08);--xy-modebar-active:#edf1f6;--xy-modebar-shadow:0 8px 24px rgba(28,32,36,.1),0 2px 6px rgba(28,32,36,.06);--xy-modebar-menu-shadow:0 8px 24px rgba(28,32,36,.12);--xy-modebar-button-shadow:0 1px 2px rgba(28,32,36,.06);--xy-selection:rgba(92,101,115,.6);--xy-selection-fill:rgba(92,101,115,.12)}
:where(.dark .xy,.xy.dark){--xy-badge-text:#f8fafc;--xy-badge-bg:rgba(30,35,44,.88);--xy-badge-shadow:0 1px 4px rgba(0,0,0,.5);--xy-modebar-bg:#1b1d20;--xy-modebar-menu-bg:#1b1d20;--xy-modebar-hover:#121417;--xy-modebar-focus:#e2e5e9;--xy-modebar-text:#adb4bf;--xy-modebar-text-strong:#e2e5e9;--xy-modebar-text-soft:#adb4bf;--xy-modebar-text-subtle:#7f8996;--xy-modebar-border:rgba(226,229,233,.14);--xy-modebar-separator:rgba(226,229,233,.1);--xy-modebar-active:#121417;--xy-modebar-shadow:0 8px 24px rgba(0,0,0,.3),0 2px 6px rgba(0,0,0,.22);--xy-modebar-menu-shadow:0 8px 24px rgba(0,0,0,.34);--xy-modebar-button-shadow:0 1px 2px rgba(0,0,0,.22);--xy-selection:rgba(173,180,191,.6);--xy-selection-fill:rgba(173,180,191,.12)}
@@ -168,8 +168,8 @@ export const XY_CHROME_CSS = `
:where(.xy [data-xy-selection-lasso-handle][data-xy-active]){cursor:grabbing;fill:var(--chart-selection,var(--xy-selection))}
:where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))}
:where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)}
-:where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px}
-:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))}
+:where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:1em}
+:where(.xy [data-xy-slot="annotation_label"]){font-size:0.9167em;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))}
:where(.xy [data-xy-slot="canvas"]){cursor:var(--chart-cursor,crosshair)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)}
:where(.xy [data-xy-slot="canvas"][data-xy-dragmode="none"]){cursor:default}
diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts
index 6671f857..4925b6b9 100644
--- a/js/src/50_chartview.ts
+++ b/js/src/50_chartview.ts
@@ -2518,7 +2518,11 @@ export class ChartView {
get heatmapProg() { return this._prog("heatmap", GRID_VS, HEATMAP_FS); }
_lut(name) {
- if (this._lutCache.has(name)) return this._lutCache.get(name);
+ // Custom colormaps are stop ARRAYS, and every spec rebuild deserializes a
+ // fresh one — an identity-keyed cache would miss on each rebuild and leak
+ // a GL texture per frame. Key on the stops' text instead.
+ const key = Array.isArray(name) ? " stops:" + name.join(",") : name;
+ if (this._lutCache.has(key)) return this._lutCache.get(key);
const gl = this.gl;
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
@@ -2527,7 +2531,7 @@ export class ChartView {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
- this._lutCache.set(name, tex);
+ this._lutCache.set(key, tex);
return tex;
}
diff --git a/python/xy/_figure.py b/python/xy/_figure.py
index 80ea37bc..6a2587a0 100644
--- a/python/xy/_figure.py
+++ b/python/xy/_figure.py
@@ -128,6 +128,10 @@ def __init__(
# pyplot sets an explicit Matplotlib-style spine list.
self.frame_sides: Optional[list[str]] = None
self.colorbar_options: Optional[dict[str, Any]] = None
+ # Series/category color cycle. None means config.DEFAULT_PALETTE — the
+ # CVD-validated eight — so an unthemed chart is byte-identical to
+ # before. `xy.theme(palette=[...])` replaces it for this chart.
+ self.palette: Optional[list[str]] = None
# Declarative export defaults (xy.export_config): governs the client
# modebar's format menu + filename and the Python export defaults.
self.export_options: Optional[dict[str, Any]] = None
diff --git a/python/xy/_payload.py b/python/xy/_payload.py
index 594f7eab..f102185e 100644
--- a/python/xy/_payload.py
+++ b/python/xy/_payload.py
@@ -417,13 +417,30 @@ def _binning_coords(
return values, (float(bounds[0]), float(bounds[1]))
return self._axis_coord(axis_id, values), (c0, c1)
- @staticmethod
- def _default_styled(t: Trace) -> dict[str, Any]:
+ @property
+ def _series_palette(self) -> list[str]:
+ """This figure's series/category color cycle.
+
+ `xy.theme(palette=[...])` overrides the shipped default; the fallback
+ is the CVD-validated eight, so charts that never theme keep exactly
+ the colors they had.
+ """
+ return getattr(self, "palette", None) or DEFAULT_PALETTE
+
+ def _default_styled(self, t: Trace) -> dict[str, Any]:
"""Trace style dict with the per-trace palette default when no color
was given — the one place this rule lives (was copy-pasted per kind)."""
style = dict(t.style)
if style.get("color") is None:
- style["color"] = default_palette_color(t.id)
+ palette = self._series_palette
+ style["color"] = (
+ default_palette_color(t.id)
+ if palette is DEFAULT_PALETTE
+ # A user palette is their own call: it carries no CVD
+ # guarantee and no eight-slot rule, so the wrap warning that
+ # polices the shipped default would be noise here.
+ else palette[t.id % len(palette)]
+ )
return style
def _m4_decimate(
@@ -901,15 +918,21 @@ def _ship_channels(
path keeps unit f32 because tooltips denormalize the shipped columns
(see channels.ship_channels)."""
return channels.ship_channels(
- t, sel, ship_scalar, ship_u8, DEFAULT_PALETTE, quantize_continuous=quantize_continuous
+ t,
+ sel,
+ ship_scalar,
+ ship_u8,
+ self._series_palette,
+ quantize_continuous=quantize_continuous,
)
- @staticmethod
- def _ship_trace_styles(entry: dict[str, Any], t: Trace, sel, pw: "_PayloadWriter") -> None: # noqa: ANN001
+ def _ship_trace_styles(
+ self, entry: dict[str, Any], t: Trace, sel, pw: "_PayloadWriter"
+ ) -> None: # noqa: ANN001
"""Attach outline paint and direct instance attributes to a trace spec."""
if t.stroke_ch is not None:
entry["stroke"] = channels.ship_color_channel(
- t.stroke_ch, sel, pw.ship_scalar, pw.ship_u8, DEFAULT_PALETTE
+ t.stroke_ch, sel, pw.ship_scalar, pw.ship_u8, self._series_palette
)
if t.style_channels:
entry["channels"] = channels.ship_style_channels(
@@ -1134,7 +1157,7 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d
# color == density.
color_spec = t.color_ch.spec()
color_spec["palette"] = channels.categorical_palette(
- DEFAULT_PALETTE, len(t.color_ch.categories or ())
+ self._series_palette, len(t.color_ch.categories or ())
)
entry["color"] = color_spec
return entry
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index b58cdb32..afd368d8 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -23,6 +23,7 @@
from ._svg import (
_AXIS,
_AXIS_GRID_DASHES,
+ _BASE_FONT_SIZE,
_GRID,
_STATIC_COLOR_FALLBACK,
_TEXT,
@@ -45,14 +46,17 @@
_physical_density_alpha,
_px_size,
_resolve_static_css_vars,
+ _rule_label_anchor,
_Scale,
_solid_paint,
_step_arrays,
+ _theme_font,
_tick_label_anchor,
apply_export_background,
axis_ticks,
hexbin_ring,
layout,
+ legend_entries,
warp_grid_rgba,
)
@@ -658,6 +662,10 @@ def render_raster(
cmd = _Cmd(scale)
dom_style = (spec.get("dom") or {}).get("style") or {}
+ # The native rasterizer draws with a baked bitmap face, so only the SIZE
+ # half of `theme(font_family=, font_size=)` can apply here; the family is
+ # honored by the SVG/PDF exports and the browser client.
+ _theme_family, base_font = _theme_font(spec)
# Figure patch (mpl figure.facecolor): `theme(background=)` lands on the
# root element's CSS background, painted over the whole canvas so the
@@ -935,14 +943,16 @@ def emit_tick_labels(
"tick_label_angle": 0,
"tick_label_strategy": "hide",
}
- items = _axis_tick_label_layout(fallback_axis, values, step, axis_scale, is_x)
+ items = _axis_tick_label_layout(
+ fallback_axis, values, step, axis_scale, is_x, base_font
+ )
tick_color = _parse_color(
_css(
axis_style.get("tick_label_color", axis_style.get("tick_color")),
default_text,
)
)
- font_size = _axis_tick_font_size(axis)
+ font_size = _axis_tick_font_size(axis, base_font)
side = axis.get("side", "bottom" if is_x else "left")
# An explicit tick_label_anchor (axis spec or style) overrides the
# side-derived default, matching the browser client and SVG export.
@@ -974,7 +984,7 @@ def emit_tick_labels(
width / 2,
plot["y"] - plot["top_axis_room"] - (10 if compact else 12),
1,
- 14,
+ base_font * (14.0 / _BASE_FONT_SIZE),
text_c,
str(spec["title"]),
)
@@ -983,7 +993,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
if not axis.get("label") or _axis_tick_label_strategy(axis) == "none":
return
axis_style = axis.get("style") or {}
- geometry = _axis_label_geometry(axis, plot, is_x=is_x)
+ geometry = _axis_label_geometry(axis, plot, is_x=is_x, base=base_font)
anchor = {"start": 0, "middle": 1, "end": 2}[geometry["anchor"]]
cmd.text(
geometry["x"],
@@ -1001,7 +1011,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
for _axis_id, axis, _axis_scale in extra_y_axes:
emit_axis_title(axis, is_x=False)
- named = [t for t in spec["traces"] if t.get("name")]
+ named = legend_entries(spec)
show_main_legend = spec.get("show_legend", True) and bool(named)
extra_legends = [(extra, extra.get("items") or []) for extra in spec.get("extra_legends") or []]
legend_present = show_main_legend or any(items for _extra, items in extra_legends)
@@ -1158,9 +1168,23 @@ def _emit_annotations(
cmd.fill(decoration["points"], color)
else:
cmd.stroke(decoration["points"], stroke_width, color)
- if ann.get("kind") in ("text", "callout") and ann.get("text"):
- x, y = _annotation_point(ann, style, sx, sy, plot, width, height)
- anchor = {"start": 0, "middle": 1, "end": 2}.get(ann.get("anchor"), 0)
+ if ann.get("kind") in ("text", "callout", "rule", "band") and ann.get("text"):
+ kind = str(ann.get("kind"))
+ default_va = ""
+ if kind in ("rule", "band"):
+ # Same placement rule the SVG and the client use, so a rule's
+ # label lands in the same spot in all three outputs.
+ x, y, default_anchor, default_va = _rule_label_anchor(ann, kind, sx, sy, plot)
+ else:
+ x, y = _annotation_point(ann, style, sx, sy, plot, width, height)
+ default_anchor = "start"
+ if not (np.isfinite(x) and np.isfinite(y)):
+ continue
+ anchor = {"start": 0, "middle": 1, "end": 2}.get(
+ ann.get("anchor"), {"start": 0, "middle": 1, "end": 2}[default_anchor]
+ )
+ if default_va and not style.get("vertical_align"):
+ style = {**style, "vertical_align": default_va}
font_size = _px_size(style.get("font_size"), 11.0)
lines = str(ann["text"]).splitlines() or [""]
line_height = font_size * 1.2
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 3715dd14..6d3551e8 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -301,6 +301,29 @@ def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float:
_GRID = "rgba(32,32,32,0.14)"
_AXIS = "rgba(32,32,32,0.55)"
_FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif"
+# Every default chrome size below is expressed as a multiple of this, so
+# `theme(font_size=)` moves ticks, axis titles, and the chart title together
+# and a chart that sets no font size renders byte-identically to before.
+_BASE_FONT_SIZE = 11.0
+
+
+def _theme_font(spec: dict[str, Any]) -> tuple[str, float]:
+ """(font stack, base text size) for a chart, honoring `xy.theme(...)`.
+
+ Both arrive as ordinary chart-root style tokens, already declaration-
+ checked in `_validate`, so the exporters read them the same way they read
+ `--chart-text` — no separate wire field, and the browser client picks the
+ same two values off the same node.
+ """
+ dom_style = (spec.get("dom") or {}).get("style") or {}
+ family = dom_style.get("font-family") or dom_style.get("font_family")
+ size = dom_style.get("font-size", dom_style.get("font_size"))
+ return (
+ str(family) if isinstance(family, str) and family.strip() else _FONT,
+ _px_size(size, _BASE_FONT_SIZE),
+ )
+
+
_MS = {"s": 1e3, "m": 6e4, "h": 36e5, "d": 864e5}
_STATIC_COLOR_FALLBACK = (0.3, 0.47, 0.66, 1.0)
_AXIS_GRID_DASHES = {
@@ -636,14 +659,30 @@ def affine(self) -> bool:
return not (self.log or self.symlog)
-def _colormap_stops(colormap: str) -> list[tuple[int, int, int]]:
+def _colormap_stops(colormap: Any) -> list[tuple[int, int, int]]:
+ """Stops for a built-in colormap name, or for explicit wire stops.
+
+ A custom `colormap=["#0d1b2a", …]` arrives already resolved to RGB triples
+ (channels.resolve_colormap), so every colormap consumer in the static
+ exporters — heatmaps, density planes, scatter LUTs, the colorbar gradient —
+ picks it up by going through this one function.
+ """
+ if not isinstance(colormap, str):
+ stops = [(int(r), int(g), int(b)) for r, g, b in colormap]
+ return stops or list(COLORMAP_STOPS["viridis"])
reversed_map = colormap.endswith("_r")
base = colormap[:-2] if reversed_map else colormap
stops = COLORMAP_STOPS.get(base) or COLORMAP_STOPS["viridis"]
return list(reversed(stops)) if reversed_map else stops
-def _lut(colormap: str, t: np.ndarray) -> np.ndarray:
+def _spec_colormap(spec: dict[str, Any]) -> Any:
+ """A spec node's colormap: a built-in name or explicit wire stops."""
+ colormap = spec.get("colormap", "viridis")
+ return colormap if colormap is not None else "viridis"
+
+
+def _lut(colormap: Any, t: np.ndarray) -> np.ndarray:
"""Vectorized colormap sample: t in [0,1] -> (n,3) uint8, matching the
client's 256-texel LUT interpolation."""
stops = np.array(_colormap_stops(colormap), dtype=np.float64)
@@ -1244,21 +1283,21 @@ def _colorbar_right_axis_room(
# against this budget and the gutter grows by the OVERFLOW only, so every
# chart whose labels already fit keeps its historical geometry to the pixel.
_Y_LABEL_BUDGET_CHARS = 6.0
-# The size those six characters are measured at.
-_Y_BASE_FONT_SIZE = 11.0
_Y_CHAR_ASPECT = 0.62
# One wide label must not be able to collapse the plot rect.
_Y_EXTRA_ROOM_CAP = 120.0
-def _left_axis_room(axes: dict[str, dict[str, Any]], plot_h: float, default_left: float) -> float:
+def _left_axis_room(
+ axes: dict[str, dict[str, Any]], plot_h: float, default_left: float, base_font: float
+) -> float:
"""Left gutter for the widest left-side y tick label, never below `default_left`.
`y_axis(format="$,.0f")` turns `800000` into `$800,000` and
`theme(font_size=)` scales every label; both used to run under the axis
title because the gutter was a constant.
"""
- budget = _Y_LABEL_BUDGET_CHARS * _Y_BASE_FONT_SIZE
+ budget = _Y_LABEL_BUDGET_CHARS * _BASE_FONT_SIZE
extra = 0.0
for axis_id, axis in axes.items():
if not axis_id.startswith("y") or axis.get("side", "left") != "left":
@@ -1269,7 +1308,7 @@ def _left_axis_room(axes: dict[str, dict[str, Any]], plot_h: float, default_left
_ticks, labeled, step = axis_ticks(axis, max(1.0, plot_h), False)
except (KeyError, TypeError, ValueError):
continue
- font_size = _axis_tick_font_size(axis)
+ font_size = _axis_tick_font_size(axis, base_font)
widest = max((len(_tick_text(axis, value, step)) for value in labeled), default=0)
extra = max(extra, (widest * font_size - budget) * _Y_CHAR_ASPECT)
return default_left + min(max(0.0, extra), _Y_EXTRA_ROOM_CAP)
@@ -1335,7 +1374,7 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]:
# Otherwise the left gutter grows to fit labels wider than the default
# reserve assumes — `y_axis(format="$,.0f")` turns `800000` into
# `$800,000`, which used to run under the rotated axis title.
- left = _left_axis_room(axes, height - top - bottom, left)
+ left = _left_axis_room(axes, height - top - bottom, left, _theme_font(spec)[1])
plot = {
"x": left,
"y": top,
@@ -1399,9 +1438,9 @@ def _axis_tick_label_strategy(axis: dict[str, Any]) -> str:
return value if value in {"auto", "hide", "rotate", "stagger", "none", "off"} else "auto"
-def _axis_tick_font_size(axis: dict[str, Any]) -> float:
+def _axis_tick_font_size(axis: dict[str, Any], base: float = _BASE_FONT_SIZE) -> float:
style = axis.get("style") or {}
- return max(8.0, float(style.get("tick_label_size", style.get("tick_size", 11))))
+ return max(8.0, float(style.get("tick_label_size", style.get("tick_size", base))))
def _axis_tick_label_layout(
@@ -1410,13 +1449,14 @@ def _axis_tick_label_layout(
step: float,
scale: _Scale,
is_x: bool,
+ base: float = _BASE_FONT_SIZE,
) -> list[dict[str, Any]]:
"""Port ChartView._layoutTickLabels for deterministic static chrome."""
strategy = _axis_tick_label_strategy(axis)
if strategy in {"none", "off"}:
return []
- font_size = _axis_tick_font_size(axis)
+ font_size = _axis_tick_font_size(axis, base)
min_gap = float(axis.get("tick_label_min_gap", 8 if is_x else 4))
raw_angle = axis.get("tick_label_angle")
explicit_angle = float(raw_angle) if raw_angle is not None else None
@@ -1519,6 +1559,7 @@ def _axis_label_geometry(
plot: dict[str, float],
*,
is_x: bool,
+ base: float = _BASE_FONT_SIZE,
) -> dict[str, Any]:
"""Resolve named axis-title placement shared by SVG and native output.
@@ -1527,7 +1568,7 @@ def _axis_label_geometry(
do not have a CSS layout engine.
"""
style = axis.get("style") or {}
- font_size = float(style.get("label_size", 12))
+ font_size = float(style.get("label_size", base * (12.0 / _BASE_FONT_SIZE)))
raw_position = axis.get("label_position")
position = raw_position if isinstance(raw_position, str) else "center"
position = position.replace("-", "_")
@@ -1596,6 +1637,7 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list
xt, xlab, xstep = ticks_for(xa, plot["w"])
yt, ylab, ystep = ticks_for(ya, plot["h"])
dom_style = (spec.get("dom") or {}).get("style") or {}
+ font_family, base_font = _theme_font(spec)
xstyle, ystyle = xa.get("style") or {}, ya.get("style") or {}
default_grid = _css(dom_style.get("--chart-grid"), _GRID)
default_axis = _css(dom_style.get("--chart-axis"), _AXIS)
@@ -1643,14 +1685,14 @@ def append_tick_labels(
default_text,
)
)
- font_size = _axis_tick_font_size(axis)
+ font_size = _axis_tick_font_size(axis, base_font)
side = axis.get("side", "bottom" if is_x else "left")
# An explicit tick_label_anchor (axis spec or style) overrides the
# angle/side-derived default. Anchored labels rotate about the tick
# point (the rotate() pivot below), so anchor and rotation compose —
# matching the browser client.
explicit_anchor = _tick_label_anchor(axis, axis_style, "")
- for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x):
+ for item in _axis_tick_label_layout(axis, values, step, axis_scale, is_x, base_font):
angle = float(item["angle"])
if is_x:
row_offset = float(item["row"]) * (font_size + 4)
@@ -1786,7 +1828,8 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
chrome.append(
f'{escape(str(spec["title"]))}'
)
@@ -1794,7 +1837,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
if not axis.get("label") or _axis_tick_label_strategy(axis) == "none":
return
axis_style = axis.get("style") or {}
- geometry = _axis_label_geometry(axis, plot, is_x=is_x)
+ geometry = _axis_label_geometry(axis, plot, is_x=is_x, base=base_font)
x, y = float(geometry["x"]), float(geometry["y"])
angle = float(geometry["angle"])
transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else ""
@@ -1811,7 +1854,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
append_axis_title(axis, is_x=True)
for _axis_id, axis, _axis_scale in extra_y_axes:
append_axis_title(axis, is_x=False)
- named = [t for t in spec["traces"] if t.get("name")]
+ named = legend_entries(spec)
if spec.get("show_legend", True) and named:
chrome.append(_legend(named, plot, spec.get("legend") or {}, clip_id, default_text))
for extra in spec.get("extra_legends") or []:
@@ -1985,7 +2028,8 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]:
)
return (
f'