Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion docs/styling/themes-and-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions js/src/10_colormaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions js/src/20_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 6 additions & 2 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 31 additions & 8 deletions python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
40 changes: 32 additions & 8 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ._svg import (
_AXIS,
_AXIS_GRID_DASHES,
_BASE_FONT_SIZE,
_GRID,
_STATIC_COLOR_FALLBACK,
_TEXT,
Expand All @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"]),
)
Expand All @@ -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"],
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading