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'' + f'viewBox="0 0 {width} {height}" font-family="{escape(font_family)}" ' + f'font-size="{_num(base_font)}">' f"{defs}" f"{backgrounds}" f"{''.join(grid)}" @@ -1997,6 +2041,72 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: ) +def _rule_label_anchor( + ann: dict[str, Any], + kind: str, + sx: Callable[[float], float], + sy: Callable[[float], float], + plot: dict[str, float], +) -> tuple[float, float, str, str]: + """Where a rule's or band's own `text=` label sits, and how it anchors. + + One rule, three renderers: `js/src/51_annotations.ts` places these labels + for the live client, and the static SVG and native raster paths both come + through here, so `xy.hline(2.5, text="target")` reads the same in a + notebook and in an exported PNG. + """ + vertical = ann.get("axis") == "x" + if kind == "band": + start, end = float(ann.get("start", 0.0)), float(ann.get("end", 0.0)) + along = ( + (float(sx(start)) + float(sx(end))) / 2 + if vertical + else (float(sy(start)) + float(sy(end))) / 2 + ) + else: + value = float(ann.get("value", 0.0)) + along = float(sx(value)) if vertical else float(sy(value)) + if vertical: + # Pinned just inside the top of the plot, reading downward from the line. + anchor = "middle" if kind == "band" else "start" + return along, plot["y"] + 6.0, anchor, "top" + # Pinned just inside the right edge, sitting on the line. + return plot["x"] + plot["w"] - 6.0, along, "end", "middle" if kind == "band" else "" + + +def legend_entries(spec: dict[str, Any]) -> list[dict[str, Any]]: + """Legend rows for a spec, in the client's order and with its rules. + + A *categorical* color channel contributes one row per category rather than + one row for the trace — a scatter colored by `continent` has no trace name + to show, and without this the static exporters drew no legend at all while + the browser drew one row per continent (`js/src/50_chartview.ts`, the + `color.mode === "categorical"` branch). Each row is shaped like a trace so + the existing legend layout and painters need no special case. + """ + entries: list[dict[str, Any]] = [] + for trace in spec.get("traces") or []: + color = trace.get("color") or {} + if color.get("mode") == "categorical": + categories = color.get("categories") or [] + palette = color.get("palette") or DEFAULT_PALETTE + style = trace.get("style") or {} + for index, category in enumerate(categories): + entries.append( + { + **trace, + "name": str(category), + # The row wears its category's palette slot, not the + # trace's own default color. + "style": {**style, "color": palette[index % len(palette)]}, + "color": None, + } + ) + elif trace.get("name"): + entries.append(trace) + return entries + + def _annotation_svg( annotations: Sequence[dict[str, Any]], sx: Callable[[float], float], @@ -2076,22 +2186,34 @@ def _annotation_svg( f'' ) - if kind in ("text", "callout") and ann.get("text"): - x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0)) - space = style.get("coordinate_space") - if space == "axes_fraction": - tx, ty = px0 + x * plot["w"], py0 + (1 - y) * plot["h"] - elif space == "figure_fraction": - tx, ty = x * width, (1 - y) * height - elif space == "yaxis_transform": - tx, ty = px0 + x * plot["w"], float(sy(y)) - elif space == "xaxis_transform": - tx, ty = float(sx(x)), py0 + (1 - y) * plot["h"] + if kind in ("text", "callout", "rule", "band") and ann.get("text"): + default_anchor = "start" + default_va = "" + if kind in ("rule", "band"): + # A rule/band label has no coordinates of its own: it rides the + # line or the band's middle, pinned to the plot edge exactly as + # the client places it (js/src/51_annotations.ts). + tx, ty, default_anchor, default_va = _rule_label_anchor(ann, kind, sx, sy, plot) else: - tx, ty = float(sx(x)), float(sy(y)) + x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0)) + space = style.get("coordinate_space") + if space == "axes_fraction": + tx, ty = px0 + x * plot["w"], py0 + (1 - y) * plot["h"] + elif space == "figure_fraction": + tx, ty = x * width, (1 - y) * height + elif space == "yaxis_transform": + tx, ty = px0 + x * plot["w"], float(sy(y)) + elif space == "xaxis_transform": + tx, ty = float(sx(x)), py0 + (1 - y) * plot["h"] + else: + tx, ty = float(sx(x)), float(sy(y)) + if not np.isfinite(tx) or not np.isfinite(ty): + continue anchor = {"start": "start", "middle": "middle", "end": "end"}.get( - ann.get("anchor"), "start" + ann.get("anchor"), 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 @@ -3034,9 +3156,12 @@ def _colorbar_tick_text(value: float, format: Any) -> str: def _colorbar( options: dict, plot: dict, right_axis_room: float = 0.0, text_color: str = _TEXT ) -> str: - cmap = str(options.get("colormap", "viridis")) - gradient_id = f"xy-colorbar-{sum(map(ord, cmap))}" + cmap = _spec_colormap(options) stops = _colormap_stops(cmap) + # One gradient per distinct ramp, so two colorbars in the same + # document never share an id. Derived from the resolved STOPS rather than + # the name, which is the only thing a custom colormap has. + gradient_id = f"xy-colorbar-{hash(tuple(stops)) & 0xFFFFFFFF:08x}" stop_nodes = "".join( f'' @@ -3140,7 +3265,7 @@ def _colorbar_body( f'height="{_num(height)}" fill="url(#{gradient_id})"/>' ) n = int(levels) - cmap = str(options.get("colormap", "viridis")) + cmap = _spec_colormap(options) positions = (np.arange(n, dtype=np.float64) + 0.5) / n colors = _lut(cmap, positions) rects = [] diff --git a/python/xy/channels.py b/python/xy/channels.py index 715a85c6..a794cd52 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -12,8 +12,9 @@ from __future__ import annotations import numbers +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any, Optional, TypeAlias import numpy as np import numpy.typing as npt @@ -51,10 +52,9 @@ def is_colormap(name: str) -> bool: """Return whether *name* is a supported colormap, including ``_r`` variants. - Non-strings answer False rather than raising: a caller passing a stop list - (``colormap=["#fff", "#f00"]``, the natural guess for a custom ramp) must - reach the ValueError that names the supported colormaps, not an - AttributeError from inside this helper. + Non-strings answer False rather than raising, so a stop list reaches + `resolve_colormap` and is interpreted there rather than dying on an + AttributeError inside this helper. """ if not isinstance(name, str): return False @@ -63,6 +63,87 @@ def is_colormap(name: str) -> bool: DEFAULT_COLORMAP = "viridis" +# A custom continuous colormap is a list of CSS colors, resolved here to the +# same `(r, g, b)` stop list shape the built-in tables use and carried on the +# wire in place of a name. Every renderer resolves a colormap through one +# function — `_svg._colormap_stops` for the static exporters, `colormapStops` +# in `js/src/10_colormaps.ts` for the client — so both accept either form and +# the native rasterizer, which is handed explicit stops by Python, needs no +# change at all. Two stops is the floor: one color is a constant, not a scale. +MIN_COLORMAP_STOPS = 2 +# The client interpolates a 256-texel LUT, so more stops than texels cannot +# add detail and would only bloat the spec. +MAX_COLORMAP_STOPS = 256 + +# What a caller may pass, and what the wire carries after resolution. Keeping +# them separate is what lets the type checker catch a raw user colormap being +# handed to a renderer that only understands names or stops. +ColormapLike: TypeAlias = "str | Sequence[str]" +ResolvedColormap: TypeAlias = "str | list[list[int]]" +# `resolve_colormap` is idempotent, so any consumer may be handed either form. +ColormapArg: TypeAlias = "str | Sequence[str] | list[list[int]]" + + +def _is_resolved_stops(value: Any) -> bool: + """Whether `value` is already a resolved `(r, g, b)` stop list.""" + return ( + isinstance(value, list) + and bool(value) + and all( + isinstance(stop, (list, tuple)) + and len(stop) == 3 + and all(isinstance(channel, (int, np.integer)) for channel in stop) + for stop in value + ) + ) + + +def resolve_colormap(colormap: Any, label: str = "colormap") -> ResolvedColormap: + """Normalize `colormap=` to a built-in name or an explicit RGB stop list. + + Accepts a built-in name (`"viridis"`, `"viridis_r"`) or a sequence of two + or more CSS colors interpolated evenly across the domain. A bad name still + raises the historical message; a bad *sequence* names the offending entry, + because "unknown colormap ['#f00', 'nope']" would send the caller looking + for a colormap by that name. + """ + if is_colormap(colormap): + return colormap + if _is_resolved_stops(colormap): + # Idempotent: a mark may resolve once for its own use and hand the + # result to `resolve_color`, which resolves again. + return [[int(r), int(g), int(b)] for r, g, b in colormap] + if isinstance(colormap, str): + raise ValueError(f"unknown colormap {colormap!r}; known: {COLORMAPS}") + if isinstance(colormap, (bytes, Mapping)) or not isinstance(colormap, Sequence): + raise ValueError( + f"{label} must be a built-in colormap name or a sequence of CSS colors, " + f"got {type(colormap).__name__}" + ) + entries = list(colormap) + if not (MIN_COLORMAP_STOPS <= len(entries) <= MAX_COLORMAP_STOPS): + raise ValueError( + f"{label} colors must be between {MIN_COLORMAP_STOPS} and " + f"{MAX_COLORMAP_STOPS} CSS colors, got {len(entries)}" + ) + stops: list[list[int]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, str): + raise ValueError(f"{label} color {index} must be a CSS color string, got {entry!r}") + status, rgba = kernels.css_check(kernels.CSS_COLOR, entry) + if status != 1 or rgba is None: + # Shape-checked browser forms (`var()`, `color-mix()`) resolve in + # a cascade the exporters and the LUT builder do not run, so a + # colormap stop has to be a color this process can actually + # sample — reject it here rather than paint a silent fallback. + raise ValueError( + f"{label} color {index} {entry!r} is not a color XY can resolve to RGB; " + "use a hex, rgb(), hsl(), or named color" + ) + stops.append([round(channel * 255) for channel in rgba[:3]]) + return stops + + # The client palette LUT is 256 texels; categories beyond this collide in the # shader, so we warn (channels.resolve_color). MAX_CATEGORIES = 256 @@ -81,7 +162,8 @@ class ColorChannel: # for the axis/legend readout (exact, f64 — never through f32, §16). values: Optional[npt.NDArray[np.float64]] = None domain: Optional[tuple[float, float]] = None - colormap: str = DEFAULT_COLORMAP + # A built-in name, or explicit `(r, g, b)` stops for a custom colormap. + colormap: ResolvedColormap = DEFAULT_COLORMAP # Declarative source of the continuous values (the `color="temperature"` # column-name idiom). Legend/colorbar chrome uses it when the trace itself # is unnamed; with neither name nor label the encoding gets no legend row. @@ -395,7 +477,7 @@ def resolve_color( color: Any, n: int, *, - colormap: str = DEFAULT_COLORMAP, + colormap: ColormapArg = DEFAULT_COLORMAP, default_constant: str, domain: Optional[tuple[float, float]] = None, ) -> ColorChannel: @@ -408,14 +490,7 @@ def resolve_color( `domain` pins the continuous normalization window (matplotlib's vmin/vmax); values outside clip to the colormap ends. """ - if not is_colormap(colormap): - if not isinstance(colormap, str): - raise ValueError( - f"colormap must be one of the built-in names, got {type(colormap).__name__}. " - "A custom stop list is not supported; pass an (n, 3) or (n, 4) float array " - f"as color= for arbitrary per-point color. Known: {COLORMAPS}" - ) - raise ValueError(f"unknown colormap {colormap!r}; known: {COLORMAPS}") + resolved = resolve_colormap(colormap) if domain is not None: lo, hi = float(domain[0]), float(domain[1]) if not (np.isfinite(lo) and np.isfinite(hi)) or hi <= lo: @@ -426,12 +501,12 @@ def resolve_color( # ramp when the trace aggregates (§5 Tier 2), and a typo'd name must # error here rather than render a silently wrong ramp. if color is None: - return ColorChannel(mode="constant", constant=default_constant, colormap=colormap) + return ColorChannel(mode="constant", constant=default_constant, colormap=resolved) if isinstance(color, str): # Literal constant color: validated against the native CSS grammar so # a typo errors here instead of rendering a silently wrong mark. return ColorChannel( - mode="constant", constant=_validate.css_color(color, "color"), colormap=colormap + mode="constant", constant=_validate.css_color(color, "color"), colormap=resolved ) if hasattr(color, "to_numpy"): @@ -493,7 +568,7 @@ def resolve_color( mode="continuous", values=vals, domain=domain if domain is not None else _continuous_domain(vals), - colormap=colormap, + colormap=resolved, ) @@ -544,7 +619,7 @@ def quantize_unit_u8(values: npt.NDArray[np.float64], domain: tuple[float, float return np.rint(np.clip(unit, 0.0, 1.0) * 255.0).astype(np.uint8) -def colormap_lut_rgba8(colormap: str) -> npt.NDArray[np.uint8]: +def colormap_lut_rgba8(colormap: ResolvedColormap) -> npt.NDArray[np.uint8]: """The client's 256-texel colormap LUT as (256, 4) straight-alpha RGBA8. Built from the same stop tables the SVG exporter mirrors from diff --git a/python/xy/components.py b/python/xy/components.py index 81ca9c12..31586673 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -304,6 +304,9 @@ class Theme(Component): """Chart-wide style tokens (plot background, grid/axis/text colors).""" style: dict[str, StyleValue] = field(default_factory=dict) + # Series/category color cycle. Kept beside `style` rather than inside it + # because a token is one CSS declaration and a palette is an ordered list. + palette: Optional[list[str]] = None @dataclass @@ -511,7 +514,7 @@ def scatter( color: Union[str, ColorLike, ArrayLike, None] = None, size: Union[str, Scalar, ArrayLike, None] = 4.0, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, color_domain: Optional[tuple[float, float]] = None, size_range: tuple[float, float] = (2.0, 18.0), opacity: Any = 0.8, @@ -855,7 +858,7 @@ def segments( data: TableLike = None, name: Optional[str] = None, color: Union[str, ColorLike, ArrayLike, None] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, width: Any = 1.2, opacity: Any = 1.0, @@ -915,7 +918,7 @@ def triangle_mesh( *, data: TableLike = None, color: Union[str, ColorLike, ArrayLike, None] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, name: Optional[str] = None, opacity: Any = 1.0, @@ -1315,7 +1318,7 @@ def hexbin( reduce_C_function: Callable[[np.ndarray], Scalar] = np.mean, mincnt: Optional[int] = None, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, opacity: float = 0.9, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, @@ -1374,7 +1377,7 @@ def contour( levels: Union[int, ArrayLike] = 10, filled: bool = False, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, color: Optional[str] = None, width: float = 1.1, opacity: float = 0.9, @@ -1709,7 +1712,7 @@ def heatmap( y: Union[str, ArrayLike, None] = None, data: TableLike = None, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, opacity: float = 0.95, style: Optional[dict[str, StyleValue]] = None, @@ -2657,6 +2660,9 @@ def theme( crosshair_color: Optional[StyleValue] = None, selection_color: Optional[StyleValue] = None, selection_fill: Optional[StyleValue] = None, + palette: Optional[Sequence[str]] = None, + font_family: Optional[str] = None, + font_size: Optional[float] = None, **tokens: StyleValue, ) -> Theme: """Configure chart theme tokens. @@ -2675,6 +2681,19 @@ def theme( crosshair_color: Hover crosshair color. selection_color: Selection-outline color. selection_fill: Selection-region fill color. + palette: Series and category color cycle, replacing the shipped + default. Series take colors in order and repeat once the list runs + out. XY's default eight are validated for color-vision deficiency + (``config.DEFAULT_PALETTE``); a replacement is not checked, so + verify a brand palette stays distinguishable. + font_family: Chart-wide font stack, applied to every text element the + chart draws. Honored by the browser client and the SVG and PDF + exports; the native raster exporter draws with a baked bitmap + font, so PNG/JPEG/WebP keep their own face (see + ``Engine.chromium`` for pixel-exact browser text). + font_size: Base chart text size in px. Every text element scales from + it, so tick labels, axis titles, legends, and the chart title keep + their relative proportions. **tokens: Additional supported theme tokens. """ merged = _style_dict(style, "theme style") @@ -2693,9 +2712,44 @@ def theme( "theme", ) ) + if font_family is not None: + # Routed through the same declaration check as any other style value, + # so a font stack cannot smuggle CSS into the chrome or the SVG. + merged.update(_style_dict({"font-family": font_family}, "theme font_family")) + if font_size is not None: + merged["font-size"] = _theme_font_size(font_size) if tokens: merged.update(_theme_tokens(tokens, "theme tokens")) - return Theme(style=merged) + return Theme(style=merged, palette=_theme_palette(palette)) + + +# Below ~6px text stops being text on any renderer, and a runaway size blows +# the reserved axis/legend room rather than producing a bigger chart. +_MIN_FONT_SIZE = 6.0 +_MAX_FONT_SIZE = 48.0 + + +def _theme_font_size(value: Any) -> float: + size = _finite_number(value, "theme font_size") + if not _MIN_FONT_SIZE <= size <= _MAX_FONT_SIZE: + raise ValueError( + f"theme font_size must be between {_MIN_FONT_SIZE:g} and {_MAX_FONT_SIZE:g} px, " + f"got {size:g}" + ) + return size + + +def _theme_palette(value: Any) -> Optional[list[str]]: + if value is None: + return None + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise ValueError("theme palette must be a sequence of CSS color strings") + colors = [ + _validate.css_color(entry, f"theme palette[{index}]") for index, entry in enumerate(value) + ] + if not colors: + raise ValueError("theme palette must contain at least one color") + return colors def interaction_config( @@ -3073,6 +3127,10 @@ def figure(self) -> Figure: fig.style = {} for theme_node in themes: fig.style.update(theme_node.style) + # Last theme with a palette wins, matching how the style tokens + # above merge; a theme that sets no palette leaves the prior one. + if theme_node.palette is not None: + fig.palette = _theme_palette(theme_node.palette) fig.style.update(self.style) chart_animation = animations[-1] if animations else None if chart_animation is not None: @@ -3906,7 +3964,11 @@ def _declarative_colorbar_options(mark: Mark, traces: list[Any]) -> Optional[dic continue options = { "domain": [float(domain[0]), float(domain[1])], - "colormap": str(colormap), + # Either a built-in name or resolved RGB stops — `str()` here would + # turn a custom colormap's stops into a name no renderer knows, and + # the bar would silently wear viridis while the marks wore the + # user's ramp. + "colormap": colormap if not isinstance(colormap, str) else str(colormap), } if options is None: diff --git a/python/xy/marks.py b/python/xy/marks.py index 1db275ab..e20f2c83 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -319,7 +319,7 @@ def segments( *, name: Optional[str] = None, color: Union[str, ArrayLike, None] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, width: Any = 1.2, opacity: Any = 1.0, @@ -369,7 +369,7 @@ def triangle_mesh( y2: ArrayLike, *, color: Union[str, ArrayLike, None] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, name: Optional[str] = None, opacity: Any = 1.0, @@ -1343,7 +1343,7 @@ def scatter( zoom_size_factor: float = 1.0, zoom_opacity: Optional[float] = None, zoom_emphasis: float = 16.0, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, color_domain: Optional[tuple[float, float]] = None, size_range: tuple[float, float] = (2.0, 18.0), density: Optional[bool] = None, @@ -1946,7 +1946,7 @@ def hexbin( reduce_C_function: Callable[[np.ndarray], Scalar] = np.mean, mincnt: Optional[int] = None, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, opacity: float = 0.9, style: styles.StyleMapping | None = None, ) -> "Figure": @@ -1977,8 +1977,7 @@ def hexbin( raise ValueError("hexbin bins must be 'count' or 'log'") name = self._optional_text(name, "hexbin name") opacity = self._opacity(opacity, "hexbin opacity") - if not channels.is_colormap(colormap): - raise ValueError(f"unknown colormap {colormap!r}; known: {channels.COLORMAPS}") + resolved_colormap = channels.resolve_colormap(colormap) # Canonicalize WITHOUT ingesting: only occupied bin centers ship, so the # raw points must not stay resident in the figure's column store. x_all, _x_kind, _x_copies = columns._canonicalize(x) @@ -2057,7 +2056,7 @@ def hexbin( reduced.append(float(made)) metric = np.asarray(reduced, dtype=np.float64) color_ch = channels.resolve_color( - metric, len(metric), colormap=colormap, default_constant=DEFAULT_PALETTE[0] + metric, len(metric), colormap=resolved_colormap, default_constant=DEFAULT_PALETTE[0] ) checkpoint = self._checkpoint() try: @@ -2137,7 +2136,7 @@ def contour( levels: Union[int, ArrayLike] = 10, filled: bool = False, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, color: Optional[str] = None, width: float = 1.1, opacity: float = 0.9, @@ -2185,8 +2184,7 @@ def contour( raise ValueError( f"contour grid x levels exceeds the bounded work budget ({MAX_CONTOUR_WORK:,})" ) - if not channels.is_colormap(colormap): - raise ValueError(f"unknown colormap {colormap!r}; known: {channels.COLORMAPS}") + resolved_colormap = channels.resolve_colormap(colormap) name = self._optional_text(name, "contour name") color = self._optional_css_color(color, "contour color") width = self._positive_scalar(width, "contour width") @@ -2214,7 +2212,7 @@ def contour( x=dense_x, y=dense_y, name=name, - colormap=colormap, + colormap=resolved_colormap, domain=(float(edges[0]), float(edges[-1])), opacity=min(opacity, 0.9), ) @@ -2224,7 +2222,7 @@ def contour( x=x, y=y, name=name, - colormap=colormap, + colormap=resolved_colormap, opacity=min(opacity, 0.7), ) x0, x1, y0, y1, level_values = _contour_segments(arr, xpos, ypos, level_values) @@ -2233,7 +2231,7 @@ def contour( domain = self._auto_domain((float(np.min(level_values)), float(np.max(level_values)))) color_ch = ( channels.ColorChannel( - mode="continuous", values=level_values, domain=domain, colormap=colormap + mode="continuous", values=level_values, domain=domain, colormap=resolved_colormap ) if color is None else None @@ -2387,7 +2385,7 @@ def heatmap( x: Optional[ArrayLike] = None, y: Optional[ArrayLike] = None, name: Optional[str] = None, - colormap: str = channels.DEFAULT_COLORMAP, + colormap: channels.ColormapArg = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, opacity: float = 0.95, style: styles.StyleMapping | None = None, @@ -2424,8 +2422,10 @@ def heatmap( x_edges = self._cell_edges(xpos, "heatmap x") y_edges = self._cell_edges(ypos, "heatmap y") z_flat = zv.reshape(-1) - if not truecolor and not channels.is_colormap(colormap): - raise ValueError(f"unknown colormap {colormap!r}; known: {channels.COLORMAPS}") + # A truecolor heatmap carries its own RGB and never samples a ramp, so its + # unused `colormap=` stays unvalidated as it always has — but it still has + # to reach the spec, where nothing reads it. + resolved_colormap = colormap if truecolor else channels.resolve_colormap(colormap) explicit_domain = ( None if truecolor or domain is None @@ -2470,7 +2470,7 @@ def heatmap( style={ "opacity": opacity, "role": "heatmap", - "colormap": colormap, + "colormap": resolved_colormap, "domain": [lo, hi], "truecolor": truecolor, "x_range": [float(x_edges[0]), float(x_edges[-1])], diff --git a/spec/api/styling.md b/spec/api/styling.md index c3e95ded..4f9f1cc0 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -600,6 +600,21 @@ Python, no browser, no extra dependencies. Because decimation runs first, the file is **screen-bounded**: a 10M-point line exports in ~4 ms as a ~58 KB SVG. Density/heatmap tiers embed as compact rasters. +### Chrome the static exporters draw + +`_svg.render_svg` and `_raster` share one layout (`_svg.layout`) and one legend +model (`_svg.legend_entries`), so their chrome matches the client's: + +- **Categorical legends.** A `color=` channel that resolves to categories + contributes one legend row per category, wearing that category's palette + slot — the same rule as the client's `color.mode === "categorical"` branch. + Before this, only *named traces* produced rows, so a scatter colored by a + category column exported with no legend at all while the browser drew one. +- **Rule and band labels.** `xy.hline(..., text=)`, `vline`, `x_band`/`y_band` + render their own label, anchored by `_svg._rule_label_anchor` — the port of + the client's placement in `js/src/51_annotations.ts`. A label whose scale + maps it off the finite plane is dropped rather than emitted as `x="nan"`. + `fig.to_png(path?, width=, height=, scale=)` defaults to `engine=xy.Engine.default`: the built-in **Rust rasterizer** paints that same decimated payload — no browser and diff --git a/spec/design-dossier.md b/spec/design-dossier.md index e0765edd..3accd576 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -430,7 +430,7 @@ re-exports several of them as a historic import path and is not listed for those | `LOD_POINT_CACHE_WINDOWS` | `3` | Retired exact point windows kept per trace client-side beyond the live drill; LRU-bounded VRAM, swept by the T11 outgrown rule. | `js/src/45_lod.ts` (`lodRetireDrill`, `lodPromoteCachedDrill`) | | `LOD_POINTS_REQUEST_BAND` | `4` | The aggregate tier never refines per view (T13, revised): a raw-view `density_view` goes out only when the estimated in-view count sits within `budget × 4` of points territory — the LOWER of an area-scaled cached-window count and the retained sample counted in-view (`lodSampleViewCount`, distribution-true where area-scaling over-estimates sparse tails). | `js/src/45_lod.ts` (`lodAggregateStands`) | | `LOD_AGG_STEP_FACTOR` / `LOD_AGG_STEP_MAX` / `LOD_AGG_STEP_SLACK` | `4` / `2` / `1.5` | The stepped aggregate ladder (T13): while standing, the only density request is the view snapped outward to a power-of-4 block grid over the extent (per axis), at most 2 steps below home, and only when every covering texture is coarser than the step by more than the slack. Quantized windows are pan-stable and dedupable — at most 2 smooth-to-smooth swaps before points, worst-case softness ≈ 4× stretch per axis. | `js/src/45_lod.ts` (`lodAggregateStepWindow`) | -| `DEFAULT_PALETTE` | 10 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | +| `DEFAULT_PALETTE` | 8 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). `xy.theme(palette=[...])` replaces it per chart (`Figure.palette` → `_payload._series_palette`); the default's adjacency order is the CVD gate, so a user palette carries no such guarantee and skips the eight-slot wrap warning. | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | | `PYRAMID_MIN_POINTS` | `2_000_000` | Trace size at/above which a Tier-3 tile pyramid is built lazily; smaller traces never pay for one. | `interaction.py` | | `PYRAMID_BASE_DIM` | `2048` | Edge of the pyramid's base level in cells (`dim²` u32 counts, ~1/3 overhead for the coarser levels); sets resident pyramid bytes. | `interaction.py` | @@ -1323,8 +1323,13 @@ author expects. - *Pending:* a **series-palette token** (`--chart-series-N`, indexed rather than a space-separated list so entries cascade and override individually, cycling with a lightness rotation past the highest defined index) and a **colormap token** - (`--chart-colormap`, named ramp or stops → LUT texture). Neither is wired: series - colors and colormaps come from the spec / `theme()` only. The categorical and + (`--chart-colormap`, named ramp or stops → LUT texture). Neither CSS token is wired: + series colors and colormaps come from the spec / `theme()` only. Both are settable + from Python — `xy.theme(palette=[...])` for the series/category cycle and + `colormap=["#…", …]` on any colormapped mark for a custom continuous ramp, which + ships resolved `(r, g, b)` stops in place of a name so `colormapStops` + (`js/src/10_colormaps.ts`) and `_svg._colormap_stops` accept either form and the + native rasterizer, already handed explicit stops, is unchanged. The categorical and sequential defaults are the accessible, CVD-safe palettes from §20, not arbitrary. - **Live re-resolution.** The client watches `matchMedia('(prefers-color-scheme: dark)')` and a `MutationObserver` on the container's `class`/`data-theme`/`style`, re-resolving diff --git a/tests/test_colorbar_format.py b/tests/test_colorbar_format.py index 4b7b2698..3319db7f 100644 --- a/tests/test_colorbar_format.py +++ b/tests/test_colorbar_format.py @@ -100,7 +100,8 @@ def test_explicit_padding_is_never_second_guessed() -> None: def test_the_gutter_is_capped_so_one_label_cannot_eat_the_plot() -> None: axes = {"y": {"id": "y", "range": [0.0, 1.0], "format": "$,.8f" + "x" * 40, "label": "y"}} - assert _svg._left_axis_room(axes, 300.0, 62.0) <= 62.0 + _svg._Y_EXTRA_ROOM_CAP + room = _svg._left_axis_room(axes, 300.0, 62.0, _svg._BASE_FONT_SIZE) + assert room <= 62.0 + _svg._Y_EXTRA_ROOM_CAP def test_a_hidden_axis_contributes_no_gutter() -> None: diff --git a/tests/test_colormaps_and_palette.py b/tests/test_colormaps_and_palette.py new file mode 100644 index 00000000..81bdf815 --- /dev/null +++ b/tests/test_colormaps_and_palette.py @@ -0,0 +1,158 @@ +"""Custom colormaps and a settable series palette.""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy +from xy import channels +from xy.config import DEFAULT_PALETTE + +# --------------------------------------------------------------------------- +# Custom colormaps +# --------------------------------------------------------------------------- + +CUSTOM_CMAP = ["#0d1b2a", "#1b6ca8", "#48cae4", "#ffd166", "#ef476f"] + + +def test_custom_colormap_resolves_to_rgb_stops() -> None: + assert channels.resolve_colormap(["#ff0000", "#0000ff"]) == [[255, 0, 0], [0, 0, 255]] + assert channels.resolve_colormap("viridis") == "viridis" + + +def test_custom_colormap_paints_marks_and_the_colorbar() -> None: + values = np.linspace(0.0, 1.0, 200) + chart = xy.scatter_chart( + xy.scatter(np.arange(200.0), values, color=values, colormap=CUSTOM_CMAP), + xy.colorbar(title="t"), + ) + svg = chart.to_svg() + stops = re.findall(r'stop-color="rgb\(([^)]+)\)"', svg) + assert stops == ["13,27,42", "27,108,168", "72,202,228", "255,209,102", "239,71,111"] + + +def test_custom_colormap_reaches_the_wire_for_the_browser() -> None: + values = np.linspace(0.0, 1.0, 50) + figure = xy.scatter_chart( + xy.scatter(np.arange(50.0), values, color=values, colormap=CUSTOM_CMAP) + ).figure() + spec, _blob = figure.build_payload() + assert spec["traces"][0]["color"]["colormap"] == [ + [13, 27, 42], + [27, 108, 168], + [72, 202, 228], + [255, 209, 102], + [239, 71, 111], + ] + + +def test_custom_colormap_works_on_heatmaps() -> None: + grid = np.add.outer(np.linspace(0.0, 1.0, 8), np.linspace(0.0, 1.0, 8)) + svg = xy.heatmap_chart(xy.heatmap(z=grid, colormap=CUSTOM_CMAP)).to_svg() + assert svg.startswith(" None: + with pytest.raises(ValueError, match=message): + channels.resolve_colormap(colormap) + + +# --------------------------------------------------------------------------- +# Series palette +# --------------------------------------------------------------------------- + +BRAND = ["#ff5c00", "#111827", "#00b3a4", "#8b5cf6"] + + +def test_theme_palette_colors_unnamed_series() -> None: + x = np.arange(6.0) + chart = xy.line_chart( + *[xy.line(x, x + index, name=f"s{index}") for index in range(4)], + xy.theme(palette=BRAND), + ) + svg = chart.to_svg().lower() + assert all(color in svg for color in BRAND) + + +def test_theme_palette_colors_a_categorical_channel() -> None: + codes = np.array(["a", "b", "c", "d"] * 25) + figure = xy.scatter_chart( + xy.scatter(np.arange(100.0), np.arange(100.0), color=codes), + xy.theme(palette=BRAND), + ).figure() + spec, _blob = figure.build_payload() + assert spec["traces"][0]["color"]["palette"] == BRAND + + +def test_palette_repeats_once_it_runs_out() -> None: + x = np.arange(4.0) + figure = xy.line_chart( + *[xy.line(x, x + index, name=f"s{index}") for index in range(6)], + xy.theme(palette=BRAND[:2]), + ).figure() + spec, _blob = figure.build_payload() + colors = [trace["style"]["color"] for trace in spec["traces"]] + assert colors == [BRAND[0], BRAND[1]] * 3 + + +def test_no_palette_keeps_the_cvd_validated_default() -> None: + x = np.arange(4.0) + figure = xy.line_chart(*[xy.line(x, x + i, name=f"s{i}") for i in range(3)]).figure() + spec, _blob = figure.build_payload() + assert [trace["style"]["color"] for trace in spec["traces"]] == DEFAULT_PALETTE[:3] + + +def test_a_user_palette_skips_the_default_palette_wrap_warning() -> None: + """The eight-slot warning polices XY's CVD guarantee, which a user palette + never claimed — warning about it would be noise. + + Uses more series than the DEFAULT palette has slots, so the warning would + definitely fire if the user palette still went through + `config.default_palette_color`. + """ + import warnings + + x = np.arange(3.0) + marks = [xy.line(x, x + index, name=f"s{index}") for index in range(len(DEFAULT_PALETTE) + 3)] + + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + xy.line_chart(*marks, xy.theme(palette=BRAND)).figure().build_payload() + assert [str(record.message) for record in records] == [] + + # Same chart on the shipped default still warns, so the assertion above is + # about the palette source and not about the warning being unreachable. + with warnings.catch_warnings(record=True) as default_records: + warnings.simplefilter("always") + xy.line_chart(*marks).figure().build_payload() + assert any("default palette repeats" in str(r.message) for r in default_records) + + +@pytest.mark.parametrize( + ("palette", "message"), + [ + ("#ff0000", "must be a sequence"), + ([], "at least one color"), + (["not-a-color"], "theme palette"), + ], +) +def test_bad_palettes_raise(palette, message) -> None: + with pytest.raises(ValueError, match=message): + xy.theme(palette=palette) diff --git a/tests/test_static_chrome_parity.py b/tests/test_static_chrome_parity.py new file mode 100644 index 00000000..4e22149b --- /dev/null +++ b/tests/test_static_chrome_parity.py @@ -0,0 +1,117 @@ +"""Legend rows and annotation labels the browser drew and the exporters did not.""" + +from __future__ import annotations + +import numpy as np + +import xy +from xy import _svg +from xy.config import DEFAULT_PALETTE + +BRAND = ["#ff5c00", "#111827", "#00b3a4", "#8b5cf6"] + + +# --------------------------------------------------------------------------- +# Static-export parity for legends and annotation labels +# --------------------------------------------------------------------------- + + +def categorical_scatter() -> xy.Chart: + codes = np.array(["alpha", "beta", "gamma"] * 40) + rng = np.random.default_rng(3) + return xy.scatter_chart( + xy.scatter(rng.normal(size=120), rng.normal(size=120), color=codes, size=7), + xy.legend(), + ) + + +def test_categorical_legend_rows_reach_static_exports() -> None: + """The browser drew one row per category; the exporters drew no legend.""" + svg = categorical_scatter().to_svg() + assert all(category in svg for category in ("alpha", "beta", "gamma")) + + +def test_categorical_legend_swatches_wear_the_category_colors() -> None: + entries = _svg.legend_entries(categorical_scatter().figure().build_payload()[0]) + assert [entry["name"] for entry in entries] == ["alpha", "beta", "gamma"] + assert [entry["style"]["color"] for entry in entries] == DEFAULT_PALETTE[:3] + + +def test_categorical_legend_follows_the_theme_palette() -> None: + codes = np.array(["a", "b"] * 20) + chart = xy.scatter_chart( + xy.scatter(np.arange(40.0), np.arange(40.0), color=codes), + xy.legend(), + xy.theme(palette=BRAND), + ) + entries = _svg.legend_entries(chart.figure().build_payload()[0]) + assert [entry["style"]["color"] for entry in entries] == BRAND[:2] + + +def test_named_series_legends_are_unchanged() -> None: + chart = xy.line_chart( + xy.line([1, 2], [1, 2], name="north"), xy.line([1, 2], [2, 1], name="south"), xy.legend() + ) + entries = _svg.legend_entries(chart.figure().build_payload()[0]) + assert [entry["name"] for entry in entries] == ["north", "south"] + + +def test_unnamed_traces_still_get_no_legend_row() -> None: + chart = xy.line_chart(xy.line([1, 2], [1, 2]), xy.legend()) + assert _svg.legend_entries(chart.figure().build_payload()[0]) == [] + + +def annotated_chart() -> xy.Chart: + return xy.line_chart( + xy.line([1, 2, 3, 4], [1, 2, 3, 4]), + xy.hline(2.5, text="target", color="#dc2626"), + xy.vline(3.0, text="launch"), + xy.y_band(1.2, 1.8, text="tolerance"), + xy.text(2.0, 1.5, "inline note"), + ) + + +def test_rule_and_band_labels_reach_the_svg() -> None: + svg = annotated_chart().to_svg() + assert all(label in svg for label in ("target", "launch", "tolerance", "inline note")) + + +def test_rule_and_band_labels_reach_the_native_png(tmp_path) -> None: + labelled = tmp_path / "labelled.png" + bare = tmp_path / "bare.png" + annotated_chart().to_png(str(labelled), width=520, height=320) + xy.line_chart( + xy.line([1, 2, 3, 4], [1, 2, 3, 4]), + xy.hline(2.5, color="#dc2626"), + xy.vline(3.0), + xy.y_band(1.2, 1.8), + ).to_png(str(bare), width=520, height=320) + assert labelled.read_bytes() != bare.read_bytes() + + +def test_rule_labels_anchor_where_the_client_puts_them() -> None: + plot = {"x": 100.0, "y": 50.0, "w": 400.0, "h": 300.0} + identity = float + + x_rule = _svg._rule_label_anchor({"axis": "x", "value": 7.0}, "rule", identity, identity, plot) + assert x_rule == (7.0, 56.0, "start", "top") + + y_rule = _svg._rule_label_anchor({"axis": "y", "value": 7.0}, "rule", identity, identity, plot) + assert y_rule == (494.0, 7.0, "end", "") + + x_band = _svg._rule_label_anchor( + {"axis": "x", "start": 2.0, "end": 4.0}, "band", identity, identity, plot + ) + assert x_band == (3.0, 56.0, "middle", "top") + + +def test_a_rule_label_at_a_non_finite_pixel_is_skipped_not_emitted() -> None: + """A scale can map a rule off the finite plane; the label must be dropped + rather than emitted as `x="nan"`, which is an invalid SVG document.""" + plot = {"x": 100.0, "y": 50.0, "w": 400.0, "h": 300.0} + annotations = [{"kind": "rule", "axis": "y", "value": 1.0, "text": "ghost", "style": {}}] + marks, labels = _svg._annotation_svg( + annotations, float, lambda _v: float("inf"), plot, 600.0, 400.0 + ) + assert labels == [] + assert not any("nan" in mark or "inf" in mark.lower() for mark in labels) diff --git a/tests/test_theme_typography.py b/tests/test_theme_typography.py new file mode 100644 index 00000000..bb785843 --- /dev/null +++ b/tests/test_theme_typography.py @@ -0,0 +1,69 @@ +"""Chart-wide typography: `theme(font_family=)` and `theme(font_size=)`.""" + +from __future__ import annotations + +import re + +import pytest + +import xy + +# --------------------------------------------------------------------------- +# Typography +# --------------------------------------------------------------------------- + + +def titled_chart(**theme_kwargs) -> xy.Chart: + children = [xy.line([1, 2, 3], [1, 2, 3]), xy.x_axis(label="x")] + if theme_kwargs: + children.append(xy.theme(**theme_kwargs)) + return xy.line_chart(*children, title="T") + + +def test_theme_font_family_reaches_the_svg() -> None: + svg = titled_chart(font_family="Georgia, serif").to_svg() + assert 'font-family="Georgia, serif"' in svg + assert "system-ui" not in svg + + +def test_theme_font_size_scales_every_chrome_size_together() -> None: + default = set(re.findall(r'font-size="([^"]+)"', titled_chart().to_svg())) + assert default == {"11", "12", "14"} + scaled = set(re.findall(r'font-size="([^"]+)"', titled_chart(font_size=22).to_svg())) + # 22/11 = exactly 2x the defaults, so the proportions are preserved. + assert scaled == {"22", "24", "28"} + + +def test_an_unthemed_chart_keeps_its_pre_existing_font_chrome() -> None: + """The font tokens must be a pure addition: with no theme, the root font + stack and every chrome size are exactly what they were before.""" + svg = titled_chart().to_svg() + assert "font-family=\"system-ui, -apple-system, 'Segoe UI', sans-serif\"" in svg + assert 'font-size="11"' in svg # root / tick labels + assert 'font-size="12"' in svg # axis title + assert 'font-size="14"' in svg # chart title + + +def test_font_size_is_bounded() -> None: + with pytest.raises(ValueError, match="between 6 and 48"): + xy.theme(font_size=200) + with pytest.raises(ValueError, match="between 6 and 48"): + xy.theme(font_size=1) + + +def test_font_family_is_declaration_checked() -> None: + with pytest.raises(ValueError): + xy.theme(font_family="Georgia; background:url(evil)") + + +def test_font_size_changes_the_native_png_even_though_the_family_cannot(tmp_path) -> None: + plain = tmp_path / "plain.png" + sized = tmp_path / "sized.png" + serif = tmp_path / "serif.png" + titled_chart().to_png(str(plain), width=420, height=260) + titled_chart(font_size=18).to_png(str(sized), width=420, height=260) + titled_chart(font_family="Georgia, serif").to_png(str(serif), width=420, height=260) + assert sized.read_bytes() != plain.read_bytes() + # The native rasterizer draws a baked bitmap face; the family is honored by + # SVG/PDF and the browser instead (docs/styling/themes-and-tokens.md). + assert serif.read_bytes() == plain.read_bytes()