diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa7c7fcb..38412a8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,21 @@ in the README).
## [Unreleased]
+### Migration notes
+- Mark `style={...}` now uses paint-specific CSS: `stroke` for line-like marks
+ and `fill` for filled marks. The legacy `color=` argument remains supported,
+ but `color` is not an alias inside `style`.
+- `MarkStyle` / `mark_style(...)` are removed. Interaction state styling belongs
+ to the host framework (for example, Reflex state, conditions, and event
+ handlers), rather than a second XY state system.
+- PNG export now defaults to the browser-free native renderer. Use
+ `engine=Engine.chromium` for browser CSS/WebGL fidelity; string engine values
+ remain temporary deprecated aliases. Browser executable parameters were
+ removed in favor of automatic discovery or `XY_BROWSER`.
+- Chromium PNG and batch export accept `custom_css=`. Native PNG rejects it,
+ while complete chart-level color tokens such as `var(--accent)` resolve in
+ native SVG/PNG from the chart's own `style` mapping.
+
### Removed
- **The fluent `Figure` API is removed from the public surface.**
`xy.Figure` is no longer exported; `figure.py` is internalized as
diff --git a/README.md b/README.md
index 7af4bb59..911fa8d3 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ variants, and faceted small multiples. It includes direct rendering,
M4 line/area decimation, Tier-2 scatter density, adaptive scatter drilldown,
hover, box select/zoom, standalone HTML export, and static export (`to_svg` and
a browser-free native `to_png` — both millisecond and screen-bounded; `to_png`
-also offers a pixel-exact `engine="chromium"` mode).
+also offers installed-browser fidelity with `engine=fc.Engine.chromium`).
Styling is first-class: every DOM chrome element is a CSS/Tailwind-addressable
slot, and marks take gradient fills, rounded/stroked bars, smooth curves, and
opacity. See [`docs/styling.md`](docs/styling.md) and the full design dossier
@@ -341,14 +341,19 @@ axis labels, trace names, legends, series names, and categories are escaped
before entering inline JSON or `
`, and non-finite JSON metadata is
rejected instead of emitted as browser-dependent JavaScript.
-`Chart.to_png()` defaults to `engine="native"`: the built-in Rust rasterizer
-paints the same decimated payload with no browser — millisecond export, and it
-works anywhere the wheel imports (no Chrome needed). Pass `optimize=True` for
-the slower size-optimized indexed PNG path. Pass
-`engine="chromium"` for a pixel-exact screenshot of the standalone HTML in local
-Chromium (`to_png(..., engine="chromium")` needs a local Chrome/Chromium
-executable); the browser sandbox is on by default — use `sandbox=False` only for
-trusted HTML in CI/container environments where sandboxed Chromium cannot launch.
+`Chart.to_png()` defaults to `engine=fc.Engine.default`: the built-in Rust
+rasterizer paints the same decimated payload with no browser — millisecond
+export, and it works anywhere the wheel imports (no Chrome needed). Pass
+`optimize=True` for the slower size-optimized indexed PNG path. Pass
+`engine=fc.Engine.chromium` to screenshot the standalone HTML with an installed
+supported browser. XY automatically finds
+Chrome, Chromium, Edge, or `chrome-headless-shell`; set `XY_BROWSER` to an
+executable path to select one explicitly. The browser sandbox is on by default —
+use `sandbox=False` only for trusted HTML in CI/container environments where a
+sandboxed browser cannot launch. Browser export also accepts `custom_css="..."`
+for author CSS that should be present in the captured document; the native engine
+rejects that browser-only option. Legacy string engine values remain deprecated
+compatibility aliases.
## Example Apps
diff --git a/benchmarks/README.md b/benchmarks/README.md
index a88fe16e..baf0e6a3 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -155,8 +155,8 @@ and request-to-next-frame measurements.
Set `XY_BENCH_HARDWARE_GL=1` to disable the benchmark helpers' SwiftShader
flags. Artifacts record `environment.browser_renderer=hardware`.
The workflow benchmark measures native Rust PNG separately from the opt-in
-`engine="chromium"` screenshot row. The latter remains `software-gl` because
-the Chromium exporter forces SwiftShader; keep it out of hardware-GPU comparisons.
+`engine=Engine.chromium` screenshot row. The Chromium adapter remains
+`software-gl` because it forces SwiftShader; keep it out of hardware-GPU comparisons.
Record CPU model/core count, RAM, GPU and driver, OS build, power mode, browser
version, Python, Rust, Node, package versions, commit, and ambient workload.
diff --git a/benchmarks/_launch_static.py b/benchmarks/_launch_static.py
index 8aa5b5e1..187c6622 100644
--- a/benchmarks/_launch_static.py
+++ b/benchmarks/_launch_static.py
@@ -55,7 +55,7 @@ def nonblank_png(png: bytes) -> int:
def child_run(library: str, n: int) -> dict[str, Any]:
# Imports are excluded from chart-to-PNG time but included in RSS.
if library == "xy":
- from xy import scatter, scatter_chart
+ from xy import Engine, scatter, scatter_chart
elif library == "plotly":
import plotly.express as px
elif library == "matplotlib":
@@ -71,7 +71,7 @@ def child_run(library: str, n: int) -> dict[str, Any]:
t0 = time.perf_counter()
if library == "xy":
fig = scatter_chart(scatter(x=x, y=y), width=WIDTH, height=HEIGHT).figure()
- png = fig.to_png(width=WIDTH, height=HEIGHT, scale=1, engine="native")
+ png = fig.to_png(width=WIDTH, height=HEIGHT, scale=1, engine=Engine.default)
mode = "density" if fig.traces[0].use_density() else "direct"
elif library == "plotly":
# render_mode remains px.scatter's default "auto".
diff --git a/benchmarks/bench_native_vs_agg.py b/benchmarks/bench_native_vs_agg.py
index 5177cbdf..2915e442 100644
--- a/benchmarks/bench_native_vs_agg.py
+++ b/benchmarks/bench_native_vs_agg.py
@@ -3,7 +3,7 @@
The WebGL export path pays a browser tax (HTML parse + upload) that Matplotlib
never does, so ``xy-webgl 15 s vs matplotlib 5.6 s`` at 100M compares different
pipelines. THIS benchmark removes the browser from both sides: xy's built-in
-Rust rasterizer (``to_png(engine="native")``) against Matplotlib's Agg backend —
+Rust rasterizer (``to_png(engine=Engine.default)``) against Matplotlib's Agg backend —
both go numpy array → CPU rasterize → PNG bytes, in one process, no browser.
Fairness controls:
diff --git a/benchmarks/bench_workflows.py b/benchmarks/bench_workflows.py
index d85de379..fb5120d2 100644
--- a/benchmarks/bench_workflows.py
+++ b/benchmarks/bench_workflows.py
@@ -9,6 +9,7 @@
import argparse
import json
+import os
import statistics
import sys
import time
@@ -318,7 +319,7 @@ def figure() -> Figure:
),
(
"export_png_native_decimated_line",
- lambda fig: fig.to_png(engine="native"),
+ lambda fig: fig.to_png(engine=fc.Engine.default),
_png_oracle,
),
):
@@ -336,19 +337,27 @@ def figure() -> Figure:
)
)
if chromium:
- rows.append(
- _measure(
- scenario="export_png_chromium_decimated_line",
- family="export",
- n=n,
- setup=figure,
- operation=lambda fig: fig.to_png(engine="chromium", chromium=chromium),
- reps=1,
- category_ids=("static_export", "payload_export_size"),
- scope="public-chromium-png-export",
- oracle=_png_oracle,
+ previous_browser = os.environ.get("XY_BROWSER")
+ os.environ["XY_BROWSER"] = chromium
+ try:
+ rows.append(
+ _measure(
+ scenario="export_png_chromium_decimated_line",
+ family="export",
+ n=n,
+ setup=figure,
+ operation=lambda fig: fig.to_png(engine=fc.Engine.chromium),
+ reps=1,
+ category_ids=("static_export", "payload_export_size"),
+ scope="public-chromium-png-export",
+ oracle=_png_oracle,
+ )
)
- )
+ finally:
+ if previous_browser is None:
+ os.environ.pop("XY_BROWSER", None)
+ else:
+ os.environ["XY_BROWSER"] = previous_browser
return rows
diff --git a/benchmarks/test_codspeed_kernels.py b/benchmarks/test_codspeed_kernels.py
index e90275d2..335ed884 100644
--- a/benchmarks/test_codspeed_kernels.py
+++ b/benchmarks/test_codspeed_kernels.py
@@ -33,6 +33,7 @@
AREA_N = 100_000
BAR_N = 1_000
HEATMAP_W, HEATMAP_H = 160, 120
+HEXBIN_GRIDSIZE = 128
EXPORT_N = 100_000
APPEND_N = 100_000
APPEND_BATCH = 1_000
@@ -67,7 +68,7 @@ def warm_lazy_modules() -> None:
fig.build_payload(N_BUCKETS)
fig.build_payload_split(N_BUCKETS)
fig.to_svg(width=64, height=48)
- fig.to_png(engine="native", scale=1.0)
+ fig.to_png(engine=fc.Engine.default, scale=1.0)
fig.to_html()
@@ -638,7 +639,7 @@ def _statistical_payload(values: list[np.ndarray]) -> int:
def _hexbin_payload(x: np.ndarray, y: np.ndarray) -> int:
- fig = fc.chart(fc.hexbin(x=x, y=y, gridsize=128)).figure()
+ fig = fc.chart(fc.hexbin(x=x, y=y, gridsize=HEXBIN_GRIDSIZE)).figure()
_spec, buffers = fig.build_payload_split(N_BUCKETS)
return sum(b.nbytes for b in buffers)
@@ -873,7 +874,11 @@ def test_first_payload_hexbin_core_2d(benchmark, medium_data):
"""Hexbin scans source points through the native screen-sized bin kernel."""
x, y = medium_data
payload_bytes = benchmark(_hexbin_payload, x, y)
- assert 0 < payload_bytes < x.nbytes + y.nbytes
+ grid_height = max(2, int(HEXBIN_GRIDSIZE / np.sqrt(3.0)))
+ max_cells = (HEXBIN_GRIDSIZE + 1) * (grid_height + 1) + HEXBIN_GRIDSIZE * grid_height
+ # Each cell is six triangles with six coordinate and one color f32 buffer.
+ max_payload_bytes = max_cells * 6 * 7 * np.dtype(np.float32).itemsize
+ assert 0 < payload_bytes <= max_payload_bytes
def test_first_payload_errorbar_large(benchmark, data):
@@ -910,7 +915,7 @@ def test_native_png_export_scatter(benchmark, export_data):
"""Native raster export after screen-bounded payload preparation."""
x, y = export_data
fig = fc.chart(fc.scatter(x=x, y=y)).figure()
- png = benchmark(fig.to_png, engine="native", scale=1.0)
+ png = benchmark(fig.to_png, engine=fc.Engine.default, scale=1.0)
assert png.startswith(b"\x89PNG")
@@ -919,7 +924,7 @@ def test_native_png_export_categorical_scatter(benchmark, export_data):
x, y = export_data
categories = np.asarray([f"group-{i % 24:02d}" for i in range(len(x))])
fig = fc.chart(fc.scatter(x=x, y=y, color=categories)).figure()
- png = benchmark(fig.to_png, engine="native", scale=1.0)
+ png = benchmark(fig.to_png, engine=fc.Engine.default, scale=1.0)
assert png.startswith(b"\x89PNG")
@@ -943,7 +948,7 @@ def test_native_png_export_stroked_triangle_mesh(benchmark, compatibility_kernel
stroke_width=0.5,
)
).figure()
- png = benchmark(fig.to_png, engine="native", scale=1.0)
+ png = benchmark(fig.to_png, engine=fc.Engine.default, scale=1.0)
assert png.startswith(b"\x89PNG")
@@ -953,7 +958,7 @@ def test_native_png_export_heatmap(benchmark, core_2d_data):
x = core_2d_data["heatmap_x"]
y = core_2d_data["heatmap_y"]
fig = fc.chart(fc.heatmap(z, x=x, y=y)).figure()
- png = benchmark(fig.to_png, engine="native", scale=1.0)
+ png = benchmark(fig.to_png, engine=fc.Engine.default, scale=1.0)
assert png.startswith(b"\x89PNG")
diff --git a/docs/api-examples.md b/docs/api-examples.md
index dc64244b..18af600a 100644
--- a/docs/api-examples.md
+++ b/docs/api-examples.md
@@ -30,9 +30,13 @@ hatch, but it is not part of the public chart-building surface.
Charts accept `width="100%"` and/or `height="100%"` for responsive layouts.
Standalone `to_html(...)` needs no browser dependency, and `to_png(...)` defaults
-to a browser-free native rasterizer. `to_png(..., engine="chromium")` needs a
-local Chrome/Chromium executable because it screenshots the same standalone HTML
-document for a pixel-exact match to the live chart.
+to a browser-free native rasterizer (`Engine.default`).
+`to_png(..., engine=Engine.chromium)` uses an
+installed Chrome, Chromium, Edge, or `chrome-headless-shell` executable because
+it screenshots the same standalone HTML document for browser CSS/WebGL fidelity.
+Automatic discovery can be overridden with `XY_BROWSER`. Pass `custom_css=` to
+that engine when the screenshot also needs author CSS; native PNG intentionally
+rejects browser-only stylesheets.
## Chart Family Quick Reference
diff --git a/docs/benchmark.md b/docs/benchmark.md
index 286298b1..f53e1763 100644
--- a/docs/benchmark.md
+++ b/docs/benchmark.md
@@ -780,12 +780,13 @@ fails. Re-bless the baseline from a CI run with
### Static image export
-`Figure.to_png()` defaults to the browser-free native Rust rasterizer and its
-latency-oriented PNG encoder; `optimize=True` selects the slower, smaller-file
-path. `engine="chromium"` remains available when a pixel-exact match to the
-live WebGL chart is required (Chromium discovered via env/PATH/Playwright
-cache). Both modes are covered by the PNG tests; HTML export (`to_html`) needs
-nothing extra.
+`Figure.to_png()` defaults to `Engine.default`, the browser-free native Rust
+rasterizer and its latency-oriented PNG encoder; `optimize=True` selects the
+slower, smaller-file path. `engine=Engine.chromium` remains available when
+browser CSS/WebGL fidelity is required (Chrome, Chromium, Edge, or
+`chrome-headless-shell` discovered via
+`XY_BROWSER`, PATH, and common application locations). Both modes are covered
+by the PNG tests; HTML export (`to_html`) needs nothing extra.
---
diff --git a/docs/chart-roadmap.md b/docs/chart-roadmap.md
index 9716d1ff..319b874b 100644
--- a/docs/chart-roadmap.md
+++ b/docs/chart-roadmap.md
@@ -35,7 +35,7 @@ Beyond the mark set, three capability layers now ship on `main`:
- **Mark-level styling (§ "Styling & Theming" below):** CSS `linear-gradient`
fills, rounded (`corner_radius`, independent tip/base) and stroked bars,
line/area dashes, smooth (monotone-cubic) curves, scatter symbols + strokes,
- and per-state `mark_style` colors — all resolved from CSS so the marks obey
+ and mark colors — all resolved from CSS so the marks obey
the same theme tokens as the chrome. Every styling input is validated at
build time by the native CSS grammar (`src/css.rs`, ABI v9): closed grammars
(hex/`rgb()`/`hsl()`/named colors, lengths, numbers) parse strictly,
@@ -45,7 +45,7 @@ Beyond the mark set, three capability layers now ship on `main`:
- **Static export:** `fig.to_svg(...)` (pure-Python, screen-bounded vector —
a 10M-point line exports in ~4 ms / ~58 KB) and `fig.to_png(...)` (a
browser-free native Rust rasterizer by default, ~50× faster than the
- `engine="chromium"` screenshot). Both consume the same decimated payload, so
+ `engine=Engine.chromium` screenshot). Both consume the same decimated payload, so
export cost scales with pixels, not points.
- **Standalone LOD without a kernel:** `to_html` exports now re-bin the
retained density sample in a bundled Web Worker on zoom (off the main
@@ -299,9 +299,8 @@ The marks themselves speak CSS. `fill=` accepts a real CSS
`corner_radius` (scalar or independent `(tip, base)`), `stroke`, and
`stroke_width`; lines and area outlines take `dash` (presets or an on/off
pattern); line/area take `curve="smooth"` (monotone-cubic); scatter takes
-`symbol` (circle/square/diamond/triangle/cross) plus point strokes; and
-`mark_style` sets per-state (hover/selected/unselected) colors. Every color
-flows through the same `--chart-*` tokens as the chrome, so a theme change
+`symbol` (circle/square/diamond/triangle/cross) plus point strokes. Mark colors
+flow through the same `--chart-*` tokens as the chrome, so a theme change
re-resolves marks and chrome together. The full matrix and per-mark support
table live in [`docs/styling.md`](styling.md). Static SVG export reproduces all
of it (gradients → ``, smooth curves → exact cubic Béziers,
@@ -378,12 +377,12 @@ integration and compatibility depth, not re-implementing shipped primitives.
Parallel, non-chart-type tracks:
- **Native PNG rasterizer** (perf) — **shipped** (dossier Phase 3).
- `Chart.to_png(engine="native")`, now the default, paints the decimated
+ `Chart.to_png(engine=Engine.default)`, now the default, paints the decimated
payload with an AA rasterizer in the Rust core (introduced in ABI v8,
`fc_rasterize`) — no browser, ~50× faster than the Chromium screenshot,
fast truecolor PNGs, and a baked bitmap font for text. `optimize=True`
retains the slower indexed-palette path for smaller files;
- `engine="chromium"` stays for a pixel-exact WebGL screenshot.
+ `engine=Engine.chromium` stays for an installed-browser CSS/WebGL screenshot.
- **Reflex-first reactive API** — now the primary post-alpha product track, as
described in step 2 above.
diff --git a/docs/design/reflex-shaped-api.md b/docs/design/reflex-shaped-api.md
index 86bdeef3..eea130b3 100644
--- a/docs/design/reflex-shaped-api.md
+++ b/docs/design/reflex-shaped-api.md
@@ -1,6 +1,6 @@
# Reflex-Shaped API Without A Reflex Dependency
-**Status:** design proposal.
+**Status:** implemented core contract; adapter boundary clarified.
Goal: make XY feel like a Reflex component tree: declarative,
children-first, styleable with normal CSS/Tailwind, and easy to wrap in Reflex
@@ -50,7 +50,8 @@ chart.show() # notebook / IPython display
html = chart.to_html() # standalone HTML
chart.to_html("chart.html") # shareable file
chart._repr_html_() # standalone HTML repr fallback
-chart.to_png("chart.png") # optional Chromium screenshot
+chart.to_png("chart.png") # fast browser-free native PNG
+chart.to_png("browser.png", engine=fc.Engine.chromium) # browser CSS/WebGL fidelity
```
The tree is not a Reflex tree. It is a pure XY Python object graph that
@@ -74,6 +75,22 @@ flowchart LR
TREE --> RX
```
+### Reflex owns application behavior
+
+XY is a renderer, not a second reactive framework. A Reflex integration owns:
+
+- `Var` expressions and computed state;
+- conditional values and conditional styles;
+- click, hover, selection, and other event handlers;
+- component composition, responsive application layout, and lifecycle;
+- application themes, design tokens, and reusable style systems.
+
+XY accepts the concrete values, CSS declarations, classes, data buffers, and
+callbacks supplied by that integration. It emits render output and event
+payloads. XY must not add parallel `field()`, `condition()`, selection-state,
+event-action, layout, or template DSLs; doing so would create two competing
+sources of state and composition semantics.
+
## 2. Design Principles
- **No framework dependency in `xy`.** The package may be easy to wrap
@@ -95,10 +112,10 @@ flowchart LR
`fc.line(...)`, ...) resolves to the single mark implementation in
`marks.py`, so mark names, channel names, defaults, and validation rules
cannot fork.
-- **CSS styles chrome, spec styles marks.** DOM chrome like title, tooltip,
- legend, modebar, and wrapper can be styled with classes and CSS variables.
- WebGL marks are pixels, so their color/opacity/width still come from mark
- props that may resolve CSS color tokens.
+- **CSS vocabulary styles chrome and marks.** DOM chrome receives the normal
+ browser cascade. WebGL/native/SVG marks accept a smaller documented CSS
+ appearance subset which XY compiles to renderer values. Marks remain pixels,
+ not selector-addressable DOM nodes.
- **Data never lives in framework state.** Component props describe bindings and
styling. Large canonical arrays stay in XY column storage and ship as
binary buffers.
@@ -159,18 +176,26 @@ fc.scatter(
data=df,
name="requests",
class_name="fc-mark-latency",
- color_scale="viridis",
+ style={"fill": "var(--accent)", "stroke-width": "1px"},
)
```
Important distinction: `class_name` on a mark is metadata for generated DOM
readouts and adapters. It cannot directly style already-rastered WebGL pixels.
-Mark colors can still use CSS-resolved values:
+Mark appearance uses the cross-renderer CSS subset:
```python
-fc.line(x="t", y="p95", data=df, color="var(--chart-critical)", width=2)
+fc.line(
+ x="t",
+ y="p95",
+ data=df,
+ style={"stroke": "var(--chart-critical)", "stroke-width": "2px"},
+)
```
+The older appearance keywords remain compatibility aliases. When both are
+provided, `style` wins so an adapter has one predictable final override layer.
+
### 3.3 Readout Methods
Every renderable component exposes the full readout surface directly:
@@ -183,7 +208,7 @@ chart.to_html() # standalone string
chart.to_html(path) # write standalone file
chart.html() # alias for to_html()
chart._repr_html_() # notebook/static HTML repr fallback
-chart.to_png(path) # screenshot standalone HTML
+chart.to_png(path) # native PNG by default; Engine.chromium is the browser tier
chart.memory_report()
```
@@ -258,6 +283,10 @@ DOM chrome slots:
| `legend` | Legend container |
| `legend_item` | Legend row |
| `legend_swatch` | Legend color swatch |
+| `colorbar` | Colorbar container |
+| `colorbar_bar` | Colorbar gradient/bands |
+| `colorbar_tick` | Colorbar tick label |
+| `colorbar_title` | Colorbar title |
| `tooltip` | Tooltip container |
| `modebar` | Modebar container |
| `modebar_button` | Modebar button |
@@ -365,10 +394,11 @@ chart = fc.chart(
fc.scatter(x="x", y="y", color="segment", data=df),
fc.legend(rx.vstack(...), show=False),
fc.tooltip(rx.box(...), show=False, fields=["x", "y", "segment"]),
+ fc.colorbar(rx.vstack(...), show=False),
)
chart.chrome_components()
-# {"legend": , "tooltip": }
+# {"legend": , "tooltip": , "colorbar": }
```
The render objects are not serialized into standalone HTML. `show=False`
@@ -427,7 +457,7 @@ The component tree compiles once and can target multiple renderers.
| Notebook widget | `show()` / `widget()` | Python callbacks available |
| Notebook/static HTML repr | `_repr_html_()` | Self-contained fallback that reuses standalone export |
| Standalone HTML | `to_html()` / optional `html()` alias | Self-contained, no Python callbacks |
-| Static PNG | `to_png()` | Screenshots same standalone render |
+| Static PNG | `to_png()` | Fast native default; optional Chromium standalone screenshot |
| Future Reflex | external adapter package | Uses the smallest supported Reflex surface; core does not import Reflex |
| Future server app | generic payload/message routes | Same wire protocol |
@@ -536,7 +566,7 @@ chart = fc.chart(
chart.show() # notebook / IPython
chart.to_html("clusters.html") # standalone shareable file
-chart.to_png("clusters.png") # optional local Chromium screenshot
+chart.to_png("clusters.png") # fast browser-free native PNG
```
The same object compiles to the internal `_figure.Figure`, so engine
@@ -656,6 +686,7 @@ Now part of the core alpha contract:
- Neutral `fc.chart(...)`.
- `fc.tooltip(...)`, `fc.modebar(...)`, `fc.theme(...)`.
+- `fc.colorbar(...)` with the same CSS-slot and opaque adapter-render contract.
- `class_name`, `class_names`, and `style` props.
Can add:
diff --git a/docs/styling.md b/docs/styling.md
index 4769dc47..3843d4a8 100644
--- a/docs/styling.md
+++ b/docs/styling.md
@@ -8,12 +8,13 @@ This guide is the single reference for the styling contract. For the API shapes
see [reflex-shaped-api.md](design/reflex-shaped-api.md); for the render internals
see [renderer-architecture.md](design/renderer-architecture.md).
-## The four ways to style
+## The five ways to style
| Mechanism | Scope | Where |
| --- | --- | --- |
| `class_names={slot: "..."}` | Add classes to a chrome slot (great for Tailwind) | `fc.chart(...)` |
| `styles={slot: {...}}` | Inline CSS on a chrome slot | `fc.chart(...)` |
+| `style={...}` | Cross-renderer CSS appearance subset for a rendered mark | `fc.line(...)`, `fc.scatter(...)`, … |
| `class_name=` / `style=` | A single annotation (per-call) | `.vline(...)`, `.text(...)`, … |
| `custom_css="..."` | A raw author stylesheet in the exported document | `to_html(fig, custom_css=...)` |
@@ -21,7 +22,11 @@ see [renderer-architecture.md](design/renderer-architecture.md).
import xy as fc
chart = fc.chart(
- fc.scatter(x=xs, y=ys),
+ fc.scatter(
+ x=xs,
+ y=ys,
+ style={"fill": "var(--accent)", "stroke": "currentColor", "stroke-width": "1px"},
+ ),
class_names={
"tooltip": "rounded-lg bg-slate-900/90 text-white shadow-xl",
"legend": "text-xs font-medium",
@@ -36,6 +41,99 @@ style APIs: a bare number on a length property becomes `px` (`{"font_size": 18}`
→ `font-size:18px`), custom properties (`--x`) and unitless properties pass
through untouched.
+## Rendered marks: standard CSS vocabulary
+
+WebGL and native-raster marks are not DOM elements, so XY compiles a deliberate
+CSS subset instead of pretending every browser property can work. Property
+names are canonical CSS kebab-case; snake_case aliases remain accepted for
+Python compatibility. Unsupported properties raise before the figure mutates.
+
+```python
+fc.line(
+ x=x,
+ y=y,
+ style={
+ "stroke": "var(--accent)",
+ "stroke-width": "2px",
+ "stroke-opacity": 0.85,
+ "stroke-dasharray": "6px 3px",
+ },
+)
+
+fc.bar(
+ x=category,
+ y=value,
+ style={
+ "fill": "linear-gradient(to top, #2563eb, #93c5fd)",
+ "stroke": "#1e3a8a",
+ "stroke-width": "1px",
+ "border-radius": "4px",
+ },
+)
+```
+
+| Mark family | Supported CSS properties |
+| --- | --- |
+| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` |
+| area, error band | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; area also supports `stroke-dasharray` |
+| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` |
+| histogram, bar, column | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` |
+| segments, error bars, contour, stem | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` |
+| box, violin | `fill`, `fill-opacity`, `opacity` |
+| triangle mesh | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` |
+| heatmap, hexbin | `fill-opacity`, `opacity` |
+
+Legacy appearance arguments such as `color=`, `width=`, and `opacity=` remain
+supported; a CSS `style` declaration is the final override when both are set.
+Within `style`, use the standard paint property for the geometry: `stroke` for
+line-like marks and `fill` for filled marks. `color` is not a paint alias there;
+this avoids ambiguous combinations such as `color` plus `stroke` and keeps the
+same declarations meaningful in SVG, WebGL, and native PNG output.
+
+### Reflex integration boundary
+
+Reflex owns reactive `Var` values, conditions, application state, event
+handlers, layouts, and themes. XY does not duplicate those facilities. The
+integration resolves them into concrete `style`, `styles`, `class_name`, and
+`class_names` values and updates the renderer. CSS variables are the preferred
+bridge for design tokens and theme changes.
+
+### Axis paint and geometry
+
+`fc.x_axis(style={...})` and `fc.y_axis(style={...})` accept a strict,
+cross-renderer axis vocabulary. Unknown keys and invalid values raise when the
+axis component is created, before the chart or an export is rendered. Keys may
+use Python snake_case or CSS kebab-case; pixel geometry accepts a finite number
+or a CSS `px` value such as `"3px"`.
+
+| Axis style key | Value |
+| --- | --- |
+| `grid_color`, `axis_color`, `tick_color`, `tick_label_color`, `label_color` | CSS color |
+| `grid_width`, `axis_width`, `tick_width` | Non-negative pixel length |
+| `grid_dash` | `"solid"`, `"dashed"`, `"dotted"`, or `"dashdot"` |
+| `grid_opacity` | Number from `0` to `1` |
+| `tick_length` | Non-negative pixel length |
+| `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size |
+| `tick_direction` | `"in"`, `"out"`, or `"inout"` |
+
+```python
+fc.x_axis(
+ label="time",
+ style={
+ "grid-color": "rgb(148 163 184 / 25%)",
+ "grid-width": "1px",
+ "grid-dash": "dashed",
+ "grid-opacity": 0.7,
+ "axis-color": "var(--axis)",
+ "tick-length": "6px",
+ "tick-direction": "out",
+ "tick-color": "currentColor",
+ "tick-label-color": "currentColor",
+ "label-size": "13px",
+ },
+)
+```
+
## Slot reference
Every element below is rendered with `data-fc-slot=""`, so
@@ -53,6 +151,10 @@ raises before it reaches the client.
| `legend` | Legend container |
| `legend_item` | One legend row |
| `legend_swatch` | Legend color swatch |
+| `colorbar` | Colorbar container |
+| `colorbar_bar` | Colorbar gradient/bands |
+| `colorbar_tick` | Colorbar tick label |
+| `colorbar_title` | Colorbar title |
| `tooltip` | Hover tooltip |
| `modebar` | Mode/tool bar container |
| `modebar_button` | One mode/tool button (`.fc-active` when engaged) |
@@ -205,13 +307,21 @@ is below the baseline).
### Opacity
-Every mark takes `opacity` (0–1) for its fill/body; it composes with everything
+Every mark takes standard CSS `opacity` (0–1) for the whole mark. Standard SVG
+CSS `fill-opacity` and `stroke-opacity` independently multiply the fill and
+stroke channels. Effective alpha is therefore paint alpha × channel opacity ×
+whole-mark opacity. These compose with everything
— a solid color, a gradient fill (each stop is scaled, so a fade-to-transparent
stays proportional), and the antialiased corner/stroke coverage. `area` also has
`line_opacity` for its outline. For finer control, any color is a full CSS color
**including alpha** — `rgba(37,99,235,.5)`, `#2563eb80`, `oklch(... / 40%)` — and
-because the stroke keeps its own color's alpha, a translucent fill with a solid
-border is just `bar(opacity=0.3, stroke="#2563eb", stroke_width=2)`.
+because the channels are separate, a translucent fill with a solid border is
+`style={"fill-opacity": 0.3, "stroke-opacity": 1}`.
+
+Whole-mark opacity applies to an area's outline as well as its fill. Therefore
+the default area `opacity=0.35` produces a `0.35`-alpha outline. For a faint
+fill with an opaque outline, keep whole-mark opacity at `1` and set
+`style={"fill-opacity": 0.35, "stroke-opacity": 1}`.
### Scatter markers — `symbol`, `stroke`, `stroke_width`
@@ -220,26 +330,12 @@ border is just `bar(opacity=0.3, stroke="#2563eb", stroke_width=2)`.
border, e.g. `scatter(x, y, symbol="triangle", stroke="#fff", stroke_width=2)`.
Each is an antialiased SDF in the point shader, so shapes stay crisp at any size
and the border is a true ring (a stroke width with no color borders in the mark
-color). Symbols compose with the color/size channels and the selected/unselected
-mark states.
-
-### Interaction states — `mark_style` / `set_mark_style(...)`
-
-Restyle marks by interaction state — `hover`, `selected`, `unselected` — each a
-style dict of `color` / `opacity` (and `size` for hover). Selecting a region
-recolors and dims in one pass on the GPU:
-
-```python
-fig.set_mark_style(
- hover={"color": "#0f172a", "size": 10}, # the point under the cursor
- selected={"color": "#f97316"}, # points inside the selection
- unselected={"opacity": 0.15}, # everything else fades back
-)
-```
+color). Symbols compose with the color/size channels.
-`color` accepts any CSS color; a state that sets no `color` keeps the mark's
-native color (so `unselected={"opacity": 0.15}` is the classic dim-the-rest
-selection affordance).
+Interaction state belongs to the host framework. In Reflex, use Reflex state,
+event handlers, conditions, and ordinary CSS classes/styles; XY only emits the
+events and renders the resulting props. The component API deliberately does not
+define a parallel hover/selected/unselected styling language.
### Smooth curves — `curve="smooth"` on `line`, `area`
@@ -260,8 +356,7 @@ dense and smoothing is invisible by construction.
| `scatter` | ✅ + color/size channels | — | `symbol` (circle/square/diamond/triangle/cross) | ✅ `stroke`/`stroke_width` | — | — | ✅ + size channel |
| `heatmap` | colormap + `domain` | colormap is the gradient | — | — | — | — | cell-driven |
-State styling (`mark_style`: hover / selected / unselected opacity & color)
-composes with all of the above. On the roadmap, in likely order: per-mark drop
+On the roadmap, in likely order: per-mark drop
shadows, gradient angles beyond the four axis directions, and stroke gradients.
### Dashes — `dash` on `line`, `area`
@@ -286,9 +381,11 @@ with, so what validates is exactly what renders:
- **Browser-resolved forms pass through.** `var(--accent)`, `oklch(…)`,
`color-mix(…)`, and `calc(…)` are shape-checked (known function, balanced)
and left for the client's probe element to resolve.
-- **Unknown properties are allowed** — your CSS is authority — but every
+- **Unknown DOM properties are allowed** — your CSS is authority — but every
value must be declaration-safe: `;`, `{`, `}`, ``, control characters,
and unbalanced quotes/parentheses are rejected on every styling surface.
+- **Canvas/WebGL mark properties use a strict CSS subset.** Unsupported mark
+ declarations raise instead of silently disappearing in one renderer.
- **A string `color=` is a constant iff it parses as a CSS color**; any other
string is a `data=` column name. The full named-color table counts, so
`color="rebeccapurple"` is a color, and a color-shaped typo reports its
@@ -309,17 +406,26 @@ 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.
-`fig.to_png(path?, width=, height=, scale=)` defaults to `engine="native"`: the
+`fig.to_png(path?, width=, height=, scale=)` defaults to
+`engine=fc.Engine.default`: the
built-in **Rust rasterizer** paints that same decimated payload — no browser and
millisecond export. Pass `optimize=True` to trade latency for indexed-palette
PNG compression and smaller files. Text uses a baked bitmap font (the core has no FreeType),
so small labels are slightly less refined than a browser's.
-For a pixel-exact match to the live WebGL chart, `engine="chromium"` screenshots
-the standalone HTML (needs a local Chrome/Chromium).
+For browser CSS, font, and WebGL fidelity, `engine=fc.Engine.chromium`
+screenshots the standalone HTML with an installed Chrome, Chromium, Edge, or
+`chrome-headless-shell`. Set `XY_BROWSER` to an executable path to override
+automatic discovery. Pass `custom_css="..."` to inject an author stylesheet
+into the captured standalone document. Since native export has no browser
+cascade, it rejects `custom_css`. Legacy string engine values remain deprecated
+aliases.
Both static engines carry the full mark styling surface — gradients, dashes,
symbols, rounded/stroked bars, smooth curves — with the same two documented
approximations: an area's mark-space gradient uses the area's bounding box (no
-per-column gradient), and `var(--x)` colors fall back to the mark color (no DOM
-to resolve them against). SVG renders smooth curves as exact cubic Béziers; the
-native raster flattens them to a fine polyline.
+per-column gradient), and nested browser-only color expressions remain
+browser-dependent in SVG and use the native rasterizer's static fallback in
+PNG. Complete paint references such as `var(--accent)` resolve against custom
+properties in the chart's own `style`, including nested token aliases and
+`var()` fallbacks. SVG renders smooth curves as exact cubic Béziers; the native
+raster flattens them to a fine polyline.
diff --git a/examples/reflex/reflex_xy_app/reflex_xy_app.py b/examples/reflex/reflex_xy_app/reflex_xy_app.py
index dacd1684..36598dc1 100644
--- a/examples/reflex/reflex_xy_app/reflex_xy_app.py
+++ b/examples/reflex/reflex_xy_app/reflex_xy_app.py
@@ -697,11 +697,6 @@ def handle_brush(brush):
link_group="demo-linked-x",
link_axes=("x",),
),
- fc.mark_style(
- hover={"color": "#0f172a", "size": 18, "opacity": 0.9},
- selected={"opacity": 1},
- unselected={"opacity": 0.18},
- ),
fc.theme(plot_background="white", grid_color="rgba(37,99,235,.12)"),
fc.tooltip(fields=["x", "y"], format={"x": ".2f", "y": ".2f"}),
fc.legend(),
diff --git a/examples/reflex/scripts/build_charts.py b/examples/reflex/scripts/build_charts.py
index a49bca0b..79544297 100644
--- a/examples/reflex/scripts/build_charts.py
+++ b/examples/reflex/scripts/build_charts.py
@@ -598,11 +598,6 @@ def interaction_basics_demo() -> fc.Chart:
link_group="demo-linked-x",
link_axes=("x",),
),
- fc.mark_style(
- hover={"color": "#0f172a", "size": 18, "opacity": 0.9},
- selected={"opacity": 1},
- unselected={"opacity": 0.18},
- ),
fc.theme(plot_background="white", grid_color="rgba(37,99,235,.12)"),
fc.tooltip(fields=["x", "y"], format={"x": ".2f", "y": ".2f"}),
fc.legend(),
diff --git a/js/src/20_theme.js b/js/src/20_theme.js
index aeaa4b59..4d09dfb2 100644
--- a/js/src/20_theme.js
+++ b/js/src/20_theme.js
@@ -80,6 +80,9 @@ const FC_CHROME_CSS = `
:where(.xy [data-fc-slot="tooltip"]){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-fc-slot="legend"]){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-fc-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
+:where(.xy [data-fc-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
+:where(.xy [data-fc-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
+:where(.xy [data-fc-slot="colorbar_title"]){font-weight:500}
:where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
:where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)}
:where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
diff --git a/js/src/40_gl.js b/js/src/40_gl.js
index bfd2bea1..96507081 100644
--- a/js/src/40_gl.js
+++ b/js/src/40_gl.js
@@ -349,7 +349,7 @@ const DENSITY_FS = `#version 300 es
precision highp float;
uniform sampler2D u_grid; uniform sampler2D u_lut;
uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1
-uniform float u_opacity;
+uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor;
in vec2 v_data;
out vec4 outColor;
void main() {
@@ -358,8 +358,11 @@ void main() {
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard;
float t = texture(u_grid, uv).r;
if (t <= 0.0) discard;
- vec3 rgb = texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
- float alpha = u_opacity * clamp(t * 1.35, 0.0, 1.0);
+ vec4 paint = u_constantColor == 1
+ ? u_color
+ : texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5));
+ vec3 rgb = paint.rgb;
+ float alpha = u_opacity * paint.a * clamp(t * 1.35, 0.0, 1.0);
if (alpha <= 0.01) discard;
outColor = vec4(rgb * alpha, alpha);
}`;
diff --git a/js/src/45_lod.js b/js/src/45_lod.js
index cda3b2e7..32d509aa 100644
--- a/js/src/45_lod.js
+++ b/js/src/45_lod.js
@@ -410,6 +410,7 @@ function lodApplyDensityUpdate(view, g, upd, buffers) {
g._densityFadeStart = view._now();
g.density = {
w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap,
+ color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color,
xRange: d.x_range, yRange: d.y_range,
grid,
tex: view._uploadGrid(grid, d.w, d.h, normMax),
diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js
index ad316c53..fe38c492 100644
--- a/js/src/50_chartview.js
+++ b/js/src/50_chartview.js
@@ -976,8 +976,10 @@ class ChartView {
`rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`;
}
bar.style.cssText = horizontal
- ? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`
- : `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`;
+ ? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;`
+ : `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`;
+ bar.style.setProperty("--xy-colorbar-gradient", gradient);
+ this._applySlot(bar, "colorbar_bar");
box.appendChild(bar);
const domain = cb.domain || [0, 1];
@@ -995,6 +997,7 @@ class ChartView {
tick.style.cssText = horizontal
? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`;
+ this._applySlot(tick, "colorbar_tick");
box.appendChild(tick);
}
if (cb.label) {
@@ -1003,6 +1006,7 @@ class ChartView {
label.style.cssText = horizontal
? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`;
+ this._applySlot(label, "colorbar_title");
box.appendChild(label);
}
box.title = `${cb.label ? cb.label + ": " : ""}${domain[0]} – ${domain[1]}`;
@@ -1149,6 +1153,7 @@ class ChartView {
g.densityNormMax = d.max;
g.density = {
w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap,
+ color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null,
xRange: d.x_range, yRange: d.y_range,
grid: lodCopyGrid(grid),
tex: this._uploadGrid(grid, d.w, d.h, d.max),
@@ -1383,6 +1388,14 @@ class ChartView {
gl.uniform4fv(u("u_gradColor"), grad.colors);
}
+ _fillOpacity(style, fallback = 1) {
+ return Number(style.opacity ?? fallback) * Number(style.fill_opacity ?? 1);
+ }
+
+ _strokeOpacity(style, fallback = 1) {
+ return Number(style.opacity ?? fallback) * Number(style.stroke_opacity ?? 1);
+ }
+
// Rect-family styling uniforms (rounded corners, stroke, gradient). Radius
// and stroke width are CSS px -> device px; the stroke color ships
// premultiplied to match the shader's blend space.
@@ -1394,7 +1407,8 @@ class ChartView {
gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr);
gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr);
const sc = g.strokeColor || [0, 0, 0, 0];
- gl.uniform4f(u("u_stroke"), sc[0] * sc[3], sc[1] * sc[3], sc[2] * sc[3], sc[3]);
+ const sa = sc[3] * this._strokeOpacity(g.trace.style || {});
+ gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa);
this._setGradientUniforms(prog, g.grad);
}
@@ -1923,7 +1937,7 @@ class ChartView {
gl.uniform1i(u("u_sizeMode"), g.sizeMode);
gl.uniform2f(u("u_sizeRange"), g.sizeRange[0], g.sizeRange[1]);
gl.uniform1i(u("u_colorMode"), g.colorMode);
- const markOpacity = (g.trace.style.opacity ?? 0.8) * opacityScale;
+ const markOpacity = this._fillOpacity(g.trace.style, 0.8) * opacityScale;
gl.uniform1f(u("u_opacity"), markOpacity);
gl.uniform1f(u("u_selectedOpacity"), this._markStateNumber("selected", "opacity", 1));
gl.uniform1f(u("u_unselectedOpacity"), this._markStateNumber("unselected", "opacity", 0.12));
@@ -1938,7 +1952,9 @@ class ChartView {
gl.uniform4f(u("u_color"), r, gg, b, 1);
gl.uniform1i(u("u_symbol"), g.symbol || 0);
const sc = g.pointStroke;
- const strokeAlpha = sc ? sc[3] * markOpacity : 0;
+ const strokeAlpha = sc
+ ? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale
+ : 0;
gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr);
gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0);
gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0,
@@ -2018,7 +2034,7 @@ class ChartView {
gl.uniform1f(u("u_dpr"), this.dpr);
gl.uniform1f(u("u_size"), g.size);
const [r, gg, b] = g.color;
- gl.uniform4f(u("u_color"), r, gg, b, (g.trace.style.opacity ?? 0.8) * opacityScale);
+ gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale);
this._bindVao(
g,
"points-simple",
@@ -2100,7 +2116,10 @@ class ChartView {
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
const d = density || g.density;
gl.uniform4f(u("u_gridRange"), d.xRange[0], d.xRange[1], d.yRange[0], d.yRange[1]);
- gl.uniform1f(u("u_opacity"), (g.trace.style.opacity ?? 1.0) * opacityScale);
+ gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale);
+ const constant = d.color;
+ gl.uniform1i(u("u_constantColor"), constant ? 1 : 0);
+ gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1]));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, d.tex);
gl.uniform1i(u("u_grid"), 0);
@@ -2125,7 +2144,7 @@ class ChartView {
gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]);
- gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1.0);
+ gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, h.tex);
@@ -2151,7 +2170,8 @@ class ChartView {
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = color || g.color;
- gl.uniform4f(u("u_color"), r, gg, b, a * (opacity ?? g.trace.style.opacity ?? 1));
+ const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1);
+ gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity);
const dashed = this._lineDash(g);
this._bindVao(
g,
@@ -2186,7 +2206,7 @@ class ChartView {
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = g.color;
- gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+ gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
const dashed = this._segmentDash(g, prog);
if (g.colorMode && g.lut) {
@@ -2301,10 +2321,12 @@ class ChartView {
for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis);
for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis);
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
- gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1);
+ gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1);
const stroke = g.meshStroke || [0, 0, 0, 0];
- gl.uniform4f(u("u_stroke"), stroke[0] * stroke[3], stroke[1] * stroke[3], stroke[2] * stroke[3], stroke[3]);
+ const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style);
+ gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha,
+ stroke[2] * strokeAlpha, strokeAlpha);
gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0);
if (g.colorMode && g.lut) {
gl.activeTexture(gl.TEXTURE0);
@@ -2384,7 +2406,7 @@ class ChartView {
this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis);
this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis);
const [r, gg, b, a] = g.color;
- gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 0.35));
+ gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.35));
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
this._setGradientUniforms(prog, g.grad);
this._bindVao(g, "area", [g.xBuf._fcId, g.yBuf._fcId, g.baseBuf._fcId], () => {
@@ -2416,7 +2438,7 @@ class ChartView {
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]);
const [r, gg, b, a] = g.color;
- gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+ gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const colorOn = g.colorMode && g.cBuf;
@@ -2463,7 +2485,7 @@ class ChartView {
gl.uniform1f(u("u_v0Const"), v0Const ?? 0);
gl.uniform1f(u("u_v0EdgePad"), v0EdgePad);
const [r, gg, b, a] = g.color;
- gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+ gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const v0On = g.value0Mode === 1 && g.value0Buf;
@@ -2521,6 +2543,14 @@ class ChartView {
return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined;
}
+ _axisGridDash(axis) {
+ const value = String(this._axisStyleValue(axis, "grid_dash") || "solid");
+ if (value === "dashed") return [6, 4];
+ if (value === "dotted") return [1, 3];
+ if (value === "dashdot") return [6, 3, 1, 3];
+ return [];
+ }
+
_axisTickLabelStrategy(axis) {
const raw = axis && axis.tick_label_strategy !== undefined
? axis.tick_label_strategy
@@ -2590,7 +2620,10 @@ class ChartView {
_layoutTickLabels(axis, dim, labels) {
if (labels.length <= 1) return labels.map((label) => ({ ...label, angle: 0, row: 0 }));
- const fontSize = Math.max(8, this._axisStyleNumber(axis, "tick_size", 11));
+ const fontSize = Math.max(
+ 8,
+ this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)),
+ );
const minGap = this._axisTickLabelMinGap(axis, dim);
const explicitAngle = this._axisTickLabelAngle(axis);
const baseAngle = explicitAngle === null ? 0 : explicitAngle;
@@ -2707,6 +2740,8 @@ class ChartView {
ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1));
+ ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1);
+ ctx.setLineDash(this._axisGridDash(xAxis));
ctx.beginPath();
for (const v of (hideX ? [] : xt.ticks)) {
const px = this._dataPx("x", v);
@@ -2719,6 +2754,8 @@ class ChartView {
ctx.strokeStyle = this._axisStylePaint(yAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(yAxis, "grid_width", 1));
+ ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1);
+ ctx.setLineDash(this._axisGridDash(yAxis));
ctx.beginPath();
for (const v of (hideY ? [] : yt.ticks)) {
const py = this._dataPx("y", v);
@@ -2728,6 +2765,8 @@ class ChartView {
ctx.lineTo(p.x + p.w, y);
}
ctx.stroke();
+ ctx.globalAlpha = 1;
+ ctx.setLineDash([]);
this._drawAnnotationShapes(ctx);
@@ -2737,11 +2776,11 @@ class ChartView {
// the chrome canvas, behind the data). Rebuilt with the labels; static
// between throttled zoom frames since the plot rect doesn't move on zoom.
if (updateLabels) {
- const rule = (styleAxis, left, top, w, h) => {
+ const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => {
const d = document.createElement("div");
d.style.cssText =
`position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;` +
- `background:${this._axisStylePaint(styleAxis, "axis_color", this.theme.axis)};` +
+ `background:${this._axisStylePaint(styleAxis, colorKey, this.theme.axis)};` +
"pointer-events:none;";
this.labels.appendChild(d);
};
@@ -2781,7 +2820,7 @@ class ChartView {
const x = this._dataPx("x", value);
if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue;
const top = side === "top" ? edge - tick.outward : edge - tick.inward;
- rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward);
+ rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color");
}
}
if (!hideY) {
@@ -2792,7 +2831,7 @@ class ChartView {
const y = this._dataPx("y", value);
if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue;
const left = side === "right" ? edge - tick.inward : edge - tick.outward;
- rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width);
+ rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color");
}
}
}
@@ -2804,8 +2843,14 @@ class ChartView {
d.dataset.fcLabelKind = kind;
d.dataset.fcAxis = axis && axis.id !== undefined ? String(axis.id) : "";
d.dataset.fcAxisSide = axis && axis.side ? String(axis.side) : "";
- const colorKey = kind === "label" ? "label_color" : "tick_color";
- const sizeKey = kind === "label" ? "label_size" : "tick_size";
+ const colorKey = kind === "label"
+ ? "label_color"
+ : (this._axisStyleValue(axis, "tick_label_color") !== undefined
+ ? "tick_label_color" : "tick_color");
+ const sizeKey = kind === "label"
+ ? "label_size"
+ : (this._axisStyleValue(axis, "tick_label_size") !== undefined
+ ? "tick_label_size" : "tick_size");
// Color/size are inline ONLY when the axis spec set them explicitly (the
// Python set_axis API); otherwise the stylesheet's tick_label/axis_title
// default applies so a user utility class can win. Structure stays inline.
@@ -2830,7 +2875,12 @@ class ChartView {
xLabelCandidates.push({ pos: px, text });
}
for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) {
- const rowOffset = Number(item.row || 0) * (Math.max(8, this._axisStyleNumber(xAxis, "tick_size", 11)) + 4);
+ const tickLabelSize = this._axisStyleNumber(
+ xAxis,
+ "tick_label_size",
+ this._axisStyleNumber(xAxis, "tick_size", 11),
+ );
+ const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4);
const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset;
const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`;
const origin = xAxis.side === "top" ? "bottom center" : "top center";
diff --git a/python/xy/__init__.py b/python/xy/__init__.py
index 1816535a..bc726b97 100644
--- a/python/xy/__init__.py
+++ b/python/xy/__init__.py
@@ -32,14 +32,15 @@
"Axis": ".components",
"CHART_DOM_SLOTS": ".dom",
"Chart": ".components",
+ "Colorbar": ".components",
"Column": ".columns",
"ColumnStore": ".columns",
"Component": ".components",
+ "Engine": ".export",
"FacetChart": ".components",
"Interaction": ".components",
"Legend": ".components",
"Mark": ".components",
- "MarkStyle": ".components",
"Modebar": ".components",
"Selection": "._figure",
"Theme": ".components",
@@ -56,6 +57,7 @@
"chart": ".components",
"column": ".components",
"column_chart": ".components",
+ "colorbar": ".components",
"contour": ".components",
"contour_chart": ".components",
"ecdf": ".components",
@@ -79,7 +81,6 @@
"line": ".components",
"line_chart": ".components",
"marker": ".components",
- "mark_style": ".components",
"modebar": ".components",
"scatter": ".components",
"scatter_chart": ".components",
@@ -112,14 +113,15 @@
"Annotation",
"Axis",
"Chart",
+ "Colorbar",
"Column",
"ColumnStore",
"Component",
+ "Engine",
"FacetChart",
"Interaction",
"Legend",
"Mark",
- "MarkStyle",
"Modebar",
"Selection",
"Theme",
@@ -135,6 +137,7 @@
"box_chart",
"callout",
"chart",
+ "colorbar",
"column",
"column_chart",
"contour",
@@ -159,7 +162,6 @@
"legend",
"line",
"line_chart",
- "mark_style",
"marker",
"modebar",
"scatter",
@@ -213,12 +215,12 @@ def __dir__() -> list[str]:
Annotation,
Axis,
Chart,
+ Colorbar,
Component,
FacetChart,
Interaction,
Legend,
Mark,
- MarkStyle,
Modebar,
Theme,
Tooltip,
@@ -231,6 +233,7 @@ def __dir__() -> list[str]:
box_chart,
callout,
chart,
+ colorbar,
column,
column_chart,
contour,
@@ -255,7 +258,6 @@ def __dir__() -> list[str]:
legend,
line,
line_chart,
- mark_style,
marker,
modebar,
scatter,
@@ -280,3 +282,4 @@ def __dir__() -> list[str]:
y_band,
)
from .dom import CHART_DOM_SLOTS
+ from .export import Engine
diff --git a/python/xy/_figure.py b/python/xy/_figure.py
index 58122f82..68c2bc03 100644
--- a/python/xy/_figure.py
+++ b/python/xy/_figure.py
@@ -13,7 +13,7 @@
import numpy as np
-from . import _annotations, _validate, channels, columns, export, interaction, kernels
+from . import _annotations, _validate, channels, columns, export, interaction, kernels, styles
from . import marks as _marks
from ._annotations import AnnotationsMixin
from ._payload import PayloadMixin
@@ -193,7 +193,7 @@ def set_axis(
if tick_label_min_gap is None
else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"),
"side": side,
- "style": self._optional_state_style(style, f"{axis_id} axis style"),
+ "style": styles.compile_axis_style(style, f"{axis_id} axis style"),
}
if axis_id == "x":
self.x_label = self.axis_options[axis_id]["label"]
@@ -255,7 +255,12 @@ def set_mark_style(
selected: Optional[dict[str, Any]] = None,
unselected: Optional[dict[str, Any]] = None,
) -> "Figure":
- """Configure mark hover/selection state styling."""
+ """Configure legacy standalone hover/selection styling.
+
+ This low-level compatibility hook is intentionally not exposed by the
+ declarative component API. Reflex integrations should derive ordinary
+ mark props/styles from Reflex state instead of maintaining XY state.
+ """
for state, value in (
("hover", hover),
("selected", selected),
@@ -956,7 +961,7 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any
spec["domain"] = list(opts["domain"])
if opts.get("format") is not None:
spec["format"] = opts["format"]
- style = self._optional_state_style(opts.get("style"), f"{axis_id} axis style")
+ style = styles.compile_axis_style(opts.get("style"), f"{axis_id} axis style")
if style:
spec["style"] = style
if kind == "category":
@@ -1177,20 +1182,21 @@ def to_png(
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 2.0,
- engine: str = "native",
+ engine: export.Engine = export.Engine.default,
optimize: bool = False,
- chromium: Optional[str] = None,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> bytes:
- """Static PNG (export.py). `engine="native"` (default) paints the
+ """Static PNG (export.py). `engine=Engine.default` paints the
decimated payload with the built-in Rust rasterizer — no browser,
millisecond export. `optimize=True` uses the slower size-oriented
- indexed encoder. `engine="chromium"` screenshots
- the standalone HTML for a pixel-exact match to the live WebGL chart
- (needs a Chromium/Chrome binary; see export.find_chromium); `gl`
- selects its WebGL backend — "software" (default, deterministic
- SwiftShader) or "hardware" (real GPU)."""
+ indexed encoder. `engine=Engine.chromium` screenshots the standalone
+ HTML with an automatically discovered installed browser for browser
+ CSS/WebGL fidelity (see export.find_browser); `gl` selects its WebGL
+ backend — "software" (default, deterministic SwiftShader) or
+ "hardware" (real GPU). `custom_css` is Chromium-only and injects an
+ author stylesheet into the captured document."""
return export.to_png(
self,
path,
@@ -1199,7 +1205,7 @@ def to_png(
scale=scale,
engine=engine,
optimize=optimize,
- chromium=chromium,
+ custom_css=custom_css,
sandbox=sandbox,
gl=gl,
)
diff --git a/python/xy/_payload.py b/python/xy/_payload.py
index fa49cd27..97803584 100644
--- a/python/xy/_payload.py
+++ b/python/xy/_payload.py
@@ -138,14 +138,16 @@ def _append(self, enc: np.ndarray, meta: dict[str, Any]) -> int:
def blob(self) -> bytes:
return b"".join(
- chunk if isinstance(chunk, bytes) else memoryview(chunk).cast("B")
- for chunk in self._chunks
+ chunk if isinstance(chunk, bytes) else chunk.data.cast("B") for chunk in self._chunks
)
def buffers(self) -> list[memoryview]:
"""Per-column wire buffers (split mode): zero-copy views over the
encoded chunks, ready to ship as separate binary comm frames."""
- return [memoryview(c).cast("B") for c in self._chunks]
+ return [
+ memoryview(c).cast("B") if isinstance(c, bytes) else c.data.cast("B")
+ for c in self._chunks
+ ]
class PayloadMixin(_Host):
@@ -910,6 +912,8 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d
"y_range": list(yr),
"channels_dropped": dropped, # never silent (§28)
}
+ if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None:
+ density["color"] = t.color_ch.constant
sample = self._density_sample_spec(t, sel, visible, xr, yr, pw, sample_sel=sample_sel)
if sample is not None:
density["sample"] = sample
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index fae8c543..18ee8b4a 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -20,7 +20,9 @@
from . import _png, _scene
from ._svg import (
_AXIS,
+ _AXIS_GRID_DASHES,
_GRID,
+ _STATIC_COLOR_FALLBACK,
_TEXT,
DEFAULT_PALETTE,
_colormap_stops,
@@ -28,6 +30,7 @@
_corner_radii,
_css,
_lut,
+ _resolve_static_css_vars,
_Scale,
_step_arrays,
_tick_text,
@@ -82,8 +85,8 @@ def _parse_color(css: str, opacity: float = 1.0) -> tuple[int, int, int, int]:
colors can never drift from the API contract. `none` renders transparent
(the SVG idiom); browser-only forms that survive `_css`'s fallback (an
`oklch()` a DOM would resolve) and — defensively — anything unparseable
- fall back to a mid gray so a static export never renders an invisible
- mark."""
+ use the same blue-gray fallback as the browser renderer so a static export
+ never renders an invisible or target-dependent mark."""
from . import kernels
s = str(css).strip()
@@ -91,7 +94,7 @@ def _parse_color(css: str, opacity: float = 1.0) -> tuple[int, int, int, int]:
return (0, 0, 0, 0)
_status, rgba = kernels.css_check(kernels.CSS_COLOR, s)
if rgba is None:
- rgba = (100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 1.0)
+ rgba = _STATIC_COLOR_FALLBACK
r, g, b, a = rgba
return (
int(round(r * 255)),
@@ -105,6 +108,14 @@ def _rgba(css: Any, fallback: str, opacity: float = 1.0) -> tuple[int, int, int,
return _parse_color(_css(css, fallback), opacity)
+def _fill_opacity(style: dict[str, Any], default: float = 1.0) -> float:
+ return float(style.get("opacity", default)) * float(style.get("fill_opacity", 1.0))
+
+
+def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float:
+ return float(style.get("opacity", default)) * float(style.get("stroke_opacity", 1.0))
+
+
class _Cmd:
"""Little-endian display-list writer. All coordinates/sizes are multiplied
by `scale` on emit so a single logical layout renders at any DPI."""
@@ -497,6 +508,7 @@ def render_raster(
borrowed: tuple[np.ndarray, ...] = (),
) -> np.ndarray | bytes:
"""Paint `spec` into an ``(h, w, 4)`` RGBA8 image via the native rasterizer."""
+ spec = _resolve_static_css_vars(spec)
width, height, compact, plot = layout(spec)
xa, ya = spec["x_axis"], spec["y_axis"]
sx = _Scale(xa, plot["x"], plot["x"] + plot["w"])
@@ -525,17 +537,41 @@ def render_raster(
xt, xlab, xstep = axis_ticks(xa, plot["w"], True)
yt, ylab, ystep = axis_ticks(ya, plot["h"], False)
- grid = _parse_color(_css(dom_style.get("--chart-grid"), _GRID))
+ 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)
+ default_text = _css(dom_style.get("--chart-text"), _TEXT)
px0, py0 = plot["x"], plot["y"]
px1, py1 = plot["x"] + plot["w"], plot["y"] + plot["h"]
+ hide_x = xa.get("tick_label_strategy") == "none"
+ hide_y = ya.get("tick_label_strategy") == "none"
+ hide_x_labels = hide_x or xa.get("tick_label_strategy") == "off"
+ hide_y_labels = hide_y or ya.get("tick_label_strategy") == "off"
+
cmd.clip(px0, py0, plot["w"], plot["h"])
- for v in xt:
+ for v in [] if hide_x else xt:
gx = float(sx(v))
- cmd.stroke([(gx, py0), (gx, py1)], 1.0, grid)
- for v in yt:
+ cmd.stroke(
+ [(gx, py0), (gx, py1)],
+ float(xstyle.get("grid_width", 1)),
+ _parse_color(
+ _css(xstyle.get("grid_color"), default_grid),
+ float(xstyle.get("grid_opacity", 1.0)),
+ ),
+ dash=_AXIS_GRID_DASHES.get(str(xstyle.get("grid_dash", "solid"))),
+ )
+ for v in [] if hide_y else yt:
gy = float(sy(v))
- cmd.stroke([(px0, gy), (px1, gy)], 1.0, grid)
+ cmd.stroke(
+ [(px0, gy), (px1, gy)],
+ float(ystyle.get("grid_width", 1)),
+ _parse_color(
+ _css(ystyle.get("grid_color"), default_grid),
+ float(ystyle.get("grid_opacity", 1.0)),
+ ),
+ dash=_AXIS_GRID_DASHES.get(str(ystyle.get("grid_dash", "solid"))),
+ )
for palette_i, t in enumerate(spec["traces"]):
style = t.get("style") or {}
@@ -566,42 +602,129 @@ def render_raster(
# Chrome (unclipped): baselines, labels, title, legend.
cmd.clip(0, 0, width, height)
- axis_c = _parse_color(_AXIS)
# "none" silences the whole axis chrome (sparklines); "off" hides only the
# label text and keeps baselines and the axis title (mpl shared axes).
- hide_x = xa.get("tick_label_strategy") == "none"
- hide_y = ya.get("tick_label_strategy") == "none"
- hide_x_labels = hide_x or xa.get("tick_label_strategy") == "off"
- hide_y_labels = hide_y or ya.get("tick_label_strategy") == "off"
frame_sides = spec.get("frame_sides")
if frame_sides is None:
frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")]
if not hide_y:
if "left" in frame_sides:
- cmd.stroke([(px0, py0), (px0, py1)], 1.0, axis_c)
+ cmd.stroke(
+ [(px0, py0), (px0, py1)],
+ float(ystyle.get("axis_width", 1)),
+ _parse_color(_css(ystyle.get("axis_color"), default_axis)),
+ )
if "right" in frame_sides:
- cmd.stroke([(px1, py0), (px1, py1)], 1.0, axis_c)
+ cmd.stroke(
+ [(px1, py0), (px1, py1)],
+ float(ystyle.get("axis_width", 1)),
+ _parse_color(_css(ystyle.get("axis_color"), default_axis)),
+ )
if not hide_x:
if "top" in frame_sides:
- cmd.stroke([(px0, py0), (px1, py0)], 1.0, axis_c)
+ cmd.stroke(
+ [(px0, py0), (px1, py0)],
+ float(xstyle.get("axis_width", 1)),
+ _parse_color(_css(xstyle.get("axis_color"), default_axis)),
+ )
if "bottom" in frame_sides:
- cmd.stroke([(px0, py1), (px1, py1)], 1.0, axis_c)
+ cmd.stroke(
+ [(px0, py1), (px1, py1)],
+ float(xstyle.get("axis_width", 1)),
+ _parse_color(_css(xstyle.get("axis_color"), default_axis)),
+ )
+
+ def tick_span(style):
+ length = max(0.0, float(style.get("tick_length", 0)))
+ direction = str(style.get("tick_direction", "out"))
+ if direction == "in":
+ return length, 0.0
+ if direction == "inout":
+ return length / 2, length / 2
+ return 0.0, length
- text_c = _parse_color(_TEXT)
+ if not hide_x:
+ inward, outward = tick_span(xstyle)
+ side = xa.get("side", "bottom")
+ edge = py0 if side == "top" else py1
+ for value in xt:
+ x = float(sx(value))
+ y0, y1 = (
+ (edge - outward, edge + inward)
+ if side == "top"
+ else (edge - inward, edge + outward)
+ )
+ cmd.stroke(
+ [(x, y0), (x, y1)],
+ float(xstyle.get("tick_width", 1)),
+ _parse_color(_css(xstyle.get("tick_color"), default_axis)),
+ )
+ if not hide_y:
+ inward, outward = tick_span(ystyle)
+ side = ya.get("side", "left")
+ edge = px1 if side == "right" else px0
+ for value in yt:
+ y = float(sy(value))
+ x0, x1 = (
+ (edge - inward, edge + outward)
+ if side == "right"
+ else (edge - outward, edge + inward)
+ )
+ cmd.stroke(
+ [(x0, y), (x1, y)],
+ float(ystyle.get("tick_width", 1)),
+ _parse_color(_css(ystyle.get("tick_color"), default_axis)),
+ )
+
+ text_c = _parse_color(default_text)
if not hide_x_labels:
+ x_tick_c = _parse_color(
+ _css(xstyle.get("tick_label_color", xstyle.get("tick_color")), default_text)
+ )
for v in xlab:
label_y = py0 - 7 if xa.get("side") == "top" else py1 + 15
- cmd.text(float(sx(v)), label_y, 1, 11, text_c, _tick_text(xa, v, xstep))
+ cmd.text(
+ float(sx(v)),
+ label_y,
+ 1,
+ float(xstyle.get("tick_label_size", xstyle.get("tick_size", 11))),
+ x_tick_c,
+ _tick_text(xa, v, xstep),
+ )
if not hide_y_labels:
+ y_tick_c = _parse_color(
+ _css(ystyle.get("tick_label_color", ystyle.get("tick_color")), default_text)
+ )
for v in ylab:
- cmd.text(px0 - 8, float(sy(v)) + 4, 2, 11, text_c, _tick_text(ya, v, ystep))
+ cmd.text(
+ px0 - 8,
+ float(sy(v)) + 4,
+ 2,
+ float(ystyle.get("tick_label_size", ystyle.get("tick_size", 11))),
+ y_tick_c,
+ _tick_text(ya, v, ystep),
+ )
if spec.get("title"):
cmd.text(width / 2, plot["y"] - (10 if compact else 12), 1, 14, text_c, str(spec["title"]))
if xa.get("label") and not hide_x:
- cmd.text(px0 + plot["w"] / 2, py1 + 33, 1, 12, text_c, str(xa["label"]))
+ cmd.text(
+ px0 + plot["w"] / 2,
+ py1 + 33,
+ 1,
+ float(xstyle.get("label_size", 12)),
+ _parse_color(_css(xstyle.get("label_color"), default_text)),
+ str(xa["label"]),
+ )
if ya.get("label") and not hide_y:
# Rotated 90° CCW alongside the axis, matching the SVG export.
- cmd.text(14, plot["y"] + plot["h"] / 2, 1 | 0x80, 12, text_c, str(ya["label"]))
+ cmd.text(
+ 14,
+ plot["y"] + plot["h"] / 2,
+ 1 | 0x80,
+ float(ystyle.get("label_size", 12)),
+ _parse_color(_css(ystyle.get("label_color"), default_text)),
+ str(ya["label"]),
+ )
named = [t for t in spec["traces"] if t.get("name")]
if spec.get("show_legend", True) and named:
@@ -622,7 +745,7 @@ def _emit_line(cmd, t, blob, cols, sx, sy, style, color):
xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]])
if style.get("step"):
xv, yv = _step_arrays(xv, yv, style["step"])
- c = _rgba(style.get("color"), color, float(style.get("opacity", 1.0)))
+ c = _rgba(style.get("color"), color, _stroke_opacity(style))
width = float(style.get("width", 1.5))
if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine:
cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"))
@@ -737,7 +860,7 @@ def _emit_area(cmd, t, blob, cols, sx, sy, style, color, plot):
top = _scene.curve_points(xv, yv, sx, sy, smooth)
base = _scene.curve_points(xv[::-1], bv[::-1], sx, sy, smooth)
poly = np.vstack([top, base])
- op = float(style.get("opacity", 0.35))
+ op = _fill_opacity(style, 0.35)
fill_spec = style.get("fill")
if isinstance(fill_spec, dict):
xs, ys = poly[:, 0], poly[:, 1]
@@ -751,7 +874,7 @@ def _emit_area(cmd, t, blob, cols, sx, sy, style, color, plot):
cmd.fill(poly.tolist(), _rgba(style.get("color"), color, op))
lw = float(style.get("line_width", 1.2))
if lw > 0:
- lop = float(style.get("line_opacity", 1.0))
+ lop = _stroke_opacity(style, 0.35) * float(style.get("line_opacity", 1.0))
line_color = _rgba(style.get("line_color"), style.get("color") or color, lop)
cmd.stroke(top, lw, line_color, dash=style.get("dash"))
if style.get("stroke_perimeter"):
@@ -761,13 +884,18 @@ def _emit_area(cmd, t, blob, cols, sx, sy, style, color, plot):
def _emit_scatter(cmd, t, blob, cols, sx, sy, style, color):
ch = t.get("color") or {}
size_ch = t.get("size") or {}
- op = float(style.get("opacity", 0.8))
+ fill_op = _fill_opacity(style, 0.8)
+ stroke_op = _stroke_opacity(style, 0.8)
sw = float(style.get("stroke_width", 0.0))
sym = _SYMBOLS.get(style.get("symbol", "circle"), 0)
# Transparent is the private wire sentinel for edgecolors="face". The
# native point painter replaces it with each point's resolved RGBA fill.
stroke_value = style.get("stroke")
- stroke = _rgba(stroke_value, color, op) if sw > 0 and stroke_value is not None else (0, 0, 0, 0)
+ stroke = (
+ _rgba(stroke_value, color, stroke_op)
+ if sw > 0 and stroke_value is not None
+ else (0, 0, 0, 0)
+ )
color_mode = ch.get("mode")
size_mode = size_ch.get("mode")
@@ -776,7 +904,7 @@ def _emit_scatter(cmd, t, blob, cols, sx, sy, style, color):
and sy.affine
and (color_mode in {"continuous", "categorical"} or size_mode == "continuous")
):
- alpha = max(0, min(255, int(round(op * 255))))
+ alpha = max(0, min(255, int(round(fill_op * 255))))
rgb = _parse_color(_css(ch.get("color"), color))[:3]
cmd.affine_channel_points(
cols[t["x"]],
@@ -803,7 +931,7 @@ def _emit_scatter(cmd, t, blob, cols, sx, sy, style, color):
and ch.get("mode") not in {"continuous", "categorical"}
and size_ch.get("mode") != "continuous"
):
- alpha = max(0, min(255, int(round(op * 255))))
+ alpha = max(0, min(255, int(round(fill_op * 255))))
rgb = _parse_color(_css(ch.get("color"), color))[:3]
fill = (rgb[0], rgb[1], rgb[2], alpha)
radius = float(size_ch.get("size", 4.0)) / 2
@@ -814,7 +942,7 @@ def _emit_scatter(cmd, t, blob, cols, sx, sy, style, color):
px, py = sx(xv), sy(yv)
n = len(xv)
fills = np.empty((n, 4), dtype=np.uint8)
- fills[:, 3] = max(0, min(255, int(round(op * 255))))
+ fills[:, 3] = max(0, min(255, int(round(fill_op * 255))))
if ch.get("mode") == "continuous":
fills[:, :3] = _lut(ch.get("colormap", "viridis"), _column(blob, cols[ch["buf"]]))
elif ch.get("mode") == "categorical":
@@ -842,7 +970,7 @@ def _emit_segments(cmd, t, blob, cols, sx, sy, style, color):
y0 = _column(blob, cols[t["y0"]])
y1 = _column(blob, cols[t["y1"]])
ch = t.get("color") or {}
- opacity = float(style.get("opacity", 1.0))
+ opacity = _stroke_opacity(style)
colors = np.empty((len(x0), 4), dtype=np.uint8)
colors[:, 3] = max(0, min(255, int(round(255 * opacity))))
if ch.get("mode") == "continuous":
@@ -878,9 +1006,10 @@ def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color):
vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")]
n = min(len(values) for values in vertices)
ch = t.get("color") or {}
- op = float(style.get("opacity", 1.0))
+ fill_op = _fill_opacity(style)
+ stroke_op = _stroke_opacity(style)
fills = np.empty((n, 4), dtype=np.uint8)
- fills[:, 3] = max(0, min(255, int(round(op * 255))))
+ fills[:, 3] = max(0, min(255, int(round(fill_op * 255))))
if ch.get("mode") == "continuous":
fills[:, :3] = _lut(ch.get("colormap", "viridis"), _column(blob, cols[ch["buf"]])[:n])
elif ch.get("mode") == "categorical":
@@ -893,7 +1022,7 @@ def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color):
x0, y0, x1, y1, x2, y2 = vertices
sw = float(style.get("stroke_width", 0.0))
- stroke = _rgba(style.get("stroke"), color) if sw > 0 else (0, 0, 0, 0)
+ stroke = _rgba(style.get("stroke"), color, stroke_op) if sw > 0 else (0, 0, 0, 0)
cmd.triangles(
sx(x0[:n]),
sy(y0[:n]),
@@ -923,12 +1052,15 @@ def _bar_geom(cmd, x, y, w, h, style, fill_cmd, stroke_c, sw, tip_top):
def _fill_maker(cmd, style, color, plot):
"""Return (fill_cmd, stroke_c, sw) closure honoring gradient/stroke style."""
- op = float(style.get("opacity", 0.85))
+ fill_op = _fill_opacity(style, 0.85)
+ stroke_op = _stroke_opacity(style, 0.85)
sw = float(style.get("stroke_width", 0.0))
- stroke_c = _rgba(style.get("stroke"), color) if sw > 0 else (0, 0, 0, 0)
+ stroke_c = _rgba(style.get("stroke"), color, stroke_op) if sw > 0 else (0, 0, 0, 0)
fill_spec = style.get("fill")
if isinstance(fill_spec, dict):
- stops = [(o, (c[0], c[1], c[2], int(c[3] * op))) for o, c in _grad_stops(fill_spec, color)]
+ stops = [
+ (o, (c[0], c[1], c[2], int(c[3] * fill_op))) for o, c in _grad_stops(fill_spec, color)
+ ]
def fill_cmd(poly):
xs = [p[0] for p in poly]
@@ -939,7 +1071,7 @@ def fill_cmd(poly):
)
cmd.grad(poly, g0, g1, stops)
else:
- flat = _rgba(style.get("color"), color, op)
+ flat = _rgba(style.get("color"), color, fill_op)
def fill_cmd(poly):
cmd.fill(poly, flat)
@@ -969,7 +1101,7 @@ def _emit_bars(cmd, t, blob, cols, sx, sy, style, color, plot):
ya, yb = sy(np.maximum(v0, v1)), sy(np.minimum(v0, v1))
x0, x1 = np.minimum(xa, xb), np.maximum(xa, xb)
y0, y1 = np.minimum(ya, yb), np.maximum(ya, yb)
- fill = _rgba(style.get("color"), color, float(style.get("opacity", 0.85)))
+ fill = _rgba(style.get("color"), color, _fill_opacity(style, 0.85))
fills = np.tile(np.asarray(fill, dtype=np.uint8), (len(pos), 1))
cmd.rects(x0, y0, x1, y1, fills)
return
@@ -995,7 +1127,7 @@ def _emit_rects(cmd, t, blob, cols, sx, sy, style, color, plot):
if not isinstance(style.get("fill"), dict) and not (r_tip or r_base or sw > 0):
xa, xb = sx(x0v), sx(x1v)
ya, yb = sy(y0v), sy(y1v)
- fill = _rgba(style.get("color"), color, float(style.get("opacity", 0.85)))
+ fill = _rgba(style.get("color"), color, _fill_opacity(style, 0.85))
fills = np.tile(np.asarray(fill, dtype=np.uint8), (len(x0v), 1))
cmd.rects(
np.minimum(xa, xb),
@@ -1021,9 +1153,7 @@ def _emit_grid(cmd, kind, g, blob, cols, sx, sy, style):
if "rgba_bufs" in g:
channels = [_column(blob, cols[index]) for index in g["rgba_bufs"]]
rgba = np.clip(np.column_stack(channels) * 255.0, 0, 255).astype(np.uint8)
- rgba[:, 3] = (rgba[:, 3].astype(np.float64) * float(style.get("opacity", 1.0))).astype(
- np.uint8
- )
+ rgba[:, 3] = (rgba[:, 3].astype(np.float64) * _fill_opacity(style)).astype(np.uint8)
rgba = rgba.reshape(h, w, 4)[::-1]
xr, yr = g["x_range"], g["y_range"]
dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy)
@@ -1031,7 +1161,7 @@ def _emit_grid(cmd, kind, g, blob, cols, sx, sy, style):
return
meta = cols[g["buf"]]
stops = np.asarray(_colormap_stops(g.get("colormap", "viridis")), dtype=np.uint8)
- alpha = int(255 * float(style.get("opacity", 0.95)))
+ alpha = int(255 * _fill_opacity(style, 0.95))
xr, yr = g["x_range"], g["y_range"]
dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy)
canonical = g.get("enc") == "canonical-f64"
@@ -1053,7 +1183,13 @@ def _emit_grid(cmd, kind, g, blob, cols, sx, sy, style):
elif g.get("enc") == "log-u8":
w, h = int(g["w"]), int(g["h"])
meta = cols[g["buf"]]
- stops = np.asarray(_colormap_stops(g.get("colormap", "viridis")), dtype=np.uint8)
+ paint_alpha = 1.0
+ if g.get("color") is not None:
+ red, green, blue, alpha = _parse_color(g["color"])
+ stops = np.asarray([(red, green, blue), (red, green, blue)], dtype=np.uint8)
+ paint_alpha = alpha / 255.0
+ else:
+ stops = np.asarray(_colormap_stops(g.get("colormap", "viridis")), dtype=np.uint8)
xr, yr = g["x_range"], g["y_range"]
dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy)
cmd.density_image(
@@ -1066,7 +1202,7 @@ def _emit_grid(cmd, kind, g, blob, cols, sx, sy, style):
meta["byte_offset"],
float(g.get("max") or 0.0),
stops,
- float(style.get("opacity", 0.85)),
+ _fill_opacity(style, 0.85) * paint_alpha,
span=int(meta.get("span", 0)),
)
return
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 1a52ab47..62e6892c 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -10,13 +10,15 @@
(`30_ticks.js`, `10_colormaps.js`, `50_chartview.js`); tests assert the
ported tables stay in sync with the JS parts. Known static-export
approximations, documented in docs/styling.md: area mark-space gradients use
-the area's bounding box (SVG has no per-column gradient), and `var(--x)`
-colors fall back to the mark color (no DOM to resolve against).
+the area's bounding box (SVG has no per-column gradient); complete chart color
+tokens resolve statically, while nested browser-only expressions remain
+browser-dependent in SVG and use the native PNG fallback.
"""
from __future__ import annotations
import base64
+import re
from datetime import UTC, datetime
from os import PathLike
from typing import Any, Optional
@@ -27,6 +29,17 @@
from . import _png
from .config import DEFAULT_PALETTE
+
+def _fill_opacity(style: dict[str, Any], default: float = 1.0) -> float:
+ """CSS whole-mark opacity multiplied by the fill-only channel."""
+ return float(style.get("opacity", default)) * float(style.get("fill_opacity", 1.0))
+
+
+def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float:
+ """CSS whole-mark opacity multiplied by the stroke-only channel."""
+ return float(style.get("opacity", default)) * float(style.get("stroke_opacity", 1.0))
+
+
# Mirrors js/src/10_colormaps.js COLORMAP_STOPS (§36) — test-guarded.
COLORMAP_STOPS: dict[str, list[tuple[int, int, int]]] = {
"binary": [(255, 255, 255), (0, 0, 0)],
@@ -285,6 +298,13 @@
_AXIS = "rgba(32,32,32,0.55)"
_FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif"
_MS = {"s": 1e3, "m": 6e4, "h": 36e5, "d": 864e5}
+_STATIC_COLOR_FALLBACK = (0.3, 0.47, 0.66, 1.0)
+_AXIS_GRID_DASHES = {
+ "solid": None,
+ "dashed": [6.0, 4.0],
+ "dotted": [1.0, 3.0],
+ "dashdot": [6.0, 3.0, 1.0, 3.0],
+}
# ---------------------------------------------------------------------------
@@ -547,19 +567,135 @@ def _lut(colormap: str, t: np.ndarray) -> np.ndarray:
return out
+def _paint_rgba8(css: Any) -> tuple[int, int, int, int]:
+ """Resolve a validated CSS paint for static density images."""
+ from . import kernels
+
+ _status, rgba = kernels.css_check(kernels.CSS_COLOR, str(css))
+ if rgba is None:
+ rgba = _STATIC_COLOR_FALLBACK
+ red, green, blue, alpha = rgba
+ return (
+ int(round(red * 255)),
+ int(round(green * 255)),
+ int(round(blue * 255)),
+ int(round(alpha * 255)),
+ )
+
+
def _css(c: Any, fallback: str) -> str:
- """Static color resolution: currentColor -> the mark color; var() can't
- resolve without a DOM, so it falls back to the mark color too."""
+ """Resolve static colors after chart-level tokens have been expanded."""
s = str(c or "").strip()
- if not s or s.lower() == "currentcolor" or s.startswith("var("):
+ if not s or s.lower() == "currentcolor" or s.lower().startswith("var("):
return fallback
return s
+_CSS_VAR_RE = re.compile(
+ r"^var\(\s*(--[A-Za-z_][A-Za-z0-9_-]*)\s*(?:,\s*(.+))?\)$",
+ re.DOTALL | re.IGNORECASE,
+)
+_STATIC_PAINT_KEYS = frozenset(
+ {
+ "axis_color",
+ "background",
+ "canvas_background",
+ "color",
+ "fill",
+ "grid_color",
+ "label_color",
+ "line_color",
+ "stroke",
+ "stroke_color",
+ "tick_color",
+ }
+)
+
+
+def _resolve_css_var(value: Any, variables: dict[str, Any], seen: tuple[str, ...] = ()) -> Any:
+ """Resolve a complete ``var(--token[, fallback])`` static paint value."""
+ if not isinstance(value, str):
+ return value
+ match = _CSS_VAR_RE.fullmatch(value.strip())
+ if match is None:
+ return value
+ name, fallback = match.groups()
+ if name in seen:
+ return fallback.strip() if fallback is not None else value
+ replacement = variables.get(name, fallback)
+ if replacement is None:
+ return value
+ if isinstance(replacement, str):
+ replacement = replacement.strip()
+ return _resolve_css_var(replacement, variables, (*seen, name))
+
+
+def _resolve_static_css_vars(spec: dict[str, Any]) -> dict[str, Any]:
+ """Resolve chart-level color tokens with a copy-on-write spec traversal.
+
+ This deliberately handles complete color-token references only. Browser
+ expressions containing variables, such as ``color-mix(...)``, retain the
+ documented native fallback instead of approximating the browser CSS engine.
+ """
+ dom_style = (spec.get("dom") or {}).get("style") or {}
+ variables = {key: value for key, value in dom_style.items() if key.startswith("--")}
+
+ def resolve_stops(value: Any) -> Any:
+ if not isinstance(value, list):
+ return value
+ changed = False
+ out: list[Any] = []
+ for stop in value:
+ if isinstance(stop, (list, tuple)) and len(stop) >= 2:
+ paint = _resolve_css_var(stop[1], variables)
+ if paint != stop[1]:
+ changed = True
+ copied = list(stop)
+ copied[1] = paint
+ out.append(copied if isinstance(stop, list) else tuple(copied))
+ continue
+ out.append(stop)
+ return out if changed else value
+
+ def rewrite(value: Any) -> Any:
+ if isinstance(value, dict):
+ changed = False
+ out: dict[Any, Any] = {}
+ for key, item in value.items():
+ if key == "stops":
+ resolved = resolve_stops(item)
+ elif isinstance(item, str) and (
+ key in _STATIC_PAINT_KEYS
+ or (isinstance(key, str) and (key.startswith("--") or key.endswith("_color")))
+ ):
+ resolved = _resolve_css_var(item, variables)
+ else:
+ resolved = rewrite(item)
+ changed = changed or resolved is not item
+ out[key] = resolved
+ return out if changed else value
+ if isinstance(value, list):
+ out = [rewrite(item) for item in value]
+ return (
+ out if any(new is not old for new, old in zip(out, value, strict=True)) else value
+ )
+ return value
+
+ return rewrite(spec)
+
+
def _num(v: float) -> str:
return f"{v:.2f}".rstrip("0").rstrip(".")
+def _axis_grid_attrs(style: dict[str, Any]) -> str:
+ opacity = float(style.get("grid_opacity", 1.0))
+ dash = _AXIS_GRID_DASHES.get(str(style.get("grid_dash", "solid")))
+ return (f' stroke-opacity="{_num(opacity)}"' if opacity < 1 else "") + (
+ f' stroke-dasharray="{",".join(_num(value) for value in dash)}"' if dash else ""
+ )
+
+
# Embedded heatmap/density rasters use the shared truecolor PNG encoder.
_png_rgba = _png.png_truecolor
@@ -850,6 +986,7 @@ def axis_ticks(
def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str:
+ spec = _resolve_static_css_vars(spec)
width, height, compact, plot = layout(spec)
xa, ya = spec["x_axis"], spec["y_axis"]
sx = _Scale(xa, plot["x"], plot["x"] + plot["w"])
@@ -864,7 +1001,10 @@ 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 {}
- grid_color = escape(_css(dom_style.get("--chart-grid"), _GRID))
+ 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)
+ default_text = _css(dom_style.get("--chart-text"), _TEXT)
grid: list[str] = []
labels: list[str] = []
# "none" silences the whole axis chrome (sparklines); "off" hides only the
@@ -879,7 +1019,10 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list
px = float(sx(v))
grid.append(
f''
+ f'y2="{_num(plot["y"] + plot["h"])}" '
+ f'stroke="{escape(_css(xstyle.get("grid_color"), default_grid))}" '
+ f'stroke-width="{_num(float(xstyle.get("grid_width", 1)))}"'
+ f"{_axis_grid_attrs(xstyle)}/>"
)
for v in yt:
if hide_y:
@@ -887,20 +1030,40 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list
py = float(sy(v))
grid.append(
f''
+ f'y2="{_num(py)}" stroke="{escape(_css(ystyle.get("grid_color"), default_grid))}" '
+ f'stroke-width="{_num(float(ystyle.get("grid_width", 1)))}"'
+ f"{_axis_grid_attrs(ystyle)}/>"
)
if not hide_x_labels:
for v in xlab:
tick_y = plot["y"] - 7 if xa.get("side") == "top" else plot["y"] + plot["h"] + 16
labels.append(
f'{escape(_tick_text(xa, v, xstep))}'
+ f'fill="{escape(_css(xstyle.get("tick_label_color", xstyle.get("tick_color")), default_text))}" '
+ f'font-size="{_num(float(xstyle.get("tick_label_size", xstyle.get("tick_size", 11))))}" '
+ f'text-anchor="middle"'
+ + (
+ f' transform="rotate({_num(float(xa["tick_label_angle"]))} '
+ f'{_num(float(sx(v)))} {_num(tick_y)})"'
+ if xa.get("tick_label_angle") is not None
+ else ""
+ )
+ + f">{escape(_tick_text(xa, v, xstep))}"
)
if not hide_y_labels:
for v in ylab:
labels.append(
f'{escape(_tick_text(ya, v, ystep))}'
+ f'fill="{escape(_css(ystyle.get("tick_label_color", ystyle.get("tick_color")), default_text))}" '
+ f'font-size="{_num(float(ystyle.get("tick_label_size", ystyle.get("tick_size", 11))))}" '
+ f'text-anchor="end"'
+ + (
+ f' transform="rotate({_num(float(ya["tick_label_angle"]))} '
+ f'{_num(plot["x"] - 8)} {_num(float(sy(v)) + 4)})"'
+ if ya.get("tick_label_angle") is not None
+ else ""
+ )
+ + f">{escape(_tick_text(ya, v, ystep))}"
)
# -- marks --------------------------------------------------------------
@@ -909,7 +1072,7 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list
def line_attrs(style: dict[str, Any], color: str) -> str:
w = float(style.get("width", 1.5))
- op = float(style.get("opacity", 1.0))
+ op = _stroke_opacity(style)
return (
f'stroke="{escape(color)}" stroke-width="{_num(w)}" fill="none" '
f'stroke-linejoin="round" stroke-linecap="round"'
@@ -949,12 +1112,12 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
if isinstance(fill_spec, dict)
else escape(color)
)
- op = float(style.get("opacity", 0.35))
+ op = _fill_opacity(style, 0.35)
joined = f"{top_path} L {base_path[2:]} Z" # strip the M of the return path
marks.append(f'')
lw = float(style.get("line_width", 1.2))
if lw > 0:
- lop = float(style.get("line_opacity", 1.0))
+ lop = _stroke_opacity(style, 0.35) * float(style.get("line_opacity", 1.0))
line_color = style.get("line_color") or color
outline_path = joined if style.get("stroke_perimeter") else top_path
marks.append(
@@ -992,19 +1155,21 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
chrome.append(
f'{escape(str(spec["title"]))}'
+ f'fill="{escape(default_text)}">{escape(str(spec["title"]))}'
)
if xa.get("label") and not hide_x:
chrome.append(
f'{escape(str(xa["label"]))}'
+ f'text-anchor="middle" font-size="{_num(float(xstyle.get("label_size", 12)))}" '
+ f'font-weight="500" fill="{escape(_css(xstyle.get("label_color"), default_text))}">'
+ f"{escape(str(xa['label']))}"
)
if ya.get("label") and not hide_y:
cx, cy = 14, plot["y"] + plot["h"] / 2
chrome.append(
- f'{escape(str(ya["label"]))}'
)
named = [t for t in spec["traces"] if t.get("name")]
@@ -1029,7 +1194,9 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
if side in frame_sides:
baselines += (
f''
+ f'y2="{_num(plot["y"] + plot["h"])}" '
+ f'stroke="{escape(_css(ystyle.get("axis_color"), default_axis))}" '
+ f'stroke-width="{_num(float(ystyle.get("axis_width", 1)))}"/>'
)
if not hide_x:
for side, y in (("top", plot["y"]), ("bottom", plot["y"] + plot["h"])):
@@ -1037,9 +1204,52 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
baselines += (
f''
+ f'stroke="{escape(_css(xstyle.get("axis_color"), default_axis))}" '
+ f'stroke-width="{_num(float(xstyle.get("axis_width", 1)))}"/>'
)
+ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]:
+ length = max(0.0, float(style.get("tick_length", 0)))
+ direction = str(style.get("tick_direction", "out"))
+ if direction == "in":
+ return length, 0.0, float(style.get("tick_width", 1))
+ if direction == "inout":
+ return length / 2, length / 2, float(style.get("tick_width", 1))
+ return 0.0, length, float(style.get("tick_width", 1))
+
+ if not hide_x:
+ inward, outward, tick_width = tick_span(xstyle)
+ side = xa.get("side", "bottom")
+ edge = plot["y"] if side == "top" else plot["y"] + plot["h"]
+ for value in xt:
+ x = float(sx(value))
+ y1, y2 = (
+ (edge - outward, edge + inward)
+ if side == "top"
+ else (edge - inward, edge + outward)
+ )
+ baselines += (
+ f''
+ )
+ if not hide_y:
+ inward, outward, tick_width = tick_span(ystyle)
+ side = ya.get("side", "left")
+ edge = plot["x"] + plot["w"] if side == "right" else plot["x"]
+ for value in yt:
+ y = float(sy(value))
+ x1, x2 = (
+ (edge - inward, edge + outward)
+ if side == "right"
+ else (edge - outward, edge + inward)
+ )
+ baselines += (
+ f''
+ )
+
clip_id = svg.uid("clip")
svg.defs.append(
f' str:
f"{''.join(grid)}"
f'{"".join(marks)}'
f"{baselines}"
- f'{"".join(labels)}'
+ f'{"".join(labels)}'
f"{''.join(chrome)}"
f""
)
@@ -1165,7 +1375,7 @@ def _segment_marks(
y0 = _column(blob, cols[t["y0"]])
y1 = _column(blob, cols[t["y1"]])
width = float(style.get("width", 1.2))
- op = float(style.get("opacity", 1.0))
+ op = _stroke_opacity(style)
channel = t.get("color") or {}
if channel.get("mode") == "continuous":
rgb = _lut(channel.get("colormap", "viridis"), _column(blob, cols[channel["buf"]]))
@@ -1218,7 +1428,8 @@ def _scatter_marks(
else:
radii = np.full(n, float(size_ch.get("size", 4.0)) / 2)
- op = float(style.get("opacity", 0.8))
+ fill_op = _fill_opacity(style, 0.8)
+ stroke_op = _stroke_opacity(style, 0.8)
stroke_w = float(style.get("stroke_width", 0.0))
line_symbol = style.get("symbol") in {"plus_line", "x_line"}
if line_symbol and stroke_w <= 0:
@@ -1230,7 +1441,12 @@ def _scatter_marks(
# Collection alpha applies to faces and edges. A missing explicit stroke
# means edgecolors="face", so resolve the edge separately for every mark.
- out = [f''] if op < 1 else [""]
+ attrs = ""
+ if fill_op < 1:
+ attrs += f' fill-opacity="{_num(fill_op)}"'
+ if stroke_op < 1:
+ attrs += f' stroke-opacity="{_num(stroke_op)}"'
+ out = [f""]
for i in range(n):
fill_attr = f' fill="{escape(fills[i])}"'
point_stroke = stroke or (fills[i] if stroke_w or line_symbol else None)
@@ -1272,12 +1488,14 @@ def _triangle_mesh_marks(
else:
fills = [_css(color_ch.get("color"), fallback)] * n
- opacity = float(style.get("opacity", 1.0))
+ fill_op = _fill_opacity(style)
+ stroke_op = _stroke_opacity(style)
stroke_width = float(style.get("stroke_width", 0.0))
stroke = _css(style.get("stroke"), fallback) if stroke_width else None
- group_attr = f' fill-opacity="{_num(opacity)}"' if opacity < 1 else ""
+ group_attr = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else ""
stroke_attr = (
f' stroke="{escape(stroke)}" stroke-width="{_num(stroke_width)}"'
+ + (f' stroke-opacity="{_num(stroke_op)}"' if stroke_op < 1 else "")
if stroke is not None
else ""
)
@@ -1296,12 +1514,15 @@ def _triangle_mesh_marks(
def _bar_fill(style: dict, color: str, svg: _Svg, plot: dict) -> tuple[str, str]:
fill_spec = style.get("fill")
fill = svg.gradient(fill_spec, color, plot) if isinstance(fill_spec, dict) else escape(color)
- op = float(style.get("opacity", 0.85))
+ fill_op = _fill_opacity(style, 0.85)
+ stroke_op = _stroke_opacity(style, 0.85)
stroke_w = float(style.get("stroke_width", 0.0))
stroke = _css(style.get("stroke"), color) if stroke_w else None
- extra = f' fill-opacity="{_num(op)}"' if op < 1 else ""
+ extra = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else ""
if stroke:
extra += f' stroke="{escape(stroke)}" stroke-width="{_num(stroke_w)}"'
+ if stroke_op < 1:
+ extra += f' stroke-opacity="{_num(stroke_op)}"'
return fill, extra
@@ -1412,8 +1633,17 @@ def _density_image(
grid = _density_column(blob, cols[d["buf"]], d).reshape(h, w)
gmax = float(d.get("max") or 1.0) or 1.0
tnorm = np.clip(grid / gmax, 0.0, 1.0)
- rgb = _lut(d.get("colormap", "viridis"), tnorm.reshape(-1)).reshape(h, w, 3)
- alpha = (np.clip(tnorm * 1.35, 0, 1) * 255 * float(style.get("opacity", 0.85))).astype(np.uint8)
+ paint_alpha = 1.0
+ if d.get("color") is not None:
+ red, green, blue, alpha8 = _paint_rgba8(d["color"])
+ rgb = np.empty((h, w, 3), dtype=np.uint8)
+ rgb[:] = (red, green, blue)
+ paint_alpha = alpha8 / 255.0
+ else:
+ rgb = _lut(d.get("colormap", "viridis"), tnorm.reshape(-1)).reshape(h, w, 3)
+ alpha = (np.clip(tnorm * 1.35, 0, 1) * 255 * _fill_opacity(style, 0.85) * paint_alpha).astype(
+ np.uint8
+ )
alpha[tnorm <= 0] = 0
rgba = np.dstack([rgb, alpha])[::-1].tobytes() # flip: PNG rows are top-first
return _grid_image(w, h, rgba, d["x_range"], d["y_range"], sx, sy)
@@ -1424,15 +1654,15 @@ def _heatmap_image(hm: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, st
if "rgba_bufs" in hm:
channels = [_column(blob, cols[index]) for index in hm["rgba_bufs"]]
rgba_array = np.clip(np.column_stack(channels) * 255.0, 0, 255).astype(np.uint8)
- rgba_array[:, 3] = (
- rgba_array[:, 3].astype(np.float64) * float(style.get("opacity", 1.0))
- ).astype(np.uint8)
+ rgba_array[:, 3] = (rgba_array[:, 3].astype(np.float64) * _fill_opacity(style)).astype(
+ np.uint8
+ )
rgba = rgba_array.reshape(h, w, 4)[::-1].tobytes()
return _grid_image(w, h, rgba, hm["x_range"], hm["y_range"], sx, sy)
raw = _column(blob, cols[hm["buf"]]).reshape(h, w)
t = np.clip(raw, 0.0, 1.0)
rgb = _lut(hm.get("colormap", "viridis"), t.reshape(-1)).reshape(h, w, 3)
- alpha = np.full((h, w), int(255 * float(style.get("opacity", 0.95))), dtype=np.uint8)
+ alpha = np.full((h, w), int(255 * _fill_opacity(style, 0.95)), dtype=np.uint8)
alpha[~np.isfinite(raw)] = 0
rgba = np.dstack([rgb, alpha])[::-1].tobytes()
return _grid_image(w, h, rgba, hm["x_range"], hm["y_range"], sx, sy)
diff --git a/python/xy/components.py b/python/xy/components.py
index 6c0504c0..d2fb0d95 100644
--- a/python/xy/components.py
+++ b/python/xy/components.py
@@ -40,7 +40,7 @@
import numpy as np
-from . import _validate, channels
+from . import _validate, channels, export, styles
from ._figure import Figure, Selection
from .dom import CHART_DOM_SLOTS, validate_dom_slots
@@ -60,12 +60,12 @@
"Annotation",
"Axis",
"Chart",
+ "Colorbar",
"Component",
"FacetChart",
"Interaction",
"Legend",
"Mark",
- "MarkStyle",
"Modebar",
"Theme",
"Tooltip",
@@ -78,6 +78,7 @@
"box_chart",
"callout",
"chart",
+ "colorbar",
"column",
"column_chart",
"contour",
@@ -102,7 +103,6 @@
"legend",
"line",
"line_chart",
- "mark_style",
"marker",
"modebar",
"scatter",
@@ -152,6 +152,7 @@ class Mark(Component):
data: Any = None
name: Optional[str] = None
class_name: Optional[str] = None
+ style: dict[str, StyleValue] = field(default_factory=dict)
props: dict[str, Any] = field(default_factory=dict)
@@ -211,6 +212,16 @@ class Tooltip(Component):
render: Any = None
+@dataclass
+class Colorbar(Component):
+ """Color scale chrome; ``render`` remains opaque for Reflex adapters."""
+
+ show: bool = True
+ class_name: Optional[str] = None
+ style: dict[str, StyleValue] = field(default_factory=dict)
+ render: Any = None
+
+
@dataclass
class Modebar(Component):
show: bool = True
@@ -225,13 +236,6 @@ class Theme(Component):
style: dict[str, StyleValue] = field(default_factory=dict)
-@dataclass
-class MarkStyle(Component):
- hover: dict[str, StyleValue] = field(default_factory=dict)
- selected: dict[str, StyleValue] = field(default_factory=dict)
- unselected: dict[str, StyleValue] = field(default_factory=dict)
-
-
@dataclass
class Interaction(Component):
hover: Optional[bool] = None
@@ -265,6 +269,7 @@ def scatter(
symbol: str = "circle",
stroke: Optional[str] = None,
stroke_width: float = 0.0,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -280,6 +285,7 @@ def scatter(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "scatter style"),
props={
"color": color,
"size": size,
@@ -308,6 +314,7 @@ def line(
opacity: float = 1.0,
curve: str = "linear",
dash: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -321,6 +328,7 @@ def line(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "line style"),
props={
"color": color,
"width": width,
@@ -349,6 +357,7 @@ def area(
fill: Any = None,
curve: str = "linear",
dash: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -363,6 +372,7 @@ def area(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "area style"),
props={
"base": base,
"color": color,
@@ -392,6 +402,7 @@ def error_band(
line_width: float = 0.0,
line_opacity: float = 0.0,
fill: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -404,6 +415,7 @@ def error_band(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "error_band style"),
props={
"upper": upper,
"color": color,
@@ -429,6 +441,7 @@ def errorbar(
width: float = 1.2,
cap_size: Optional[float] = None,
opacity: float = 1.0,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -441,6 +454,7 @@ def errorbar(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "errorbar style"),
props={
"yerr": yerr,
"xerr": xerr,
@@ -467,6 +481,7 @@ def segments(
domain: Optional[tuple[float, float]] = None,
width: float = 1.2,
opacity: float = 1.0,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -479,6 +494,7 @@ def segments(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "segments style"),
props={
"x1": x1,
"y1": y1,
@@ -509,6 +525,7 @@ def triangle_mesh(
opacity: float = 1.0,
stroke: Optional[str] = None,
stroke_width: float = 0.0,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -521,6 +538,7 @@ def triangle_mesh(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "triangle_mesh style"),
props={
"x1": x1,
"y1": y1,
@@ -549,6 +567,7 @@ def step(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -561,6 +580,7 @@ def step(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "step style"),
props={
"where": where,
"color": color,
@@ -584,6 +604,7 @@ def stairs(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -596,6 +617,7 @@ def stairs(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "stairs style"),
props={
"where": where,
"color": color,
@@ -621,6 +643,7 @@ def stem(
marker: bool = True,
marker_size: float = 5.0,
symbol: str = "circle",
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -633,6 +656,7 @@ def stem(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "stem style"),
props={
"base": base,
"color": color,
@@ -657,6 +681,7 @@ def ecdf(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -668,6 +693,7 @@ def ecdf(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "ecdf style"),
props={
"bins": bins,
"color": color,
@@ -693,6 +719,7 @@ def box(
orientation: str = "vertical",
show_outliers: bool = True,
outlier_size: float = 4.0,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -704,6 +731,7 @@ def box(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "box style"),
props={
"x": x,
"group": group,
@@ -731,6 +759,7 @@ def violin(
bins: int = 64,
opacity: float = 0.55,
orientation: str = "vertical",
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -742,6 +771,7 @@ def violin(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "violin style"),
props={
"x": x,
"group": group,
@@ -770,6 +800,7 @@ def hexbin(
name: Optional[str] = None,
colormap: str = channels.DEFAULT_COLORMAP,
opacity: float = 0.9,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -782,6 +813,7 @@ def hexbin(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "hexbin style"),
props={
"gridsize": gridsize,
"range": range,
@@ -811,6 +843,7 @@ def contour(
width: float = 1.1,
opacity: float = 0.9,
dash_negative: bool = False,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -823,6 +856,7 @@ def contour(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "contour style"),
props={
"z": z,
"levels": levels,
@@ -853,6 +887,7 @@ def histogram(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -864,6 +899,7 @@ def histogram(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "histogram style"),
props={
"bins": bins,
"range": range,
@@ -896,6 +932,7 @@ def hist(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -915,6 +952,7 @@ def hist(
stroke=stroke,
stroke_width=stroke_width,
fill=fill,
+ style=style,
class_name=class_name,
x_axis=x_axis,
y_axis=y_axis,
@@ -939,6 +977,7 @@ def bar(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -952,6 +991,7 @@ def bar(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "bar style"),
props={
"color": color,
"colors": colors,
@@ -989,6 +1029,7 @@ def column(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -1001,6 +1042,7 @@ def column(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "column style"),
props={
"color": color,
"colors": colors,
@@ -1030,6 +1072,7 @@ def heatmap(
colormap: str = channels.DEFAULT_COLORMAP,
domain: Optional[tuple[float, float]] = None,
opacity: float = 0.95,
+ style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
y_axis: str = "y",
@@ -1042,6 +1085,7 @@ def heatmap(
data=data,
name=name,
class_name=class_name,
+ style=_mark_style_dict(style, "heatmap style"),
props={
"z": z,
"colormap": colormap,
@@ -1413,7 +1457,7 @@ def x_axis(
tick_label_min_gap, "x_axis tick_label_min_gap"
),
side=_axis_side(side, "x"),
- style=_style_dict(style, "x_axis style"),
+ style=styles.compile_axis_style(style, "x_axis style"),
)
@@ -1464,7 +1508,7 @@ def y_axis(
tick_label_min_gap, "y_axis tick_label_min_gap"
),
side=_axis_side(side, "y"),
- style=_style_dict(style, "y_axis style"),
+ style=styles.compile_axis_style(style, "y_axis style"),
)
@@ -1512,6 +1556,23 @@ def tooltip(
)
+def colorbar(
+ *children: Any,
+ show: bool = True,
+ render: Any = None,
+ class_name: Optional[str] = None,
+ style: Optional[dict[str, StyleValue]] = None,
+) -> Colorbar:
+ """Style built-in color chrome or supply an opaque Reflex replacement."""
+ show, render = _chrome_render_args(children, show, render, "colorbar")
+ return Colorbar(
+ show=_strict_bool(show, "colorbar show"),
+ class_name=_optional_string(class_name, "colorbar class_name"),
+ style=_style_dict(style, "colorbar style"),
+ render=render,
+ )
+
+
def modebar(
show: bool = True,
*,
@@ -1561,26 +1622,6 @@ def theme(
return Theme(style=merged)
-def mark_style(
- *,
- hover: Optional[dict[str, StyleValue]] = None,
- selected: Optional[dict[str, StyleValue]] = None,
- unselected: Optional[dict[str, StyleValue]] = None,
-) -> MarkStyle:
- """Style mark interaction states declaratively.
-
- The first renderer-backed fields are scatter hover highlights and
- selected/unselected opacity. The component shape is shared by future mark
- kinds so charts do not need an API migration when bars/lines gain richer
- state styling.
- """
- return MarkStyle(
- hover=_style_dict(hover, "mark_style hover"),
- selected=_style_dict(selected, "mark_style selected"),
- unselected=_style_dict(unselected, "mark_style unselected"),
- )
-
-
def interaction_config(
*,
hover: Optional[bool] = None,
@@ -1757,9 +1798,9 @@ def figure(self) -> Figure:
axes = {c.id or c.which: c for c in axis_children}
legends = [c for c in self.children if isinstance(c, Legend)]
tooltips = [c for c in self.children if isinstance(c, Tooltip)]
+ colorbars = [c for c in self.children if isinstance(c, Colorbar)]
modebars = [c for c in self.children if isinstance(c, Modebar)]
themes = [c for c in self.children if isinstance(c, Theme)]
- mark_styles = [c for c in self.children if isinstance(c, MarkStyle)]
interactions = [c for c in self.children if isinstance(c, Interaction)]
legend_shows = [_strict_bool(c.show, "legend show") for c in legends]
known = (
@@ -1768,16 +1809,16 @@ def figure(self) -> Figure:
Axis,
Legend,
Tooltip,
+ Colorbar,
Modebar,
Theme,
- MarkStyle,
Interaction,
)
unknown = [c for c in self.children if not isinstance(c, known)]
if unknown:
raise TypeError(
f"{self.kind}() children must be marks/annotations/axes/legend/tooltip/"
- f"modebar/theme/mark_style/interaction_config, got "
+ f"colorbar/modebar/theme/interaction_config, got "
f"{[type(c).__name__ for c in unknown]}"
)
@@ -1825,12 +1866,6 @@ def figure(self) -> Figure:
fig.style.update(self.style)
for slot, slot_style in self.styles.items():
fig.chrome_styles[slot] = {**fig.chrome_styles.get(slot, {}), **slot_style}
- for node in mark_styles:
- fig.set_mark_style(
- hover=node.hover,
- selected=node.selected,
- unselected=node.unselected,
- )
if (
self.hover is not None
or self.click is not None
@@ -1918,6 +1953,11 @@ def figure(self) -> Figure:
_apply_chrome_node(fig, "tooltip", node.class_name, node.style)
fig.show_tooltip = node.show
fig.tooltip = _tooltip_spec(node, tooltip_aliases, tooltip_sources)
+ if colorbars:
+ node = colorbars[-1]
+ _apply_chrome_node(fig, "colorbar", node.class_name, node.style)
+ if not node.show:
+ fig.colorbar_options = None
self._figure = fig
return fig
@@ -1928,7 +1968,7 @@ def chrome_components(self) -> dict[str, Any]:
Core xy does not import or serialize framework components. The
objects returned here are the exact Python objects passed to
- `fc.legend(...)` / `fc.tooltip(...)`, so an adapter can mount them while
+ `fc.legend(...)` / `fc.tooltip(...)` / `fc.colorbar(...)`, so an adapter can mount them while
standalone HTML keeps using the built-in safe DOM fallback.
"""
result: dict[str, Any] = {}
@@ -1938,6 +1978,9 @@ def chrome_components(self) -> dict[str, Any]:
tooltips = [c for c in self.children if isinstance(c, Tooltip)]
if tooltips and tooltips[-1].render is not None:
result["tooltip"] = tooltips[-1].render
+ colorbars = [c for c in self.children if isinstance(c, Colorbar)]
+ if colorbars and colorbars[-1].render is not None:
+ result["colorbar"] = colorbars[-1].render
return result
def reflex_components(self) -> dict[str, Any]:
@@ -2001,9 +2044,9 @@ def to_png(
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 2.0,
- engine: str = "native",
+ engine: export.Engine = export.Engine.default,
optimize: bool = False,
- chromium: Optional[str] = None,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> bytes:
@@ -2014,7 +2057,7 @@ def to_png(
scale=scale,
engine=engine,
optimize=optimize,
- chromium=chromium,
+ custom_css=custom_css,
sandbox=sandbox,
gl=gl,
)
@@ -2108,6 +2151,10 @@ def _style_dict(value: Any, label: str) -> dict[str, StyleValue]:
return Figure._style_mapping(value, label)
+def _mark_style_dict(value: Any, label: str) -> dict[str, StyleValue]:
+ return styles.normalize_css_style(value, label)
+
+
def _slot_styles_dict(value: Any, label: str) -> dict[str, dict[str, StyleValue]]:
"""Per-slot inline styles (`styles={slot: {...}}` — docs/styling.md's
fourth mechanism): slot names validate against `CHART_DOM_SLOTS`, each
@@ -2310,6 +2357,7 @@ def _facet_mark(mark: Mark, mask: np.ndarray, n: int) -> Mark:
data=_subset_data(mark.data, mask, n),
name=mark.name,
class_name=mark.class_name,
+ style=mark.style,
props=mark.props,
)
@@ -2485,9 +2533,9 @@ def to_png(
path: Optional[str | PathLike[str]] = None,
*,
scale: float = 2.0,
- engine: str = "native",
+ engine: export.Engine = export.Engine.default,
optimize: bool = False,
- chromium: Optional[str] = None,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> bytes:
@@ -2496,7 +2544,7 @@ def to_png(
scale=scale,
engine=engine,
optimize=optimize,
- chromium=chromium,
+ custom_css=custom_css,
sandbox=sandbox,
gl=gl,
)
@@ -2606,6 +2654,7 @@ def _apply_scatter(fig: Figure, m: Mark, data: Any) -> None:
symbol=m.props["symbol"],
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
+ style=m.style,
)
except Exception:
# Axis resolution happens before Figure.scatter's own transactional
@@ -2624,6 +2673,7 @@ def _apply_line(fig: Figure, m: Mark, data: Any) -> None:
opacity=m.props["opacity"],
curve=m.props["curve"],
dash=m.props["dash"],
+ style=m.style,
)
@@ -2643,6 +2693,7 @@ def _apply_area(fig: Figure, m: Mark, data: Any) -> None:
fill=m.props["fill"],
curve=m.props["curve"],
dash=m.props["dash"],
+ style=m.style,
)
@@ -2657,6 +2708,7 @@ def _apply_error_band(fig: Figure, m: Mark, data: Any) -> None:
line_width=m.props["line_width"],
line_opacity=m.props["line_opacity"],
fill=m.props["fill"],
+ style=m.style,
)
@@ -2673,6 +2725,7 @@ def _apply_errorbar(fig: Figure, m: Mark, data: Any) -> None:
width=m.props["width"],
cap_size=m.props["cap_size"],
opacity=m.props["opacity"],
+ style=m.style,
)
@@ -2689,6 +2742,7 @@ def _apply_segments(fig: Figure, m: Mark, data: Any) -> None:
domain=m.props["domain"],
width=m.props["width"],
opacity=m.props["opacity"],
+ style=m.style,
)
@@ -2708,6 +2762,7 @@ def _apply_triangle_mesh(fig: Figure, m: Mark, data: Any) -> None:
opacity=m.props["opacity"],
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
+ style=m.style,
)
@@ -2721,6 +2776,7 @@ def _apply_step(fig: Figure, m: Mark, data: Any) -> None:
width=m.props["width"],
opacity=m.props["opacity"],
dash=m.props["dash"],
+ style=m.style,
)
@@ -2735,6 +2791,7 @@ def _apply_stairs(fig: Figure, m: Mark, data: Any) -> None:
width=m.props["width"],
opacity=m.props["opacity"],
dash=m.props["dash"],
+ style=m.style,
)
@@ -2751,6 +2808,7 @@ def _apply_stem(fig: Figure, m: Mark, data: Any) -> None:
marker=m.props["marker"],
marker_size=m.props["marker_size"],
symbol=m.props["symbol"],
+ style=m.style,
)
@@ -2763,6 +2821,7 @@ def _apply_ecdf(fig: Figure, m: Mark, data: Any) -> None:
width=m.props["width"],
opacity=m.props["opacity"],
dash=m.props["dash"],
+ style=m.style,
)
@@ -2780,6 +2839,7 @@ def _apply_box(fig: Figure, m: Mark, data: Any) -> None:
orientation=m.props["orientation"],
show_outliers=m.props["show_outliers"],
outlier_size=m.props["outlier_size"],
+ style=m.style,
)
@@ -2796,6 +2856,7 @@ def _apply_violin(fig: Figure, m: Mark, data: Any) -> None:
bins=m.props["bins"],
opacity=m.props["opacity"],
orientation=m.props["orientation"],
+ style=m.style,
)
@@ -2814,6 +2875,7 @@ def _apply_hexbin(fig: Figure, m: Mark, data: Any) -> None:
name=m.name,
colormap=m.props["colormap"],
opacity=m.props["opacity"],
+ style=m.style,
)
@@ -2830,6 +2892,7 @@ def _apply_contour(fig: Figure, m: Mark, data: Any) -> None:
width=m.props["width"],
opacity=m.props["opacity"],
dash_negative=m.props.get("dash_negative", False),
+ style=m.style,
)
@@ -2847,6 +2910,7 @@ def _apply_histogram(fig: Figure, m: Mark, data: Any) -> None:
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
fill=m.props["fill"],
+ style=m.style,
)
@@ -2859,6 +2923,7 @@ def _apply_heatmap(fig: Figure, m: Mark, data: Any) -> None:
colormap=m.props["colormap"],
domain=m.props["domain"],
opacity=m.props["opacity"],
+ style=m.style,
)
@@ -2880,6 +2945,7 @@ def _apply_bar(fig: Figure, m: Mark, data: Any) -> None:
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
fill=m.props["fill"],
+ style=m.style,
)
@@ -2901,6 +2967,7 @@ def _apply_column(fig: Figure, m: Mark, data: Any) -> None:
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
fill=m.props["fill"],
+ style=m.style,
)
diff --git a/python/xy/dom.py b/python/xy/dom.py
index efe39bf1..2ed6f4c4 100644
--- a/python/xy/dom.py
+++ b/python/xy/dom.py
@@ -13,6 +13,10 @@
"legend",
"legend_item",
"legend_swatch",
+ "colorbar",
+ "colorbar_bar",
+ "colorbar_tick",
+ "colorbar_title",
"tooltip",
"modebar",
"modebar_button",
diff --git a/python/xy/export.py b/python/xy/export.py
index bec2077f..6f55e654 100644
--- a/python/xy/export.py
+++ b/python/xy/export.py
@@ -13,7 +13,9 @@
import shutil
import subprocess
import tempfile
+import warnings
from contextlib import suppress
+from enum import StrEnum
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Optional, SupportsFloat, SupportsIndex, cast
@@ -21,6 +23,19 @@
if TYPE_CHECKING:
from ._figure import Figure
+
+class Engine(StrEnum):
+ """PNG export engine.
+
+ ``default`` is XY's fast, deterministic native renderer. ``chromium``
+ renders the standalone chart with an automatically discovered installed
+ Chromium-family browser for browser CSS/WebGL fidelity.
+ """
+
+ default = "default"
+ chromium = "chromium"
+
+
# Warn above this payload size; base64 carries a stated ~33% tax (§29).
EMBED_WARN_BYTES = 64 * 2**20
@@ -33,16 +48,31 @@
# string). 48 MiB is divisible by 3, keeping the alignment invariant trivially.
_B64_CHUNK_BYTES = 48 * 2**20
-# Static PNG export shells out to a headless Chromium (the same engine that
-# renders the chart interactively) — no Python browser dependency, no
-# kaleido-class native package. Discovery order: explicit env var, then PATH,
-# then common local/CI browser installs.
+# Browser-fidelity PNG export shells out to an installed, headless-capable
+# browser — no Python browser dependency and no bundled browser runtime. The
+# first adapter is the Chromium family (Chrome, Chromium, Edge, and the smaller
+# headless shell), all of which share the command line + CDP surface used here.
+# The public enum deliberately selects the fidelity tier rather than exposing
+# executable paths; discovery remains an implementation detail.
+_BROWSER_ENV = "XY_BROWSER"
_CHROMIUM_ENV = "XY_CHROMIUM"
-_CHROMIUM_NAMES = ("chromium", "chromium-browser", "chrome", "google-chrome")
-_CHROMIUM_FALLBACKS = (
+_BROWSER_NAMES = (
+ "chrome-headless-shell",
+ "chromium",
+ "chromium-browser",
+ "chrome",
+ "google-chrome",
+ "google-chrome-stable",
+ "microsoft-edge",
+ "microsoft-edge-stable",
+ "msedge",
+)
+_BROWSER_FALLBACKS = (
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
"/opt/pw-browsers/chromium",
)
_STATIC = Path(__file__).parent / "static"
@@ -313,21 +343,75 @@ def notebook_iframe(doc: str, *, width: object, height: object) -> str:
)
-def find_chromium(explicit: Optional[str] = None) -> Optional[str]:
- """Locate a headless-capable Chromium/Chrome, or None."""
- for cand in (explicit, os.environ.get(_CHROMIUM_ENV)):
- if cand and Path(cand).exists():
- return cand
- for name in _CHROMIUM_NAMES:
+def _installed_browser(candidate: object) -> Optional[str]:
+ if not isinstance(candidate, str) or not candidate.strip():
+ return None
+ text = os.path.expandvars(os.path.expanduser(candidate.strip()))
+ if Path(text).exists():
+ return text
+ return shutil.which(text)
+
+
+def find_browser(explicit: Optional[str] = None) -> Optional[str]:
+ """Locate a supported installed browser executable, or return ``None``.
+
+ ``None`` and ``"auto"`` search ``XY_BROWSER``, the legacy
+ ``XY_CHROMIUM`` variable, ``PATH``, and common application locations.
+ Any other value is treated as an explicit path or executable name and is
+ not silently replaced with a different installed browser when missing.
+ """
+ if explicit not in (None, "auto"):
+ return _installed_browser(explicit)
+ for env_name in (_BROWSER_ENV, _CHROMIUM_ENV):
+ configured = os.environ.get(env_name)
+ if configured:
+ return _installed_browser(configured)
+ for name in _BROWSER_NAMES:
found = shutil.which(name)
if found:
return found
- for cand in _CHROMIUM_FALLBACKS:
+ fallbacks = list(_BROWSER_FALLBACKS)
+ for root_name in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
+ root = os.environ.get(root_name)
+ if not root:
+ continue
+ fallbacks.extend(
+ (
+ str(Path(root) / "Google/Chrome/Application/chrome.exe"),
+ str(Path(root) / "Microsoft/Edge/Application/msedge.exe"),
+ )
+ )
+ for cand in fallbacks:
if Path(cand).exists():
return cand
return None
+def find_chromium(explicit: Optional[str] = None) -> Optional[str]:
+ """Compatibility alias for :func:`find_browser`."""
+ return find_browser(explicit)
+
+
+def _png_engine(engine: object, label: str = "PNG") -> str:
+ if isinstance(engine, Engine):
+ return "native" if engine is Engine.default else "browser"
+ if engine == "native":
+ warnings.warn(
+ 'engine="native" is deprecated; use engine=Engine.default instead',
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return "native"
+ if engine in ("chromium", "browser"):
+ warnings.warn(
+ "string export engines are deprecated; use engine=Engine.chromium instead",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return "browser"
+ raise ValueError(f"{label} engine must be Engine.default or Engine.chromium, got {engine!r}")
+
+
def _gl_option(value: object) -> str:
if value not in ("software", "hardware"):
raise ValueError(f"PNG gl must be 'software' or 'hardware', got {value!r}")
@@ -342,13 +426,15 @@ def html_to_png(
scale: float = 2.0,
time_budget_ms: int = 4000,
timeout_s: float = 120.0,
- chromium: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> bytes:
- """Rasterize a standalone chart HTML string to PNG bytes via headless
- Chromium `--screenshot`. Pure mechanism (no Figure), so it is testable
- without numpy. `scale` is the device-pixel ratio (2 = retina-crisp).
+ """Rasterize standalone chart HTML to PNG with an installed headless browser.
+
+ The current adapter supports the Chromium family
+ (Chrome, Chromium, Edge, and chrome-headless-shell). Pure mechanism (no
+ Figure), so it is testable without numpy. `scale` is the device-pixel
+ ratio (2 = retina-crisp).
`gl` picks the WebGL backend: "software" (default) pins SwiftShader for
deterministic pixels on any machine (including GPU-less CI); "hardware"
@@ -361,13 +447,12 @@ def html_to_png(
timeout_s = _positive_finite_float(timeout_s, "PNG timeout_s")
sandbox = _bool_option(sandbox, "PNG sandbox")
gl = _gl_option(gl)
- exe = find_chromium(chromium)
+ exe = find_browser()
if exe is None:
raise RuntimeError(
- "static PNG export needs a Chromium/Chrome binary and none was found. "
- f"Set ${_CHROMIUM_ENV} to its path, put `chromium` on PATH, or install "
- "one (e.g. `playwright install chromium`). HTML export (to_html) needs "
- "nothing extra."
+ "browser PNG export needs a supported Chrome/Chromium/Edge executable "
+ f"and none was found. Set ${_BROWSER_ENV} to its executable path "
+ "or install a supported browser. HTML export (to_html) needs nothing extra."
)
with tempfile.TemporaryDirectory() as td:
page = Path(td) / "chart.html"
@@ -425,40 +510,43 @@ def write_images(
paths: list[str | PathLike[str]],
*,
scale: float = 2.0,
- engine: str = "chromium",
- chromium: Optional[str] = None,
+ engine: Engine = Engine.default,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> list[bytes]:
"""Export many figures to PNGs through ONE browser session.
- `html_to_png` launches a fresh Chromium per image, so a loop over figures
+ `html_to_png` launches a fresh browser per image, so a loop over figures
pays ~1-2 s of browser startup each time — the classic batch-export trap.
- This keeps a single headless Chromium alive (CDP; `_chromium.py`) and
- renders every figure as a tab navigation + screenshot, amortizing startup
- across the list. `engine="native"` is also accepted for symmetry and simply
- loops the (already millisecond-fast, browser-free) native rasterizer.
+ `engine=Engine.chromium` keeps one installed Chromium-family browser alive
+ (CDP; `_chromium.py`) and renders every figure as a tab navigation +
+ screenshot, amortizing startup across the list. The default
+ `engine=Engine.default` simply loops the millisecond-fast, browser-free
+ native rasterizer.
Figures with fluid ("100%") sizes fall back to the same explicit export
- dimensions as `to_png`."""
+ dimensions as `to_png`. `custom_css` is available only with Chromium,
+ where it is injected into every standalone document."""
if len(figs) != len(paths):
raise ValueError(f"write_images got {len(figs)} figures but {len(paths)} paths")
scale = _positive_finite_float(scale, "PNG scale")
sandbox = _bool_option(sandbox, "PNG sandbox")
gl = _gl_option(gl)
- if engine not in ("native", "chromium"):
- raise ValueError(f"PNG engine must be 'native' or 'chromium', got {engine!r}")
- if engine == "native":
+ resolved_engine = _png_engine(engine)
+ if resolved_engine == "native":
+ if custom_css is not None:
+ raise ValueError("custom_css requires engine=Engine.chromium")
return [
- to_png(fig, path, scale=scale, engine="native")
+ to_png(fig, path, scale=scale, engine=Engine.default)
for fig, path in zip(figs, paths, strict=True)
]
- exe = find_chromium(chromium)
+ _custom_css_block(custom_css)
+ exe = find_browser()
if exe is None:
raise RuntimeError(
- "batch PNG export needs a Chromium/Chrome binary and none was found. "
- f"Set ${_CHROMIUM_ENV} to its path, put `chromium` on PATH, or install "
- "one (e.g. `playwright install chromium`)."
+ "batch browser PNG export needs a supported Chrome/Chromium/Edge "
+ f"executable and none was found. Set ${_BROWSER_ENV} to select one."
)
from ._chromium import ChromiumSession
@@ -469,7 +557,7 @@ def write_images(
h = _positive_pixel_count(
fig.height if isinstance(fig.height, int) else 500, "PNG height"
)
- data = session.render_png(to_html(fig), w, h, scale=scale)
+ data = session.render_png(to_html(fig, custom_css=custom_css), w, h, scale=scale)
with open(path, "wb") as f:
f.write(data)
out.append(data)
@@ -483,22 +571,26 @@ def to_png(
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 2.0,
- engine: str = "native",
+ engine: Engine = Engine.default,
optimize: bool = False,
- chromium: Optional[str] = None,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
) -> bytes:
"""Rasterize `fig` to a PNG (bytes, optionally saved).
- `engine="native"` (default) paints the decimated payload with the built-in
+ `engine=Engine.default` paints the decimated payload with the built-in
Rust rasterizer — no browser and millisecond export. Pass
``optimize=True`` to trade latency for the smaller indexed/deflate output.
- `engine="chromium"` renders the standalone HTML in headless Chromium and
- screenshots it, so the pixels match the interactive WebGL chart exactly
- (needs a Chromium binary; honors `chromium`/`sandbox`/`gl` — see
- `html_to_png`). Fluid ("100%") sizes fall back to an explicit export size
- since a raster needs concrete dims."""
+ `engine=Engine.chromium` renders the standalone HTML in an installed
+ browser and screenshots it, so CSS, fonts, and WebGL use that browser's
+ implementation. It automatically discovers Chrome, Chromium, Edge, or
+ `chrome-headless-shell` via `XY_BROWSER`, PATH, and common install locations,
+ and honors `sandbox`/`gl` (see `html_to_png`). `custom_css` injects an
+ author stylesheet into that browser document and is rejected by the native
+ engine. Former string engine values remain deprecated aliases.
+ Fluid ("100%") sizes fall back to an explicit export size since a raster
+ needs concrete dims."""
w = _positive_pixel_count(
width if width is not None else (fig.width if isinstance(fig.width, int) else 800),
"PNG width",
@@ -510,15 +602,23 @@ def to_png(
scale = _positive_finite_float(scale, "PNG scale")
optimize = _bool_option(optimize, "PNG optimize")
sandbox = _bool_option(sandbox, "PNG sandbox")
- if engine not in ("native", "chromium"):
- raise ValueError(f"PNG engine must be 'native' or 'chromium', got {engine!r}")
- if engine == "native":
+ resolved_engine = _png_engine(engine)
+ if resolved_engine == "native":
+ if custom_css is not None:
+ raise ValueError("custom_css requires engine=Engine.chromium")
from . import _raster
data = _raster.to_png(fig, None, width=w, height=h, scale=scale, fast=not optimize)
else:
- doc = to_html(fig)
- data = html_to_png(doc, w, h, scale=scale, chromium=chromium, sandbox=sandbox, gl=gl)
+ doc = to_html(fig, custom_css=custom_css)
+ data = html_to_png(
+ doc,
+ w,
+ h,
+ scale=scale,
+ sandbox=sandbox,
+ gl=gl,
+ )
if path is not None:
with open(path, "wb") as f:
f.write(data)
diff --git a/python/xy/facets.py b/python/xy/facets.py
index dd2a1c22..2f5d0569 100644
--- a/python/xy/facets.py
+++ b/python/xy/facets.py
@@ -253,23 +253,27 @@ def to_png(
path: Optional[str | PathLike[str]] = None,
*,
scale: float = 2.0,
- engine: str = "native",
+ engine: export.Engine = export.Engine.default,
optimize: bool = False,
- chromium: Optional[str] = None,
+ custom_css: Optional[str] = None,
sandbox: bool = True,
+ gl: str = "software",
) -> bytes:
optimize = export._bool_option(optimize, "facet PNG optimize")
- if engine == "chromium":
+ resolved_engine = export._png_engine(engine, "facet PNG")
+ if resolved_engine == "browser":
data = export.html_to_png(
- self.to_html(),
+ self.to_html(custom_css=custom_css),
self.width,
# Match the actual HTML height: panels + gaps + title strip.
self.grid_height + self._title_height,
scale=scale,
- chromium=chromium,
sandbox=sandbox,
+ gl=gl,
)
- elif engine == "native":
+ elif resolved_engine == "native":
+ if custom_css is not None:
+ raise ValueError("custom_css requires engine=Engine.chromium")
if scale <= 0 or not np.isfinite(scale):
raise ValueError("facet PNG scale must be finite and positive")
panel_images: list[np.ndarray] = []
@@ -302,8 +306,8 @@ def to_png(
compression_level=1,
)
)
- else:
- raise ValueError("facet PNG engine must be 'native' or 'chromium'")
+ else: # `_png_engine` returns only these two internal values.
+ raise AssertionError(f"unreachable PNG engine {resolved_engine!r}")
if path is not None:
Path(path).write_bytes(data)
return data
diff --git a/python/xy/interaction.py b/python/xy/interaction.py
index 78f354ed..4ab029d9 100644
--- a/python/xy/interaction.py
+++ b/python/xy/interaction.py
@@ -456,6 +456,8 @@ def density_view(
"x_range": [lo_x, hi_x],
"y_range": [lo_y, hi_y],
}
+ if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None:
+ density["color"] = t.color_ch.constant
if sample is not None:
density["sample"] = sample
return (
diff --git a/python/xy/marks.py b/python/xy/marks.py
index 91fa8b06..7226b72d 100644
--- a/python/xy/marks.py
+++ b/python/xy/marks.py
@@ -16,7 +16,7 @@
import numpy as np
-from . import _validate, channels, columns, kernels
+from . import _validate, channels, columns, kernels, styles
from ._trace import Trace
from .config import DEFAULT_PALETTE, DIRECT_SOFT_CEILING, MAX_CONTOUR_WORK
@@ -40,6 +40,7 @@ def _append_segment_trace(
color_ch: Any = None,
count: Optional[int] = None,
dash: Any = None,
+ extra_style: Optional[dict[str, Any]] = None,
) -> None:
"""Append a compact instanced line-segment trace.
@@ -81,6 +82,7 @@ def _append_segment_trace(
"width": width,
"role": role,
**({"dash": dash} if dash else {}),
+ **(extra_style or {}),
},
color_ch=color_ch,
count=count,
@@ -104,8 +106,13 @@ def segments(
domain: Optional[tuple[float, float]] = None,
width: float = 1.2,
opacity: float = 1.0,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add independent line segments through the shared instanced renderer."""
+ css = styles.compile_mark_style("segments", style)
+ color = css.get("color", color)
+ width = css.get("width", width)
+ opacity = css.get("opacity", opacity)
arrays = [self._as_1d_float(values, "segments color geometry") for values in (x0, y0, x1, y1)]
if len({len(values) for values in arrays}) != 1:
raise ValueError("segments coordinate columns must have equal length")
@@ -130,6 +137,7 @@ def segments(
width=width,
role="segments",
color_ch=None if color_ch.mode == "constant" else color_ch,
+ extra_style=styles._opacity_channels(css),
)
return self
@@ -150,8 +158,14 @@ def triangle_mesh(
opacity: float = 1.0,
stroke: Optional[str] = None,
stroke_width: float = 0.0,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add independently colored filled triangles as one instanced mesh."""
+ css = styles.compile_mark_style("triangle_mesh", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ stroke = css.get("stroke", stroke)
+ stroke_width = css.get("stroke_width", stroke_width)
name = self._optional_text(name, "triangle_mesh name")
opacity = self._opacity(opacity, "triangle_mesh opacity")
stroke = self._optional_css_color(stroke, "triangle_mesh stroke")
@@ -182,6 +196,7 @@ def triangle_mesh(
try:
x0c, y0c, x1c, y1c, x2c, y2c = [self.store.ingest(values) for values in arrays]
style: dict[str, Any] = {"opacity": opacity, "role": "triangle-mesh"}
+ style.update(styles._opacity_channels(css))
if stroke is not None:
style["stroke"] = stroke
if stroke_width:
@@ -350,11 +365,13 @@ def _bar_like(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style_extra: Optional[dict[str, Any]] = None,
) -> "Figure":
name = self._optional_text(name, f"{kind} name")
width = self._positive_scalar(width, f"{kind} width")
opacity = self._opacity(opacity, f"{kind} opacity")
mark_style = self._rect_mark_style(kind, corner_radius, stroke, stroke_width, fill)
+ mark_style.update(style_extra or {})
if mode not in {"grouped", "stacked", "normalized"}:
raise ValueError(f"{kind} mode must be 'grouped', 'stacked', or 'normalized'")
if orientation not in {"vertical", "horizontal"}:
@@ -452,7 +469,13 @@ def line(
opacity: float = 1.0,
curve: str = "linear",
dash: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
+ css = styles.compile_mark_style("line", style)
+ color = css.get("color", color)
+ width = css.get("width", width)
+ opacity = css.get("opacity", opacity)
+ dash = css.get("dash", dash)
name = self._optional_text(name, "line name")
color = self._optional_css_color(color, "line color")
width = self._positive_scalar(width, "line width")
@@ -472,6 +495,7 @@ def line(
xc = self.store.ingest(xc.values[order])
yc = self.store.ingest(yc.values[order])
style: dict[str, Any] = {"color": color, "width": width, "opacity": opacity}
+ style.update(styles._opacity_channels(css))
if curve != "linear":
style["curve"] = curve
if dash_spec is not None:
@@ -508,6 +532,7 @@ def area(
fill: Any = None,
curve: str = "linear",
dash: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a filled area trace between `y` and `base`.
@@ -517,13 +542,21 @@ def area(
`curve="smooth"` renders a monotone cubic through the points; `dash`
dashes the outline.
"""
+ css = styles.compile_mark_style("area", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ line_color = css.get("line_color", line_color)
+ line_width = css.get("line_width", line_width)
+ line_opacity = css.get("line_opacity", line_opacity)
+ fill = css.get("fill", fill)
+ dash = css.get("dash", dash)
name = self._optional_text(name, "area name")
color = self._optional_css_color(color, "area color")
opacity = self._opacity(opacity, "area opacity")
line_color = self._optional_css_color(line_color, "area line_color")
line_width = self._nonnegative_scalar(line_width, "area line_width")
line_opacity = self._opacity(line_opacity, "area line_opacity")
- stroke_perimeter = _validate.optional_bool(stroke_perimeter, "area stroke_perimeter")
+ stroke_perimeter = _validate.bool_param(stroke_perimeter, "area stroke_perimeter")
fill_spec = _validate.mark_fill(fill, "area fill")
curve = _validate.curve(curve, "area curve")
dash_spec = _validate.dash(dash, "area dash")
@@ -549,6 +582,7 @@ def area(
"line_opacity": line_opacity,
"stroke_perimeter": stroke_perimeter,
}
+ style.update(styles._opacity_channels(css))
if line_color is not None:
style["line_color"] = line_color
if fill_spec is not None:
@@ -586,12 +620,19 @@ def error_band(
line_width: float = 0.0,
line_opacity: float = 0.0,
fill: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add an uncertainty/confidence band between ``lower`` and ``upper``.
The band is one filled strip, not one rectangle per observation. It uses
the same M4 reduction and WebGL area path as a large area series.
"""
+ css = styles.compile_mark_style("error_band", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ line_width = css.get("line_width", line_width)
+ line_opacity = css.get("line_opacity", line_opacity)
+ fill = css.get("fill", fill)
name = self._optional_text(name, "error_band name")
color = self._optional_css_color(color, "error_band color")
opacity = self._opacity(opacity, "error_band opacity")
@@ -616,6 +657,7 @@ def error_band(
"line_opacity": line_opacity,
"role": "error-band",
}
+ style.update(styles._opacity_channels(css))
if fill_spec is not None:
style["fill"] = fill_spec
self.traces.append(
@@ -659,6 +701,7 @@ def errorbar(
width: float = 1.2,
cap_size: Optional[float] = None,
opacity: float = 1.0,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add vertical and/or horizontal error bars as instanced segments.
@@ -669,6 +712,10 @@ def errorbar(
spacing of the distinct positions along that axis (0.4 when fewer than
two are distinct); ``cap_size=0`` omits the caps entirely.
"""
+ css = styles.compile_mark_style("errorbar", style)
+ color = css.get("color", color)
+ width = css.get("width", width)
+ opacity = css.get("opacity", opacity)
if yerr is None and xerr is None:
raise ValueError("errorbar requires yerr, xerr, or both")
name = self._optional_text(name, "errorbar name")
@@ -708,6 +755,7 @@ def errorbar(
width=width,
role="y-errorbar",
count=n,
+ extra_style=styles._opacity_channels(css),
)
emitted = True
if xerr is not None:
@@ -732,6 +780,7 @@ def errorbar(
width=width,
role="x-errorbar",
count=n,
+ extra_style=styles._opacity_channels(css),
)
return self
except Exception:
@@ -750,12 +799,23 @@ def step(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a step line without expanding the canonical input columns."""
if where not in {"pre", "post", "mid"}:
raise ValueError("step where must be 'pre', 'post', or 'mid'")
- self.line(x, y, name=name, color=color, width=width, opacity=opacity, dash=dash)
+ css = styles.compile_mark_style("step", style)
+ self.line(
+ x,
+ y,
+ name=name,
+ color=css.get("color", color),
+ width=css.get("width", width),
+ opacity=css.get("opacity", opacity),
+ dash=css.get("dash", dash),
+ )
self.traces[-1].style["step"] = where
+ self.traces[-1].style.update(styles._opacity_channels(css))
return self
@@ -770,6 +830,7 @@ def stairs(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a Matplotlib-style precomputed stairs series.
@@ -805,6 +866,7 @@ def stairs(
width=width,
opacity=opacity,
dash=dash,
+ style=style,
)
@@ -818,6 +880,7 @@ def ecdf(
width: float = 1.5,
opacity: float = 1.0,
dash: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add an empirical cumulative distribution function.
@@ -846,14 +909,30 @@ def ecdf(
sx = np.concatenate(([edges[0]], edges[1:][keep]))
sy = np.concatenate(([0.0], np.cumsum(counts)[keep] / len(vals)))
return self.step(
- sx, sy, where="post", name=name, color=color, width=width, opacity=opacity, dash=dash
+ sx,
+ sy,
+ where="post",
+ name=name,
+ color=color,
+ width=width,
+ opacity=opacity,
+ dash=dash,
+ style=style,
)
unique, counts = np.unique(vals, return_counts=True)
cdf = np.cumsum(counts, dtype=np.float64) / len(vals)
sx = np.concatenate(([unique[0]], unique))
sy = np.concatenate(([0.0], cdf))
return self.step(
- sx, sy, where="post", name=name, color=color, width=width, opacity=opacity, dash=dash
+ sx,
+ sy,
+ where="post",
+ name=name,
+ color=color,
+ width=width,
+ opacity=opacity,
+ dash=dash,
+ style=style,
)
@@ -870,8 +949,13 @@ def stem(
marker: bool = True,
marker_size: float = 5.0,
symbol: str = "circle",
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add vertical stem segments and optional point markers."""
+ css = styles.compile_mark_style("stem", style)
+ color = css.get("color", color)
+ width = css.get("width", width)
+ opacity = css.get("opacity", opacity)
name = self._optional_text(name, "stem name")
color = self._optional_css_color(color, "stem color")
if color is None:
@@ -896,6 +980,7 @@ def stem(
width=width,
role="stem",
count=len(xc),
+ extra_style=styles._opacity_channels(css),
)
if marker:
self.scatter(
@@ -930,6 +1015,7 @@ def scatter(
symbol: str = "circle",
stroke: Optional[str] = None,
stroke_width: float = 0.0,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a scatter trace.
@@ -940,6 +1026,11 @@ def scatter(
`stroke_width` draw a point border. Large scatters auto-switch to a
Tier-2 density surface (§5); pass `density=True/False` to force it.
"""
+ css = styles.compile_mark_style("scatter", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ stroke = css.get("stroke", stroke)
+ stroke_width = css.get("stroke_width", stroke_width)
name = self._optional_text(name, "scatter name")
opacity = self._opacity(opacity, "scatter opacity")
density = self._optional_bool(density, "scatter density")
@@ -959,6 +1050,7 @@ def scatter(
size_ch = channels.resolve_size(size, n, range_px=size_range)
point_style: dict[str, Any] = {"opacity": opacity}
+ point_style.update(styles._opacity_channels(css))
if symbol != "circle":
point_style["symbol"] = symbol
if stroke is not None:
@@ -1031,6 +1123,7 @@ def histogram(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a 1D histogram backed by the shared rectangle primitive.
@@ -1038,9 +1131,17 @@ def histogram(
count mode the last bin equals the number of in-range values; combined
with `density=True` it becomes the empirical CDF (last bin ~1.0).
"""
+ css = styles.compile_mark_style("histogram", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ corner_radius = css.get("corner_radius", corner_radius)
+ stroke = css.get("stroke", stroke)
+ stroke_width = css.get("stroke_width", stroke_width)
+ fill = css.get("fill", fill)
name = self._optional_text(name, "histogram name")
opacity = self._opacity(opacity, "histogram opacity")
mark_style = self._rect_mark_style("histogram", corner_radius, stroke, stroke_width, fill)
+ mark_style.update(styles._opacity_channels(css))
density = self._bool_param(density, "histogram density")
cumulative = self._bool_param(cumulative, "histogram cumulative")
vals = self._as_1d_float(values, "histogram values")
@@ -1103,6 +1204,7 @@ def hist(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Short alias for `histogram(...)`, matching common Python chart APIs."""
return self.histogram(
@@ -1118,6 +1220,7 @@ def hist(
stroke=stroke,
stroke_width=stroke_width,
fill=fill,
+ style=style,
)
@@ -1134,8 +1237,12 @@ def box(
orientation: str = "vertical",
show_outliers: bool = True,
outlier_size: float = 4.0,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add grouped Tukey box plots from 1-D or column-oriented 2-D values."""
+ css = styles.compile_mark_style("box", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
if orientation not in {"vertical", "horizontal"}:
raise ValueError("box orientation must be 'vertical' or 'horizontal'")
name = self._optional_text(name, "box name")
@@ -1221,7 +1328,11 @@ def box(
color=color,
opacity=opacity,
role="box",
- extra_style={"stroke_width": 1.0, "box_orientation": orientation},
+ extra_style={
+ "stroke_width": 1.0,
+ "box_orientation": orientation,
+ **styles._opacity_channels(css),
+ },
)
self._append_segment_trace(
"box_median",
@@ -1277,6 +1388,7 @@ def violin(
bins: int = 64,
opacity: float = 0.55,
orientation: str = "vertical",
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add bounded-resolution violin distributions.
@@ -1285,6 +1397,9 @@ def violin(
through the shared instanced rectangle path, so input cardinality does not
become DOM/GPU object cardinality.
"""
+ css = styles.compile_mark_style("violin", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
if orientation not in {"vertical", "horizontal"}:
raise ValueError("violin orientation must be 'vertical' or 'horizontal'")
if (
@@ -1347,6 +1462,7 @@ def violin(
color=color,
opacity=opacity,
role="violin",
+ extra_style=styles._opacity_channels(css),
)
except Exception:
self._rollback(checkpoint)
@@ -1368,12 +1484,15 @@ def hexbin(
name: Optional[str] = None,
colormap: str = channels.DEFAULT_COLORMAP,
opacity: float = 0.9,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a screen-bounded hexagonal density plot.
Binning is performed by the native 2-D kernel. Only occupied bins are
shipped as centers plus one scalar count/color channel.
"""
+ css = styles.compile_mark_style("hexbin", style)
+ opacity = css.get("opacity", opacity)
if isinstance(gridsize, (int, np.integer)) and not isinstance(gridsize, (bool, np.bool_)):
w = int(gridsize)
h = max(2, int(w / np.sqrt(3.0)))
@@ -1490,6 +1609,7 @@ def hexbin(
"opacity": opacity,
"hex_dx": dx,
"hex_dy": dy,
+ **styles._opacity_channels(css),
},
color_ch=color_ch,
size_ch=channels.SizeChannel(mode="constant", constant=8.0),
@@ -1558,6 +1678,7 @@ def contour(
width: float = 1.1,
opacity: float = 0.9,
dash_negative: bool = False,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add regular-grid contour isolines, optionally over a filled heatmap.
@@ -1565,6 +1686,10 @@ def contour(
contour (Matplotlib's monochrome convention); it is ignored when a colormap
drives per-level color.
"""
+ css = styles.compile_mark_style("contour", style)
+ color = css.get("color", color)
+ width = css.get("width", width)
+ opacity = css.get("opacity", opacity)
arr = self._as_float_array(z, "contour z")
if arr.ndim != 2 or min(arr.shape) < 2:
raise ValueError(
@@ -1669,6 +1794,7 @@ def contour(
role="contour",
color_ch=color_ch,
dash=dash,
+ extra_style=styles._opacity_channels(css),
)
except Exception:
self._rollback(checkpoint)
@@ -1694,6 +1820,7 @@ def bar(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add vertical bars. 2D y values render grouped, stacked, or
normalized (per-category fractions summing to 1) series.
@@ -1701,6 +1828,13 @@ def bar(
`corner_radius`/`stroke`/`stroke_width` are the CSS border analogues
rendered into the mark; `fill` accepts a CSS `linear-gradient(...)`
(docs/styling.md#styling-the-marks)."""
+ css = styles.compile_mark_style("bar", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ corner_radius = css.get("corner_radius", corner_radius)
+ stroke = css.get("stroke", stroke)
+ stroke_width = css.get("stroke_width", stroke_width)
+ fill = css.get("fill", fill)
return _bar_like(
self,
"bar",
@@ -1719,6 +1853,7 @@ def bar(
stroke=stroke,
stroke_width=stroke_width,
fill=fill,
+ style_extra=styles._opacity_channels(css),
)
@@ -1740,8 +1875,16 @@ def column(
stroke: Optional[str] = None,
stroke_width: float = 0.0,
fill: Any = None,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Alias for vertical column charts; shares the bar/rect renderer."""
+ css = styles.compile_mark_style("column", style)
+ color = css.get("color", color)
+ opacity = css.get("opacity", opacity)
+ corner_radius = css.get("corner_radius", corner_radius)
+ stroke = css.get("stroke", stroke)
+ stroke_width = css.get("stroke_width", stroke_width)
+ fill = css.get("fill", fill)
return _bar_like(
self,
"column",
@@ -1760,6 +1903,7 @@ def column(
stroke=stroke,
stroke_width=stroke_width,
fill=fill,
+ style_extra=styles._opacity_channels(css),
)
@@ -1773,12 +1917,15 @@ def heatmap(
colormap: str = channels.DEFAULT_COLORMAP,
domain: Optional[tuple[float, float]] = None,
opacity: float = 0.95,
+ style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add a rectangular heatmap from a 2D value matrix.
`z` is shaped `(rows, columns)`. Optional `x` and `y` arrays name the
column/row centers; string/object arrays become categorical axes.
"""
+ css = styles.compile_mark_style("heatmap", style)
+ opacity = css.get("opacity", opacity)
name = self._optional_text(name, "heatmap name")
opacity = self._opacity(opacity, "heatmap opacity")
if hasattr(z, "to_numpy"):
@@ -1850,6 +1997,7 @@ def heatmap(
"truecolor": truecolor,
"x_range": [float(x_edges[0]), float(x_edges[-1])],
"y_range": [float(y_edges[0]), float(y_edges[-1])],
+ **styles._opacity_channels(css),
},
)
)
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 76967921..c5380752 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -1087,7 +1087,8 @@ def scatter(
# matplotlib s= is absolute (points²); pin the engine's size range
# to the converted pixel values so normalization is the identity
# instead of compressing everything into the default 2-18 px band.
- finite_sizes = size_px[np.isfinite(size_px)]
+ size_values = np.asarray(size_px, dtype=np.float64)
+ finite_sizes = size_values[np.isfinite(size_values)]
if finite_sizes.size:
lo_px, hi_px = float(finite_sizes.min()), float(finite_sizes.max())
if hi_px > lo_px:
diff --git a/python/xy/static/index.js b/python/xy/static/index.js
index 987386e0..a51bfcff 100644
--- a/python/xy/static/index.js
+++ b/python/xy/static/index.js
@@ -235,6 +235,9 @@ const FC_CHROME_CSS = `
:where(.xy [data-fc-slot="tooltip"]){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-fc-slot="legend"]){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-fc-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
+:where(.xy [data-fc-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
+:where(.xy [data-fc-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
+:where(.xy [data-fc-slot="colorbar_title"]){font-weight:500}
:where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
:where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)}
:where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
@@ -749,7 +752,7 @@ const DENSITY_FS = `#version 300 es
precision highp float;
uniform sampler2D u_grid; uniform sampler2D u_lut;
uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1
-uniform float u_opacity;
+uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor;
in vec2 v_data;
out vec4 outColor;
void main() {
@@ -758,8 +761,11 @@ void main() {
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard;
float t = texture(u_grid, uv).r;
if (t <= 0.0) discard;
- vec3 rgb = texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
- float alpha = u_opacity * clamp(t * 1.35, 0.0, 1.0);
+ vec4 paint = u_constantColor == 1
+ ? u_color
+ : texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5));
+ vec3 rgb = paint.rgb;
+ float alpha = u_opacity * paint.a * clamp(t * 1.35, 0.0, 1.0);
if (alpha <= 0.01) discard;
outColor = vec4(rgb * alpha, alpha);
}`;
@@ -1488,6 +1494,7 @@ g.prevDensity = g.density;
g._densityFadeStart = view._now();
g.density = {
w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap,
+color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color,
xRange: d.x_range, yRange: d.y_range,
grid,
tex: view._uploadGrid(grid, d.w, d.h, normMax),
@@ -2465,8 +2472,10 @@ gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${stops.map((c) =
`rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`;
}
bar.style.cssText = horizontal
-? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`
-: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`;
+? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;`
+: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`;
+bar.style.setProperty("--xy-colorbar-gradient", gradient);
+this._applySlot(bar, "colorbar_bar");
box.appendChild(bar);
const domain = cb.domain || [0, 1];
const lo = Number(domain[0]), hi = Number(domain[1]);
@@ -2483,6 +2492,7 @@ const fraction = (value - lo) / span;
tick.style.cssText = horizontal
? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`;
+this._applySlot(tick, "colorbar_tick");
box.appendChild(tick);
}
if (cb.label) {
@@ -2491,6 +2501,7 @@ label.textContent = String(cb.label);
label.style.cssText = horizontal
? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`;
+this._applySlot(label, "colorbar_title");
box.appendChild(label);
}
box.title = `${cb.label ? cb.label + ": " : ""}${domain[0]} – ${domain[1]}`;
@@ -2617,6 +2628,7 @@ const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw;
g.densityNormMax = d.max;
g.density = {
w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap,
+color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null,
xRange: d.x_range, yRange: d.y_range,
grid: lodCopyGrid(grid),
tex: this._uploadGrid(grid, d.w, d.h, d.max),
@@ -2826,6 +2838,12 @@ gl.uniform1i(u("u_gradCount"), grad.count);
gl.uniform1fv(u("u_gradPos"), grad.pos);
gl.uniform4fv(u("u_gradColor"), grad.colors);
}
+_fillOpacity(style, fallback = 1) {
+return Number(style.opacity ?? fallback) * Number(style.fill_opacity ?? 1);
+}
+_strokeOpacity(style, fallback = 1) {
+return Number(style.opacity ?? fallback) * Number(style.stroke_opacity ?? 1);
+}
_setRectStyleUniforms(prog, g) {
const gl = this.gl;
const u = (n) => uniformOf(gl, prog, n);
@@ -2834,7 +2852,8 @@ const cr = g.cornerRadius || [0, 0];
gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr);
gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr);
const sc = g.strokeColor || [0, 0, 0, 0];
-gl.uniform4f(u("u_stroke"), sc[0] * sc[3], sc[1] * sc[3], sc[2] * sc[3], sc[3]);
+const sa = sc[3] * this._strokeOpacity(g.trace.style || {});
+gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa);
this._setGradientUniforms(prog, g.grad);
}
_rectMarkStyleGpu(g, t) {
@@ -3280,7 +3299,7 @@ gl.uniform1f(u("u_size"), g.size);
gl.uniform1i(u("u_sizeMode"), g.sizeMode);
gl.uniform2f(u("u_sizeRange"), g.sizeRange[0], g.sizeRange[1]);
gl.uniform1i(u("u_colorMode"), g.colorMode);
-const markOpacity = (g.trace.style.opacity ?? 0.8) * opacityScale;
+const markOpacity = this._fillOpacity(g.trace.style, 0.8) * opacityScale;
gl.uniform1f(u("u_opacity"), markOpacity);
gl.uniform1f(u("u_selectedOpacity"), this._markStateNumber("selected", "opacity", 1));
gl.uniform1f(u("u_unselectedOpacity"), this._markStateNumber("unselected", "opacity", 0.12));
@@ -3294,7 +3313,9 @@ const [r, gg, b] = g.color;
gl.uniform4f(u("u_color"), r, gg, b, 1);
gl.uniform1i(u("u_symbol"), g.symbol || 0);
const sc = g.pointStroke;
-const strokeAlpha = sc ? sc[3] * markOpacity : 0;
+const strokeAlpha = sc
+? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale
+: 0;
gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr);
gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0);
gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0,
@@ -3365,7 +3386,7 @@ this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis);
gl.uniform1f(u("u_dpr"), this.dpr);
gl.uniform1f(u("u_size"), g.size);
const [r, gg, b] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, (g.trace.style.opacity ?? 0.8) * opacityScale);
+gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale);
this._bindVao(
g,
"points-simple",
@@ -3443,7 +3464,10 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
const d = density || g.density;
gl.uniform4f(u("u_gridRange"), d.xRange[0], d.xRange[1], d.yRange[0], d.yRange[1]);
-gl.uniform1f(u("u_opacity"), (g.trace.style.opacity ?? 1.0) * opacityScale);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale);
+const constant = d.color;
+gl.uniform1i(u("u_constantColor"), constant ? 1 : 0);
+gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1]));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, d.tex);
gl.uniform1i(u("u_grid"), 0);
@@ -3467,7 +3491,7 @@ gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1);
gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]);
-gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1.0);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, h.tex);
@@ -3492,7 +3516,8 @@ this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis);
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = color || g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (opacity ?? g.trace.style.opacity ?? 1));
+const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1);
+gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity);
const dashed = this._lineDash(g);
this._bindVao(
g,
@@ -3526,7 +3551,7 @@ this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis);
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
const dashed = this._segmentDash(g, prog);
if (g.colorMode && g.lut) {
@@ -3639,10 +3664,12 @@ gl.uniform2f(u("u_ymap"), ym[0], ym[1]);
for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis);
for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis);
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
-gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1);
const stroke = g.meshStroke || [0, 0, 0, 0];
-gl.uniform4f(u("u_stroke"), stroke[0] * stroke[3], stroke[1] * stroke[3], stroke[2] * stroke[3], stroke[3]);
+const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style);
+gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha,
+stroke[2] * strokeAlpha, strokeAlpha);
gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0);
if (g.colorMode && g.lut) {
gl.activeTexture(gl.TEXTURE0);
@@ -3716,7 +3743,7 @@ this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis);
this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis);
this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 0.35));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.35));
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
this._setGradientUniforms(prog, g.grad);
this._bindVao(g, "area", [g.xBuf._fcId, g.yBuf._fcId, g.baseBuf._fcId], () => {
@@ -3747,7 +3774,7 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const colorOn = g.colorMode && g.cBuf;
@@ -3793,7 +3820,7 @@ gl.uniform1i(u("u_v0Mode"), g.value0Mode);
gl.uniform1f(u("u_v0Const"), v0Const ?? 0);
gl.uniform1f(u("u_v0EdgePad"), v0EdgePad);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const v0On = g.value0Mode === 1 && g.value0Buf;
@@ -3844,6 +3871,13 @@ _axisStyleValue(axis, key) {
const style = axis && typeof axis.style === "object" ? axis.style : null;
return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined;
}
+_axisGridDash(axis) {
+const value = String(this._axisStyleValue(axis, "grid_dash") || "solid");
+if (value === "dashed") return [6, 4];
+if (value === "dotted") return [1, 3];
+if (value === "dashdot") return [6, 3, 1, 3];
+return [];
+}
_axisTickLabelStrategy(axis) {
const raw = axis && axis.tick_label_strategy !== undefined
? axis.tick_label_strategy
@@ -3906,7 +3940,10 @@ return labels.slice(0, 1);
}
_layoutTickLabels(axis, dim, labels) {
if (labels.length <= 1) return labels.map((label) => ({ ...label, angle: 0, row: 0 }));
-const fontSize = Math.max(8, this._axisStyleNumber(axis, "tick_size", 11));
+const fontSize = Math.max(
+8,
+this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)),
+);
const minGap = this._axisTickLabelMinGap(axis, dim);
const explicitAngle = this._axisTickLabelAngle(axis);
const baseAngle = explicitAngle === null ? 0 : explicitAngle;
@@ -4007,6 +4044,8 @@ const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(p
const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5));
ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1));
+ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1);
+ctx.setLineDash(this._axisGridDash(xAxis));
ctx.beginPath();
for (const v of (hideX ? [] : xt.ticks)) {
const px = this._dataPx("x", v);
@@ -4018,6 +4057,8 @@ ctx.lineTo(x, p.y + p.h);
ctx.stroke();
ctx.strokeStyle = this._axisStylePaint(yAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(yAxis, "grid_width", 1));
+ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1);
+ctx.setLineDash(this._axisGridDash(yAxis));
ctx.beginPath();
for (const v of (hideY ? [] : yt.ticks)) {
const py = this._dataPx("y", v);
@@ -4027,13 +4068,15 @@ ctx.moveTo(p.x, y);
ctx.lineTo(p.x + p.w, y);
}
ctx.stroke();
+ctx.globalAlpha = 1;
+ctx.setLineDash([]);
this._drawAnnotationShapes(ctx);
if (updateLabels) {
-const rule = (styleAxis, left, top, w, h) => {
+const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => {
const d = document.createElement("div");
d.style.cssText =
`position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;` +
-`background:${this._axisStylePaint(styleAxis, "axis_color", this.theme.axis)};` +
+`background:${this._axisStylePaint(styleAxis, colorKey, this.theme.axis)};` +
"pointer-events:none;";
this.labels.appendChild(d);
};
@@ -4072,7 +4115,7 @@ for (const value of xt.ticks) {
const x = this._dataPx("x", value);
if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue;
const top = side === "top" ? edge - tick.outward : edge - tick.inward;
-rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward);
+rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color");
}
}
if (!hideY) {
@@ -4083,7 +4126,7 @@ for (const value of yt.ticks) {
const y = this._dataPx("y", value);
if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue;
const left = side === "right" ? edge - tick.inward : edge - tick.outward;
-rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width);
+rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color");
}
}
}
@@ -4094,8 +4137,14 @@ d.textContent = text;
d.dataset.fcLabelKind = kind;
d.dataset.fcAxis = axis && axis.id !== undefined ? String(axis.id) : "";
d.dataset.fcAxisSide = axis && axis.side ? String(axis.side) : "";
-const colorKey = kind === "label" ? "label_color" : "tick_color";
-const sizeKey = kind === "label" ? "label_size" : "tick_size";
+const colorKey = kind === "label"
+? "label_color"
+: (this._axisStyleValue(axis, "tick_label_color") !== undefined
+? "tick_label_color" : "tick_color");
+const sizeKey = kind === "label"
+? "label_size"
+: (this._axisStyleValue(axis, "tick_label_size") !== undefined
+? "tick_label_size" : "tick_size");
let color = "";
if (this._axisStyleValue(axis, colorKey) !== undefined) {
color = `color:${this._axisStylePaint(axis, colorKey, this.theme.label)};`;
@@ -4117,7 +4166,12 @@ const text = this._axisTickText(xAxis, v, xt.step);
xLabelCandidates.push({ pos: px, text });
}
for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) {
-const rowOffset = Number(item.row || 0) * (Math.max(8, this._axisStyleNumber(xAxis, "tick_size", 11)) + 4);
+const tickLabelSize = this._axisStyleNumber(
+xAxis,
+"tick_label_size",
+this._axisStyleNumber(xAxis, "tick_size", 11),
+);
+const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4);
const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset;
const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`;
const origin = xAxis.side === "top" ? "bottom center" : "top center";
diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js
index ad830787..46b2f1ff 100644
--- a/python/xy/static/standalone.js
+++ b/python/xy/static/standalone.js
@@ -236,6 +236,9 @@ const FC_CHROME_CSS = `
:where(.xy [data-fc-slot="tooltip"]){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-fc-slot="legend"]){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-fc-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px}
+:where(.xy [data-fc-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px}
+:where(.xy [data-fc-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box}
+:where(.xy [data-fc-slot="colorbar_title"]){font-weight:500}
:where(.xy [data-fc-slot="badge"]){gap:3px;font-size:11px;line-height:1.2}
:where(.xy [data-fc-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,#0f172a);background:var(--chart-badge-bg,rgba(255,255,255,.82));box-shadow:0 1px 4px rgba(15,23,42,.14)}
:where(.xy [data-fc-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,rgba(255,255,255,.78));border:1px solid rgba(128,128,128,.18);border-radius:4px;padding:1px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
@@ -750,7 +753,7 @@ const DENSITY_FS = `#version 300 es
precision highp float;
uniform sampler2D u_grid; uniform sampler2D u_lut;
uniform vec4 u_gridRange; // gx0,gx1,gy0,gy1
-uniform float u_opacity;
+uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor;
in vec2 v_data;
out vec4 outColor;
void main() {
@@ -759,8 +762,11 @@ void main() {
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard;
float t = texture(u_grid, uv).r;
if (t <= 0.0) discard;
- vec3 rgb = texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
- float alpha = u_opacity * clamp(t * 1.35, 0.0, 1.0);
+ vec4 paint = u_constantColor == 1
+ ? u_color
+ : texture(u_lut, vec2(clamp(t, 0.0, 1.0), 0.5));
+ vec3 rgb = paint.rgb;
+ float alpha = u_opacity * paint.a * clamp(t * 1.35, 0.0, 1.0);
if (alpha <= 0.01) discard;
outColor = vec4(rgb * alpha, alpha);
}`;
@@ -1489,6 +1495,7 @@ g.prevDensity = g.density;
g._densityFadeStart = view._now();
g.density = {
w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap,
+color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color,
xRange: d.x_range, yRange: d.y_range,
grid,
tex: view._uploadGrid(grid, d.w, d.h, normMax),
@@ -2466,8 +2473,10 @@ gradient = `linear-gradient(to ${horizontal ? "right" : "top"},${stops.map((c) =
`rgb(${c[0]},${c[1]},${c[2]})`).join(",")})`;
}
bar.style.cssText = horizontal
-? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`
-: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;background:${gradient};border:1px solid currentColor;box-sizing:border-box;`;
+? `position:absolute;inset:0 0 auto 0;height:${COLORBAR_THICKNESS}px;`
+: `position:absolute;inset:0 auto 0 0;width:${COLORBAR_THICKNESS}px;`;
+bar.style.setProperty("--xy-colorbar-gradient", gradient);
+this._applySlot(bar, "colorbar_bar");
box.appendChild(bar);
const domain = cb.domain || [0, 1];
const lo = Number(domain[0]), hi = Number(domain[1]);
@@ -2484,6 +2493,7 @@ const fraction = (value - lo) / span;
tick.style.cssText = horizontal
? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS + 2}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 5}px;top:${100 * (1 - fraction)}%;transform:translateY(-50%);white-space:nowrap;`;
+this._applySlot(tick, "colorbar_tick");
box.appendChild(tick);
}
if (cb.label) {
@@ -2492,6 +2502,7 @@ label.textContent = String(cb.label);
label.style.cssText = horizontal
? `position:absolute;left:50%;top:${COLORBAR_THICKNESS + 18}px;transform:translateX(-50%);white-space:nowrap;`
: `position:absolute;left:${COLORBAR_THICKNESS + 40}px;top:50%;writing-mode:vertical-rl;transform:translateY(-50%) rotate(180deg);white-space:nowrap;`;
+this._applySlot(label, "colorbar_title");
box.appendChild(label);
}
box.title = `${cb.label ? cb.label + ": " : ""}${domain[0]} – ${domain[1]}`;
@@ -2618,6 +2629,7 @@ const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw;
g.densityNormMax = d.max;
g.density = {
w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap,
+color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null,
xRange: d.x_range, yRange: d.y_range,
grid: lodCopyGrid(grid),
tex: this._uploadGrid(grid, d.w, d.h, d.max),
@@ -2827,6 +2839,12 @@ gl.uniform1i(u("u_gradCount"), grad.count);
gl.uniform1fv(u("u_gradPos"), grad.pos);
gl.uniform4fv(u("u_gradColor"), grad.colors);
}
+_fillOpacity(style, fallback = 1) {
+return Number(style.opacity ?? fallback) * Number(style.fill_opacity ?? 1);
+}
+_strokeOpacity(style, fallback = 1) {
+return Number(style.opacity ?? fallback) * Number(style.stroke_opacity ?? 1);
+}
_setRectStyleUniforms(prog, g) {
const gl = this.gl;
const u = (n) => uniformOf(gl, prog, n);
@@ -2835,7 +2853,8 @@ const cr = g.cornerRadius || [0, 0];
gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr);
gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr);
const sc = g.strokeColor || [0, 0, 0, 0];
-gl.uniform4f(u("u_stroke"), sc[0] * sc[3], sc[1] * sc[3], sc[2] * sc[3], sc[3]);
+const sa = sc[3] * this._strokeOpacity(g.trace.style || {});
+gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa);
this._setGradientUniforms(prog, g.grad);
}
_rectMarkStyleGpu(g, t) {
@@ -3281,7 +3300,7 @@ gl.uniform1f(u("u_size"), g.size);
gl.uniform1i(u("u_sizeMode"), g.sizeMode);
gl.uniform2f(u("u_sizeRange"), g.sizeRange[0], g.sizeRange[1]);
gl.uniform1i(u("u_colorMode"), g.colorMode);
-const markOpacity = (g.trace.style.opacity ?? 0.8) * opacityScale;
+const markOpacity = this._fillOpacity(g.trace.style, 0.8) * opacityScale;
gl.uniform1f(u("u_opacity"), markOpacity);
gl.uniform1f(u("u_selectedOpacity"), this._markStateNumber("selected", "opacity", 1));
gl.uniform1f(u("u_unselectedOpacity"), this._markStateNumber("unselected", "opacity", 0.12));
@@ -3295,7 +3314,9 @@ const [r, gg, b] = g.color;
gl.uniform4f(u("u_color"), r, gg, b, 1);
gl.uniform1i(u("u_symbol"), g.symbol || 0);
const sc = g.pointStroke;
-const strokeAlpha = sc ? sc[3] * markOpacity : 0;
+const strokeAlpha = sc
+? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale
+: 0;
gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr);
gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0);
gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0,
@@ -3366,7 +3387,7 @@ this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis);
gl.uniform1f(u("u_dpr"), this.dpr);
gl.uniform1f(u("u_size"), g.size);
const [r, gg, b] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, (g.trace.style.opacity ?? 0.8) * opacityScale);
+gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale);
this._bindVao(
g,
"points-simple",
@@ -3444,7 +3465,10 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
const d = density || g.density;
gl.uniform4f(u("u_gridRange"), d.xRange[0], d.xRange[1], d.yRange[0], d.yRange[1]);
-gl.uniform1f(u("u_opacity"), (g.trace.style.opacity ?? 1.0) * opacityScale);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale);
+const constant = d.color;
+gl.uniform1i(u("u_constantColor"), constant ? 1 : 0);
+gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1]));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, d.tex);
gl.uniform1i(u("u_grid"), 0);
@@ -3468,7 +3492,7 @@ gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1);
gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]);
-gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1.0);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, h.tex);
@@ -3493,7 +3517,8 @@ this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis);
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = color || g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (opacity ?? g.trace.style.opacity ?? 1));
+const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1);
+gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity);
const dashed = this._lineDash(g);
this._bindVao(
g,
@@ -3527,7 +3552,7 @@ this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis);
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
const dashed = this._segmentDash(g, prog);
if (g.colorMode && g.lut) {
@@ -3640,10 +3665,12 @@ gl.uniform2f(u("u_ymap"), ym[0], ym[1]);
for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis);
for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis);
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
-gl.uniform1f(u("u_opacity"), g.trace.style.opacity ?? 1);
+gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style));
gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1);
const stroke = g.meshStroke || [0, 0, 0, 0];
-gl.uniform4f(u("u_stroke"), stroke[0] * stroke[3], stroke[1] * stroke[3], stroke[2] * stroke[3], stroke[3]);
+const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style);
+gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha,
+stroke[2] * strokeAlpha, strokeAlpha);
gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0);
if (g.colorMode && g.lut) {
gl.activeTexture(gl.TEXTURE0);
@@ -3717,7 +3744,7 @@ this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis);
this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis);
this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 0.35));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.35));
gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height);
this._setGradientUniforms(prog, g.grad);
this._bindVao(g, "area", [g.xBuf._fcId, g.yBuf._fcId, g.baseBuf._fcId], () => {
@@ -3748,7 +3775,7 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis));
gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis));
gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const colorOn = g.colorMode && g.cBuf;
@@ -3794,7 +3821,7 @@ gl.uniform1i(u("u_v0Mode"), g.value0Mode);
gl.uniform1f(u("u_v0Const"), v0Const ?? 0);
gl.uniform1f(u("u_v0EdgePad"), v0EdgePad);
const [r, gg, b, a] = g.color;
-gl.uniform4f(u("u_color"), r, gg, b, a * (g.trace.style.opacity ?? 1));
+gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style));
gl.uniform1i(u("u_colorMode"), g.colorMode || 0);
this._setRectStyleUniforms(prog, g);
const v0On = g.value0Mode === 1 && g.value0Buf;
@@ -3845,6 +3872,13 @@ _axisStyleValue(axis, key) {
const style = axis && typeof axis.style === "object" ? axis.style : null;
return style && Object.prototype.hasOwnProperty.call(style, key) ? style[key] : undefined;
}
+_axisGridDash(axis) {
+const value = String(this._axisStyleValue(axis, "grid_dash") || "solid");
+if (value === "dashed") return [6, 4];
+if (value === "dotted") return [1, 3];
+if (value === "dashdot") return [6, 3, 1, 3];
+return [];
+}
_axisTickLabelStrategy(axis) {
const raw = axis && axis.tick_label_strategy !== undefined
? axis.tick_label_strategy
@@ -3907,7 +3941,10 @@ return labels.slice(0, 1);
}
_layoutTickLabels(axis, dim, labels) {
if (labels.length <= 1) return labels.map((label) => ({ ...label, angle: 0, row: 0 }));
-const fontSize = Math.max(8, this._axisStyleNumber(axis, "tick_size", 11));
+const fontSize = Math.max(
+8,
+this._axisStyleNumber(axis, "tick_label_size", this._axisStyleNumber(axis, "tick_size", 11)),
+);
const minGap = this._axisTickLabelMinGap(axis, dim);
const explicitAngle = this._axisTickLabelAngle(axis);
const baseAngle = explicitAngle === null ? 0 : explicitAngle;
@@ -4008,6 +4045,8 @@ const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(p
const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5));
ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1));
+ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1);
+ctx.setLineDash(this._axisGridDash(xAxis));
ctx.beginPath();
for (const v of (hideX ? [] : xt.ticks)) {
const px = this._dataPx("x", v);
@@ -4019,6 +4058,8 @@ ctx.lineTo(x, p.y + p.h);
ctx.stroke();
ctx.strokeStyle = this._axisStylePaint(yAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(yAxis, "grid_width", 1));
+ctx.globalAlpha = this._axisStyleNumber(yAxis, "grid_opacity", 1);
+ctx.setLineDash(this._axisGridDash(yAxis));
ctx.beginPath();
for (const v of (hideY ? [] : yt.ticks)) {
const py = this._dataPx("y", v);
@@ -4028,13 +4069,15 @@ ctx.moveTo(p.x, y);
ctx.lineTo(p.x + p.w, y);
}
ctx.stroke();
+ctx.globalAlpha = 1;
+ctx.setLineDash([]);
this._drawAnnotationShapes(ctx);
if (updateLabels) {
-const rule = (styleAxis, left, top, w, h) => {
+const rule = (styleAxis, left, top, w, h, colorKey = "axis_color") => {
const d = document.createElement("div");
d.style.cssText =
`position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;` +
-`background:${this._axisStylePaint(styleAxis, "axis_color", this.theme.axis)};` +
+`background:${this._axisStylePaint(styleAxis, colorKey, this.theme.axis)};` +
"pointer-events:none;";
this.labels.appendChild(d);
};
@@ -4073,7 +4116,7 @@ for (const value of xt.ticks) {
const x = this._dataPx("x", value);
if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue;
const top = side === "top" ? edge - tick.outward : edge - tick.inward;
-rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward);
+rule(xAxis, x - tick.width / 2, top, tick.width, tick.inward + tick.outward, "tick_color");
}
}
if (!hideY) {
@@ -4084,7 +4127,7 @@ for (const value of yt.ticks) {
const y = this._dataPx("y", value);
if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue;
const left = side === "right" ? edge - tick.inward : edge - tick.outward;
-rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width);
+rule(yAxis, left, y - tick.width / 2, tick.inward + tick.outward, tick.width, "tick_color");
}
}
}
@@ -4095,8 +4138,14 @@ d.textContent = text;
d.dataset.fcLabelKind = kind;
d.dataset.fcAxis = axis && axis.id !== undefined ? String(axis.id) : "";
d.dataset.fcAxisSide = axis && axis.side ? String(axis.side) : "";
-const colorKey = kind === "label" ? "label_color" : "tick_color";
-const sizeKey = kind === "label" ? "label_size" : "tick_size";
+const colorKey = kind === "label"
+? "label_color"
+: (this._axisStyleValue(axis, "tick_label_color") !== undefined
+? "tick_label_color" : "tick_color");
+const sizeKey = kind === "label"
+? "label_size"
+: (this._axisStyleValue(axis, "tick_label_size") !== undefined
+? "tick_label_size" : "tick_size");
let color = "";
if (this._axisStyleValue(axis, colorKey) !== undefined) {
color = `color:${this._axisStylePaint(axis, colorKey, this.theme.label)};`;
@@ -4118,7 +4167,12 @@ const text = this._axisTickText(xAxis, v, xt.step);
xLabelCandidates.push({ pos: px, text });
}
for (const item of this._layoutTickLabels(xAxis, "x", xLabelCandidates)) {
-const rowOffset = Number(item.row || 0) * (Math.max(8, this._axisStyleNumber(xAxis, "tick_size", 11)) + 4);
+const tickLabelSize = this._axisStyleNumber(
+xAxis,
+"tick_label_size",
+this._axisStyleNumber(xAxis, "tick_size", 11),
+);
+const rowOffset = Number(item.row || 0) * (Math.max(8, tickLabelSize) + 4);
const top = xAxis.side === "top" ? p.y - 18 - rowOffset : p.y + p.h + 6 + rowOffset;
const transform = `translateX(-50%) rotate(${Number(item.angle || 0)}deg)`;
const origin = xAxis.side === "top" ? "bottom center" : "top center";
diff --git a/python/xy/styles.py b/python/xy/styles.py
new file mode 100644
index 00000000..797a678e
--- /dev/null
+++ b/python/xy/styles.py
@@ -0,0 +1,330 @@
+"""CSS-first styling for rendered XY marks.
+
+DOM chrome already accepts arbitrary safe CSS declarations. Marks are drawn
+by WebGL, SVG, and the native rasterizer, so they cannot honestly accept the
+entire browser cascade. This module defines the smaller, internal CSS subset
+that all renderers can compile to XY's existing trace style vocabulary.
+
+The input contract is ordinary CSS: kebab-case property names and CSS values.
+Python snake_case aliases remain accepted for ergonomics and backwards
+compatibility, but normalized output uses CSS names.
+Unsupported declarations fail before data ingestion rather than being silently
+ignored by one renderer.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from typing import Any, TypeAlias
+
+import numpy as np
+
+from . import _validate
+
+StyleValue: TypeAlias = str | int | float
+StyleMapping: TypeAlias = Mapping[str, StyleValue]
+
+_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
+_PX_RE = re.compile(rf"^\s*({_NUMBER})(?:px)?\s*$", re.IGNORECASE)
+
+_LINE_KINDS = frozenset({"line", "step", "stairs", "ecdf"})
+_SIMPLE_STROKE_KINDS = frozenset({"segments", "errorbar", "contour", "stem"})
+_AREA_KINDS = frozenset({"area", "error_band"})
+_POINT_KINDS = frozenset({"scatter"})
+_RECT_KINDS = frozenset({"histogram", "hist", "bar", "column"})
+_FILL_KINDS = frozenset({"box", "violin"})
+_MESH_KINDS = frozenset({"triangle_mesh"})
+_DENSITY_KINDS = frozenset({"heatmap", "hexbin"})
+
+_AXIS_COLOR_PROPERTIES = frozenset(
+ {"grid_color", "axis_color", "tick_color", "tick_label_color", "label_color"}
+)
+_AXIS_LENGTH_PROPERTIES = frozenset({"grid_width", "axis_width", "tick_length", "tick_width"})
+_AXIS_SIZE_PROPERTIES = frozenset({"tick_size", "tick_label_size", "label_size"})
+_AXIS_COMPAT_PROPERTIES = frozenset({"grid_dash", "grid_opacity"})
+_AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"})
+_AXIS_DIRECTIONS = frozenset({"in", "out", "inout"})
+
+_MARK_KINDS = tuple(
+ sorted(
+ _LINE_KINDS
+ | _SIMPLE_STROKE_KINDS
+ | _AREA_KINDS
+ | _POINT_KINDS
+ | _RECT_KINDS
+ | _FILL_KINDS
+ | _MESH_KINDS
+ | _DENSITY_KINDS
+ )
+)
+
+
+def normalize_css_style(value: StyleMapping | None, label: str = "style") -> dict[str, StyleValue]:
+ """Validate and canonicalize a CSS declaration mapping.
+
+ The shared declaration grammar provides injection safety and strict checks
+ for known colors, lengths, and numeric properties. Canonicalization makes
+ serialization and schema-driven generation deterministic.
+ """
+ if value is None:
+ return {}
+ raw = dict(value)
+ gradient_fills: dict[str, StyleValue] = {}
+ ordinary: dict[str, StyleValue] = {}
+ for key, item in raw.items():
+ canonical = _validate._css_property_name(key).lower() if isinstance(key, str) else key
+ if (
+ canonical == "fill"
+ and isinstance(item, str)
+ and item.strip().lower().startswith("linear-gradient(")
+ ):
+ _validate.mark_fill(item, f"{label}[{key!r}]")
+ gradient_fills[key] = item
+ else:
+ ordinary[key] = item
+ validated = _validate.style_mapping(ordinary, label)
+ validated.update(gradient_fills)
+ out: dict[str, StyleValue] = {}
+ for key, item in validated.items():
+ canonical = _validate._css_property_name(key).lower()
+ if canonical in out and out[canonical] != item:
+ raise ValueError(
+ f"{label} defines {canonical!r} more than once through CSS/Python aliases"
+ )
+ out[canonical] = item
+ return out
+
+
+def _px(value: StyleValue, label: str, *, positive: bool = False) -> float:
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ number = float(value)
+ elif isinstance(value, str):
+ match = _PX_RE.match(value)
+ if match is None:
+ raise ValueError(f"{label} must be a finite CSS px length")
+ number = float(match.group(1))
+ else: # pragma: no cover - normalize_css_style rejects this first
+ raise ValueError(f"{label} must be a finite CSS px length")
+ if not np.isfinite(number) or number < 0 or (positive and number <= 0):
+ qualifier = "positive " if positive else "non-negative "
+ raise ValueError(f"{label} must be a {qualifier}finite CSS px length")
+ return number
+
+
+def _opacity(value: StyleValue, label: str) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{label} must be a number between 0 and 1") from exc
+ return _validate.opacity(number, label)
+
+
+def _dasharray(value: StyleValue, label: str) -> list[float] | None:
+ if isinstance(value, str):
+ if value.strip().lower() == "none":
+ return None
+ tokens = [token for token in re.split(r"[\s,]+", value.strip()) if token]
+ elif isinstance(value, (int, float)):
+ tokens = [value]
+ else: # pragma: no cover - normalize_css_style rejects this first
+ tokens = []
+ lengths = [_px(token, f"{label}[{i}]", positive=True) for i, token in enumerate(tokens)]
+ if not 2 <= len(lengths) <= 8:
+ raise ValueError(f"{label} must contain between 2 and 8 positive CSS px lengths")
+ return lengths
+
+
+def _paint(value: StyleValue, label: str) -> str:
+ return _validate.css_color(value, label)
+
+
+def _fill(value: StyleValue, label: str) -> tuple[str, Any]:
+ if isinstance(value, str) and value.strip().lower().startswith("linear-gradient("):
+ _validate.mark_fill(value, label)
+ return "fill", value.strip()
+ return "color", _paint(value, label)
+
+
+def _set(result: dict[str, Any], key: str, value: Any, source: str, seen: dict[str, str]) -> None:
+ previous = seen.get(key)
+ if previous is not None and result[key] != value:
+ raise ValueError(f"style properties {previous!r} and {source!r} set conflicting {key!r}")
+ result[key] = value
+ seen[key] = source
+
+
+def _supported_mark_style_properties(kind: str) -> tuple[str, ...]:
+ """Return canonical CSS properties supported by a rendered mark kind."""
+ if kind not in _MARK_KINDS:
+ raise ValueError(f"unknown mark kind {kind!r}; expected one of {_MARK_KINDS}")
+ props = {"opacity"}
+ if kind in _LINE_KINDS:
+ props |= {"stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"}
+ elif kind in _SIMPLE_STROKE_KINDS:
+ props |= {"stroke", "stroke-width", "stroke-opacity"}
+ elif kind in _AREA_KINDS:
+ props |= {
+ "fill",
+ "fill-opacity",
+ "stroke",
+ "stroke-width",
+ "stroke-opacity",
+ "stroke-dasharray",
+ }
+ if kind == "error_band":
+ props.discard("stroke-dasharray")
+ elif kind in _POINT_KINDS:
+ props |= {
+ "fill",
+ "fill-opacity",
+ "stroke",
+ "stroke-width",
+ "stroke-opacity",
+ }
+ elif kind in _RECT_KINDS:
+ props |= {
+ "fill",
+ "fill-opacity",
+ "stroke",
+ "stroke-width",
+ "stroke-opacity",
+ "border-radius",
+ }
+ elif kind in _FILL_KINDS:
+ props |= {"fill", "fill-opacity"}
+ elif kind in _MESH_KINDS:
+ props |= {
+ "fill",
+ "fill-opacity",
+ "stroke",
+ "stroke-width",
+ "stroke-opacity",
+ }
+ elif kind in _DENSITY_KINDS:
+ props |= {"fill-opacity"}
+ return tuple(sorted(props))
+
+
+def compile_mark_style(
+ kind: str, value: StyleMapping | None, label: str | None = None
+) -> dict[str, Any]:
+ """Compile standard CSS declarations into a mark builder's keyword args."""
+ label = label or f"{kind} style"
+ style = normalize_css_style(value, label)
+ supported = set(_supported_mark_style_properties(kind))
+ unknown = sorted(set(style) - supported)
+ if unknown:
+ expected = ", ".join(sorted(supported))
+ raise ValueError(
+ f"{label} has unsupported CSS property/properties {unknown}; "
+ f"{kind} supports: {expected}"
+ )
+
+ out: dict[str, Any] = {}
+ seen: dict[str, str] = {}
+ for prop, raw in style.items():
+ if prop == "opacity":
+ _set(out, "opacity", _opacity(raw, f"{label}['opacity']"), prop, seen)
+ elif prop == "fill-opacity":
+ _set(
+ out,
+ "fill_opacity",
+ _opacity(raw, f"{label}['fill-opacity']"),
+ prop,
+ seen,
+ )
+ elif prop == "stroke-opacity":
+ _set(
+ out,
+ "stroke_opacity",
+ _opacity(raw, f"{label}['stroke-opacity']"),
+ prop,
+ seen,
+ )
+ elif prop == "fill":
+ target, paint = _fill(raw, f"{label}[{prop!r}]")
+ if target == "fill" and kind not in _AREA_KINDS | _RECT_KINDS:
+ raise ValueError(f"{label}[{prop!r}] gradients are not supported by {kind}")
+ if kind in _LINE_KINDS | _SIMPLE_STROKE_KINDS:
+ target = "color"
+ _set(out, target, paint, prop, seen)
+ elif prop == "stroke":
+ target = "line_color" if kind == "area" else "color"
+ if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS:
+ target = "stroke"
+ _set(out, target, _paint(raw, f"{label}['stroke']"), prop, seen)
+ elif prop == "stroke-width":
+ target = "line_width" if kind in _AREA_KINDS else "width"
+ if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS:
+ target = "stroke_width"
+ _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen)
+ elif prop == "stroke-dasharray":
+ _set(out, "dash", _dasharray(raw, f"{label}['stroke-dasharray']"), prop, seen)
+ elif prop == "border-radius":
+ _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen)
+ return out
+
+
+def compile_axis_style(
+ value: StyleMapping | None, label: str = "axis style"
+) -> dict[str, StyleValue]:
+ """Validate renderer-backed axis appearance and normalize it to wire keys.
+
+ Axis chrome is partly canvas-painted and partly DOM, so it has a strict
+ cross-renderer vocabulary just like marks. Pixel lengths accept numbers or
+ CSS ``px`` strings and are serialized as finite numbers for every renderer.
+ The more explicit ``tick_label_*`` keys are retained for the pyplot adapter
+ and can differ from the tick-mark paint.
+ """
+ style = normalize_css_style(value, label)
+ supported = (
+ _AXIS_COLOR_PROPERTIES
+ | _AXIS_LENGTH_PROPERTIES
+ | _AXIS_SIZE_PROPERTIES
+ | _AXIS_COMPAT_PROPERTIES
+ | {"tick_direction"}
+ )
+ out: dict[str, StyleValue] = {}
+ sources: dict[str, str] = {}
+ for css_prop, raw in style.items():
+ prop = css_prop.replace("-", "_")
+ if prop not in supported:
+ expected = ", ".join(sorted(supported))
+ raise ValueError(f"{label} has unsupported property {css_prop!r}; supports: {expected}")
+ if prop in _AXIS_COLOR_PROPERTIES:
+ parsed: StyleValue = _paint(raw, f"{label}[{css_prop!r}]")
+ elif prop in _AXIS_LENGTH_PROPERTIES:
+ parsed = _px(raw, f"{label}[{css_prop!r}]")
+ elif prop in _AXIS_SIZE_PROPERTIES:
+ parsed = _px(raw, f"{label}[{css_prop!r}]", positive=True)
+ elif prop == "grid_opacity":
+ parsed = _opacity(raw, f"{label}[{css_prop!r}]")
+ elif prop == "grid_dash":
+ if not isinstance(raw, str) or raw not in _AXIS_DASH_STYLES:
+ raise ValueError(
+ f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DASH_STYLES)}"
+ )
+ parsed = raw
+ else:
+ if not isinstance(raw, str) or raw not in _AXIS_DIRECTIONS:
+ raise ValueError(f"{label}[{css_prop!r}] must be one of {sorted(_AXIS_DIRECTIONS)}")
+ parsed = raw
+ _set(out, prop, parsed, css_prop, sources)
+ return out
+
+
+def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]:
+ """Return renderer-only fill/stroke alpha channels from compiled CSS."""
+ return {
+ key: float(compiled[key]) for key in ("fill_opacity", "stroke_opacity") if key in compiled
+ }
+
+
+__all__ = [
+ "StyleMapping",
+ "StyleValue",
+ "compile_axis_style",
+ "compile_mark_style",
+ "normalize_css_style",
+]
diff --git a/scripts/check_public_api.py b/scripts/check_public_api.py
index a3763724..23ea890d 100644
--- a/scripts/check_public_api.py
+++ b/scripts/check_public_api.py
@@ -76,9 +76,9 @@
DECLARATIVE_CHROME_EXPORTS = (
"legend",
"tooltip",
+ "colorbar",
"modebar",
"theme",
- "mark_style",
"interaction_config",
)
DECLARATIVE_CHART_EXPORTS = (
diff --git a/scripts/png_export_smoke.py b/scripts/png_export_smoke.py
index 20dceed3..4b7fd2fe 100644
--- a/scripts/png_export_smoke.py
+++ b/scripts/png_export_smoke.py
@@ -5,10 +5,10 @@
- **native** (always runs): drives the Rust rasterizer `fc_rasterize` directly
via ctypes with a hand-built display-list command buffer, encodes the returned
framebuffer to PNG, and asserts a real, correctly-sized, non-blank image — the
- end-to-end browser-free `Chart.to_png(engine="native")` mechanism.
-- **chromium** (skipped without a browser): renders a hand-built standalone
- chart HTML through headless Chromium (`export.html_to_png`), the
- `engine="chromium"` path.
+ end-to-end browser-free `Chart.to_png(engine=Engine.default)` mechanism.
+- **browser** (skipped without a supported browser): renders a hand-built
+ standalone chart HTML through the installed Chromium-family adapter
+ (`export.html_to_png`), the `Engine.chromium` path.
Mirrors render_smoke_nonumpy.py's no-dependency stance so CI verifies both
export paths without numpy.
diff --git a/scripts/visual_regression_smoke.py b/scripts/visual_regression_smoke.py
index 199ed5f9..ede4d78d 100644
--- a/scripts/visual_regression_smoke.py
+++ b/scripts/visual_regression_smoke.py
@@ -905,7 +905,7 @@ def main() -> None:
return
for name, factory in CASES:
chart = factory()
- png = chart.to_png(width=W, height=H, scale=SCALE, chromium=chromium)
+ png = chart.to_png(width=W, height=H, scale=SCALE)
stats = _assert_visual(name, png)
_assert_no_tick_label_overlaps(name, chart, chromium)
print(
diff --git a/tests/pyplot/test_perf_guardrail.py b/tests/pyplot/test_perf_guardrail.py
index 2a12f04a..bec02dd9 100644
--- a/tests/pyplot/test_perf_guardrail.py
+++ b/tests/pyplot/test_perf_guardrail.py
@@ -2,9 +2,10 @@
declarative API builds, plus only figure-lifecycle bookkeeping.
Locally measured (2026-07-10, M-series): +9% at 10k points, +2% at 100k,
-+0.6% at 1M. The gate uses generous headroom for noisy CI runners — it exists
-to catch structural regressions (an O(n) copy or per-build revalidation
-sneaking into the shim), not to re-measure the margin.
++0.6% at 1M. The gate uses generous headroom plus a small absolute allowance
+for sub-millisecond timer jitter on CI runners — it exists to catch structural
+regressions (an O(n) copy or per-build revalidation sneaking into the shim),
+not to re-measure the margin.
"""
from __future__ import annotations
@@ -17,6 +18,8 @@
import xy as fc
import xy.pyplot as plt
+_CI_TIMER_JITTER = 100e-6
+
def _best(fn, reps: int) -> float:
best = float("inf")
@@ -49,7 +52,10 @@ def pyplot() -> None:
declarative(), pyplot() # warm caches
d = _best(declarative, reps)
p = _best(pyplot, reps)
- assert p <= d * ceiling, f"pyplot {p * 1e3:.3f}ms vs declarative {d * 1e3:.3f}ms at n={n}"
+ limit = d * ceiling + _CI_TIMER_JITTER
+ assert p <= limit, (
+ f"pyplot {p * 1e3:.3f}ms vs declarative {d * 1e3:.3f}ms at n={n}; limit {limit * 1e3:.3f}ms"
+ )
def test_theme_and_axis_components_are_shared() -> None:
diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py
index 6998eb6e..ff0cf335 100644
--- a/tests/test_batch_export.py
+++ b/tests/test_batch_export.py
@@ -1,6 +1,6 @@
"""`export.write_images` batch API: contract + browser-free native branch.
-The chromium branch (persistent CDP session amortizing browser startup) is
+The browser branch (persistent CDP session amortizing browser startup) is
exercised by live-browser measurement, not unit tests, matching the repo's
convention that real-browser paths are validated by scripts/*smoke*. These
tests pin the argument contract and the native loop, which need no browser.
@@ -23,7 +23,7 @@ def _fig(seed: int) -> Figure:
def test_write_images_native_batch(tmp_path):
figs = [_fig(1), _fig(2), _fig(3)]
paths = [tmp_path / f"chart-{i}.png" for i in range(3)]
- out = export.write_images(figs, paths, engine="native")
+ out = export.write_images(figs, paths)
assert len(out) == 3
for data, path in zip(out, paths, strict=True):
assert data[:8] == b"\x89PNG\r\n\x1a\n"
@@ -40,3 +40,58 @@ def test_write_images_rejects_bad_engine_and_gl(tmp_path):
export.write_images([_fig(1)], [tmp_path / "x.png"], engine="webgpu")
with pytest.raises(ValueError, match="gl"):
export.write_images([_fig(1)], [tmp_path / "x.png"], gl="metal")
+ with pytest.raises(ValueError, match=r"custom_css requires engine=Engine.chromium"):
+ export.write_images(
+ [_fig(1)],
+ [tmp_path / "x.png"],
+ custom_css=".xy { color: red; }",
+ )
+
+
+def test_write_images_chromium_engine_is_deprecated_alias(tmp_path, monkeypatch):
+ monkeypatch.setattr(export, "find_browser", lambda explicit=None: None)
+ with (
+ pytest.warns(DeprecationWarning, match="string export engines"),
+ pytest.raises(RuntimeError, match="browser PNG export"),
+ ):
+ export.write_images(
+ [_fig(1)],
+ [tmp_path / "x.png"],
+ engine="chromium",
+ )
+
+
+def test_write_images_chromium_threads_custom_css(tmp_path, monkeypatch):
+ from xy import _chromium
+
+ seen = []
+
+ class FakeSession:
+ def __init__(self, *_args, **_kwargs):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return None
+
+ def render_png(self, html, _width, _height, *, scale):
+ seen.append((html, scale))
+ return b"\x89PNG\r\n\x1a\nbatch"
+
+ monkeypatch.setattr(export, "find_browser", lambda explicit=None: "/fake/chrome")
+ monkeypatch.setattr(_chromium, "ChromiumSession", FakeSession)
+ css = '[data-fc-slot="title"] { color: rebeccapurple; }'
+ path = tmp_path / "x.png"
+
+ result = export.write_images(
+ [_fig(1)],
+ [path],
+ engine=export.Engine.chromium,
+ custom_css=css,
+ )
+
+ assert result == [path.read_bytes()]
+ assert f"" in seen[0][0]
+ assert seen[0][1] == 2.0
diff --git a/tests/test_components.py b/tests/test_components.py
index 731c7595..1d247687 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -19,10 +19,10 @@
Annotation,
Axis,
Chart,
+ Colorbar,
Interaction,
Legend,
Mark,
- MarkStyle,
Modebar,
Theme,
Tooltip,
@@ -88,10 +88,11 @@ def test_factories_return_components():
assert isinstance(fc.threshold(1.0), Annotation)
assert isinstance(fc.threshold_zone(0.0, 1.0), Annotation)
assert isinstance(fc.tooltip(fields=["x"]), Tooltip)
+ assert isinstance(fc.colorbar(show=False), Colorbar)
assert isinstance(fc.modebar(show=False), Modebar)
assert isinstance(fc.theme(style={"--chart-bg": "transparent"}), Theme)
- assert isinstance(fc.mark_style(hover={"size": 14}), MarkStyle)
assert isinstance(fc.interaction_config(crosshair=True), Interaction)
+ assert not hasattr(fc, "mark_style")
chart = fc.scatter_chart(fc.scatter(x=[1.0], y=[2.0]))
assert isinstance(chart, Chart)
assert isinstance(fc.chart(fc.scatter(x=[1.0], y=[2.0])), Chart)
@@ -419,11 +420,6 @@ def test_component_style_tooltip_and_modebar_metadata_is_opt_in():
grid_color="rgba(0,0,0,.1)",
selection_fill="rgba(37,99,235,.14)",
),
- fc.mark_style(
- hover={"color": "#0f172a", "size": 18, "opacity": 0.9},
- selected={"opacity": 1},
- unselected={"opacity": 0.2},
- ),
class_name="root-node",
class_names={"legend": "legend-slot", "tooltip": "tooltip-slot"},
style={"--chart-grid": "rgba(1,2,3,.25)", "--chart-axis": "currentColor"},
@@ -446,11 +442,6 @@ def test_component_style_tooltip_and_modebar_metadata_is_opt_in():
assert spec["dom"]["styles"]["legend"] == {"max-height": 220}
assert spec["dom"]["styles"]["tooltip"] == {"background-color": "black"}
assert spec["dom"]["styles"]["modebar_button"] == {"border-radius": 4}
- assert spec["mark_style"] == {
- "hover": {"color": "#0f172a", "size": 18, "opacity": 0.9},
- "selected": {"opacity": 1},
- "unselected": {"opacity": 0.2},
- }
assert spec["tooltip"] == {
"fields": ["feature_a", "feature_b", "segment"],
"title": "{segment}",
@@ -472,6 +463,7 @@ def test_component_style_tooltip_and_modebar_metadata_is_opt_in():
def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
legend_component = FakeReflexComponent("legend")
tooltip_component = FakeReflexComponent("tooltip")
+ colorbar_component = FakeReflexComponent("colorbar")
data = FakeFrame(
{
"month": np.array(["Jan", "Feb", "Mar"]),
@@ -532,6 +524,11 @@ def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
class_name="tooltip-node",
style={"background": "linear-gradient(red,blue)"},
),
+ fc.colorbar(
+ colorbar_component,
+ class_name="colorbar-node",
+ style={"font-size": 12},
+ ),
fc.modebar(
show=True,
class_name="modebar-node",
@@ -558,6 +555,7 @@ def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
assert chart.chrome_components() == {
"legend": legend_component,
"tooltip": tooltip_component,
+ "colorbar": colorbar_component,
}
spec, _ = chart.figure().build_payload()
@@ -566,6 +564,8 @@ def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
assert spec["height"] == 430
assert spec["show_legend"] is True
assert spec["show_tooltip"] is False
+ assert spec["dom"]["class_names"]["colorbar"] == "colorbar-node"
+ assert spec["dom"]["styles"]["colorbar"] == {"font-size": 12}
assert [trace["kind"] for trace in spec["traces"]] == ["bar", "line"]
assert [trace["y_axis"] for trace in spec["traces"]] == ["y", "y2"]
assert spec["traces"][0]["style"]["class_name"] == "revenue-bars"
@@ -604,6 +604,7 @@ def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
"modebar": "modebar-node",
"modebar_button": "modebar-button",
"tooltip": "tooltip-node",
+ "colorbar": "colorbar-node",
},
"style": {
"--chart-bg": "transparent",
@@ -613,6 +614,7 @@ def test_declarative_core_contract_for_layered_axis_chrome_and_interaction():
"styles": {
"legend": {"max-height": 180},
"tooltip": {"background": "linear-gradient(red,blue)"},
+ "colorbar": {"font-size": 12},
},
}
assert spec["tooltip"]["fields"] == ["month", "revenue", "latency"]
@@ -656,12 +658,30 @@ def test_legend_and_tooltip_accept_opaque_framework_components_without_serializi
assert spec["tooltip"] == {"fields": ["x", "y"]}
assert spec["dom"]["class_names"]["tooltip"] == "tooltip-node"
assert "FakeReflexComponent" not in json.dumps(spec)
-
html = chart.to_html()
assert "FakeReflexComponent" not in html
assert "show_tooltip" in html
+def test_colorbar_show_false_clears_generated_colorbar_options(monkeypatch):
+ original = components_module._MARK_APPLIERS["line"]
+
+ def apply_with_colorbar(fig, mark, data):
+ original(fig, mark, data)
+ fig.colorbar_options = {"domain": [0.0, 1.0], "colormap": "viridis"}
+
+ monkeypatch.setitem(components_module._MARK_APPLIERS, "line", apply_with_colorbar)
+ chart = fc.chart(
+ fc.line(x=[0.0, 1.0], y=[1.0, 2.0]),
+ fc.colorbar(show=False),
+ )
+
+ figure = chart.figure()
+ spec, _ = figure.build_payload()
+ assert figure.colorbar_options is None
+ assert "colorbar" not in spec
+
+
def test_declarative_chart_keeps_notebook_export_and_framework_chrome_contract(
monkeypatch,
tmp_path,
@@ -1535,9 +1555,9 @@ def fake_to_png(
width=None,
height=None,
scale=2.0,
- engine="native",
+ engine=fc.Engine.default,
optimize=False,
- chromium=None,
+ custom_css=None,
sandbox=True,
gl="software",
):
@@ -1550,7 +1570,7 @@ def fake_to_png(
"scale": scale,
"engine": engine,
"optimize": optimize,
- "chromium": chromium,
+ "custom_css": custom_css,
"sandbox": sandbox,
"gl": gl,
}
@@ -1564,9 +1584,9 @@ def fake_to_png(
width=320,
height=200,
scale=1.5,
- engine="chromium",
+ engine=fc.Engine.chromium,
optimize=True,
- chromium="/chrome",
+ custom_css=".chart { color: rebeccapurple; }",
sandbox=False,
gl="hardware",
)
@@ -1578,9 +1598,9 @@ def fake_to_png(
"width": 320,
"height": 200,
"scale": 1.5,
- "engine": "chromium",
+ "engine": fc.Engine.chromium,
"optimize": True,
- "chromium": "/chrome",
+ "custom_css": ".chart { color: rebeccapurple; }",
"sandbox": False,
"gl": "hardware",
}
diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py
new file mode 100644
index 00000000..3f79d0b8
--- /dev/null
+++ b/tests/test_css_mark_styles.py
@@ -0,0 +1,305 @@
+"""CSS-first mark styling stays a renderer contract, not a state framework."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import xy as fc
+from xy import _raster, _svg
+from xy._figure import Figure
+from xy.styles import compile_mark_style, normalize_css_style
+
+
+def test_renderer_style_capabilities_are_not_a_public_schema() -> None:
+ assert not hasattr(fc, "mark_style_schema")
+
+
+def test_python_style_aliases_normalize_to_css_names() -> None:
+ assert normalize_css_style({"stroke_width": "2px", "fill_opacity": 0.5}) == {
+ "stroke-width": "2px",
+ "fill-opacity": 0.5,
+ }
+ with pytest.raises(ValueError, match="more than once"):
+ normalize_css_style({"stroke_width": 1, "stroke-width": 2})
+
+
+def test_line_css_compiles_to_existing_renderer_contract() -> None:
+ compiled = compile_mark_style(
+ "line",
+ {
+ "stroke": "#ef4444",
+ "stroke-width": "2.5px",
+ "stroke-opacity": "0.75",
+ "stroke-dasharray": "6px 3px",
+ },
+ )
+
+ assert compiled == {
+ "color": "#ef4444",
+ "width": 2.5,
+ "stroke_opacity": 0.75,
+ "dash": [6.0, 3.0],
+ }
+
+
+def test_css_opacity_channels_remain_independent_in_svg() -> None:
+ fig = fc.chart(
+ fc.scatter(
+ x=[0.0],
+ y=[1.0],
+ size=10,
+ style={
+ "opacity": 0.5,
+ "fill-opacity": 0.4,
+ "stroke": "black",
+ "stroke-width": 2,
+ "stroke-opacity": 0.8,
+ },
+ )
+ ).figure()
+
+ assert fig.traces[0].style["fill_opacity"] == pytest.approx(0.4)
+ assert fig.traces[0].style["stroke_opacity"] == pytest.approx(0.8)
+ svg = fig.to_svg()
+ assert 'fill-opacity="0.2"' in svg
+ assert 'stroke-opacity="0.4"' in svg
+
+
+def test_css_style_wins_over_legacy_appearance_aliases() -> None:
+ fig = fc.chart(
+ fc.line(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ color="blue",
+ width=9,
+ opacity=1,
+ style={"stroke": "red", "stroke-width": "2px", "opacity": 0.4},
+ )
+ ).figure()
+
+ assert fig.traces[0].style == {"color": "red", "width": 2.0, "opacity": 0.4}
+
+
+def test_css_color_is_not_an_alias_for_mark_paint() -> None:
+ with pytest.raises(ValueError, match=r"unsupported CSS property.*color"):
+ fc.chart(
+ fc.line(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ style={"color": "red", "stroke": "currentColor"},
+ )
+ ).figure()
+
+ fig = fc.chart(
+ fc.line(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ color="red",
+ style={"stroke": "blue"},
+ )
+ ).figure()
+ assert fig.traces[0].style["color"] == "blue"
+
+
+def test_scatter_and_rect_css_use_fill_stroke_and_border_radius() -> None:
+ chart = fc.chart(
+ fc.scatter(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ style={"fill": "#22c55e", "stroke": "#052e16", "stroke-width": 2},
+ ),
+ fc.bar(
+ x=[3.0, 4.0],
+ y=[1.0, 2.0],
+ style={
+ "fill": "linear-gradient(to top, #2563eb, #93c5fd)",
+ "stroke": "#1e3a8a",
+ "stroke-width": "1px",
+ "border-radius": "4px",
+ },
+ ),
+ )
+ fig = chart.figure()
+
+ scatter = fig.traces[0]
+ assert scatter.color_ch is not None and scatter.color_ch.constant == "#22c55e"
+ assert scatter.style["stroke"] == "#052e16"
+ assert scatter.style["stroke_width"] == 2.0
+
+ bar = fig.traces[1]
+ assert bar.style["stroke"] == "#1e3a8a"
+ assert bar.style["stroke_width"] == 1.0
+ assert bar.style["corner_radius"] == 4.0
+ assert bar.style["fill"]["stops"][0][1] == "#2563eb"
+
+
+def test_css_mark_style_reaches_svg_and_native_renderers() -> None:
+ fig = fc.chart(
+ fc.scatter(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ size=8,
+ style={
+ "fill": "#22c55e",
+ "stroke": "#052e16",
+ "stroke-width": "2px",
+ "opacity": 1,
+ },
+ )
+ ).figure()
+
+ svg = fig.to_svg()
+ assert 'fill="#22c55e"' in svg
+ assert 'stroke="#052e16"' in svg
+ assert 'stroke-width="2"' in svg
+
+ image = _raster.render_raster(*fig.build_payload(), scale=1)
+ assert np.any(np.all(image == np.array([34, 197, 94, 255], dtype=np.uint8), axis=2))
+ assert np.any(np.all(image == np.array([5, 46, 22, 255], dtype=np.uint8), axis=2))
+
+
+def test_axis_style_reaches_svg_and_native_renderers() -> None:
+ fig = fc.chart(
+ fc.line(x=[0.0, 1.0], y=[1.0, 2.0]),
+ fc.x_axis(
+ label="time",
+ style={
+ "grid_color": "#ff0000",
+ "grid_width": 3,
+ "grid_dash": "dashed",
+ "grid_opacity": 0.6,
+ "axis_color": "#0000ff",
+ "axis_width": 2,
+ "tick_length": 6,
+ "tick_width": 2,
+ "tick_color": "#00aa00",
+ "tick_label_color": "#cc5500",
+ "tick_size": 13,
+ "label_color": "#aa00aa",
+ "label_size": 15,
+ },
+ ),
+ ).figure()
+
+ svg = fig.to_svg()
+ assert 'stroke="#ff0000" stroke-width="3" stroke-opacity="0.6" stroke-dasharray="6,4"' in svg
+ assert 'stroke="#0000ff" stroke-width="2"' in svg
+ assert 'stroke="#00aa00" stroke-width="2"' in svg
+ assert 'fill="#cc5500" font-size="13" text-anchor="middle"' in svg
+ assert 'font-size="15" font-weight="500" fill="#aa00aa"' in svg
+ assert _raster.render_raster(*fig.build_payload(), scale=1).shape[-1] == 4
+
+
+def test_axis_style_is_normalized_and_rejected_before_render() -> None:
+ axis = fc.x_axis(
+ style={
+ "grid-width": "3px",
+ "tick_label_size": "13px",
+ "tick-direction": "inout",
+ "label-color": "rebeccapurple",
+ }
+ )
+ assert axis.style == {
+ "grid_width": 3.0,
+ "tick_label_size": 13.0,
+ "tick_direction": "inout",
+ "label_color": "rebeccapurple",
+ }
+
+ with pytest.raises(ValueError, match=r"unsupported property 'box-shadow'"):
+ fc.x_axis(style={"box-shadow": "0 0 2px red"})
+ with pytest.raises(ValueError, match=r"finite CSS px length"):
+ fc.x_axis(style={"grid_width": "3em"})
+ with pytest.raises(ValueError, match=r"not a recognized CSS color"):
+ fc.y_axis(style={"tick_color": "definitely-not-a-color"})
+ with pytest.raises(ValueError, match=r"must be one of"):
+ fc.y_axis(style={"tick_direction": "sideways"})
+
+
+def test_area_outline_obeys_whole_mark_and_stroke_opacity() -> None:
+ default = fc.chart(fc.area(x=[0.0, 1.0], y=[1.0, 2.0])).figure().to_svg()
+ assert 'stroke-opacity="0.35"' in default
+
+ styled = fc.chart(
+ fc.area(
+ x=[0.0, 1.0],
+ y=[1.0, 2.0],
+ opacity=0.4,
+ line_opacity=0.5,
+ style={"stroke-opacity": 0.5},
+ )
+ ).figure()
+ assert 'stroke-opacity="0.1"' in styled.to_svg()
+
+
+def test_static_invalid_paint_fallback_matches_browser_renderer() -> None:
+ expected = (76, 120, 168, 255)
+ assert _svg._paint_rgba8("var(--unresolved)") == expected
+ assert _raster._parse_color("var(--unresolved)") == expected
+
+
+def test_mark_style_rejects_unrenderable_css_before_mutating_figure() -> None:
+ fig = Figure()
+ with pytest.raises(ValueError, match="unsupported CSS property"):
+ fig.line([0.0, 1.0], [1.0, 2.0], style={"box-shadow": "0 0 4px red"})
+ assert fig.traces == []
+ assert len(fig.store) == 0
+
+
+def test_css_variables_remain_reflex_owned_dom_values() -> None:
+ chart = fc.chart(
+ fc.line(x=[0.0, 1.0], y=[1.0, 2.0], style={"stroke": "var(--accent)"}),
+ style={"--accent": "oklch(0.7 0.2 250)"},
+ )
+ fig = chart.figure()
+ spec, _ = fig.build_payload()
+
+ assert spec["dom"]["style"]["--accent"] == "oklch(0.7 0.2 250)"
+ assert spec["traces"][0]["style"]["color"] == "var(--accent)"
+
+
+def test_static_renderers_resolve_complete_chart_color_tokens() -> None:
+ fig = fc.chart(
+ fc.line(x=[0.0, 1.0], y=[1.0, 2.0], style={"stroke": "var(--accent)"}),
+ fc.line(x=[0.0, 1.0], y=[2.0, 1.0], style={"stroke": "var(--missing, #0ea5e9)"}),
+ style={"--accent": "var(--brand)", "--brand": "#7c3aed"},
+ ).figure()
+
+ spec, blob = fig.build_payload()
+ svg = fig.to_svg()
+ assert 'stroke="#7c3aed"' in svg
+ assert 'stroke="#0ea5e9"' in svg
+ assert spec["traces"][0]["style"]["color"] == "var(--accent)"
+
+ image = _raster.render_raster(spec, blob, scale=1)
+ assert np.any(np.all(image == np.array([124, 58, 237, 255], dtype=np.uint8), axis=2))
+
+
+def test_scatter_css_fill_survives_density_lod() -> None:
+ fig = fc.chart(
+ fc.scatter(
+ x=[0.0, 1.0, 2.0],
+ y=[1.0, 2.0, 3.0],
+ density=True,
+ style={"fill": "rgb(37 99 235 / 80%)", "opacity": 0.6},
+ )
+ ).figure()
+ spec, _ = fig.build_payload()
+
+ trace = spec["traces"][0]
+ assert trace["tier"] == "density"
+ assert trace["density"]["color"] == "rgb(37 99 235 / 80%)"
+ assert trace["style"]["opacity"] == 0.6
+
+
+def test_faceting_preserves_concrete_css_style() -> None:
+ data = {"x": [0.0, 1.0], "y": [1.0, 2.0], "panel": ["a", "b"]}
+ chart = fc.facet_chart(
+ fc.line(x="x", y="y", data=data, style={"stroke": "#7c3aed"}),
+ data=data,
+ by="panel",
+ )
+ grid = chart.figure()
+
+ assert all(figure.traces[0].style["color"] == "#7c3aed" for figure in grid.figures)
diff --git a/tests/test_css_validation.py b/tests/test_css_validation.py
index 60b6a788..a79ac59f 100644
--- a/tests/test_css_validation.py
+++ b/tests/test_css_validation.py
@@ -168,5 +168,5 @@ def test_raster_parse_color_uses_the_native_grammar() -> None:
assert _parse_color("none") == (0, 0, 0, 0)
assert _parse_color("transparent")[3] == 0
assert _parse_color("#ff0000", opacity=0.5) == (255, 0, 0, 128)
- # Unparseable input keeps the documented never-invisible mid-gray fallback.
- assert _parse_color("oklch(0.7 0.1 250)") == (100, 100, 100, 255)
+ # Browser-only input uses the same never-invisible blue-gray as the JS renderer.
+ assert _parse_color("oklch(0.7 0.1 250)") == (76, 120, 168, 255)
diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py
index 93496b50..5d7fa147 100644
--- a/tests/test_docs_examples.py
+++ b/tests/test_docs_examples.py
@@ -180,7 +180,7 @@ def test_api_examples_document_alpha_api_stability_boundary() -> None:
"`chart.figure()` as an advanced escape hatch",
'`width="100%"`',
"Standalone `to_html(...)` needs no browser dependency",
- '`to_png(..., engine="chromium")` needs a local Chrome/Chromium executable',
+ "`to_png(..., engine=Engine.chromium)` uses an installed Chrome, Chromium, Edge, or",
]
for marker in required:
assert marker in text
diff --git a/tests/test_facets.py b/tests/test_facets.py
index 6d1eaf0e..17415b95 100644
--- a/tests/test_facets.py
+++ b/tests/test_facets.py
@@ -219,6 +219,18 @@ def test_facet_png_dimensions_and_default_scale() -> None:
assert grid.grid_height == 200 # one row: no gaps, no title strip
+def test_facet_chart_png_forwards_current_export_options() -> None:
+ chart = fc.facet_chart(
+ fc.line(x="x", y="y"),
+ by="g",
+ data=_table(),
+ width=320,
+ height=180,
+ )
+
+ assert chart.to_png(scale=1.0, gl="software").startswith(b"\x89PNG")
+
+
# -- shared categorical axes --------------------------------------------------
diff --git a/tests/test_figure.py b/tests/test_figure.py
index efe86136..debe1a92 100644
--- a/tests/test_figure.py
+++ b/tests/test_figure.py
@@ -1780,15 +1780,17 @@ def test_normal_magnitudes_keep_unit_scale():
def test_to_png_full_path(tmp_path):
- # End-to-end Figure.to_png: needs a Chromium binary; skip cleanly without
- # one (the mechanism itself is covered dependency-free by png_export_smoke).
+ # End-to-end browser Figure.to_png: skip cleanly without a supported
+ # installed browser (the mechanism is also covered dependency-free by the
+ # PNG smoke).
from xy import export
- if export.find_chromium() is None:
- pytest.skip("no chromium for PNG export")
+ browser = export.find_browser()
+ if browser is None:
+ pytest.skip("no supported browser for PNG export")
fig = Figure(width=320, height=200).line(np.arange(50.0), np.sin(np.arange(50.0)))
out = tmp_path / "chart.png"
- data = fig.to_png(str(out))
+ data = fig.to_png(str(out), engine=export.Engine.chromium)
assert data[:8] == b"\x89PNG\r\n\x1a\n"
assert out.read_bytes() == data
# 320x200 at default scale=2 -> 640x400
@@ -1798,37 +1800,134 @@ def test_to_png_full_path(tmp_path):
assert (w, h) == (640, 400)
-def test_find_chromium_checks_standard_macos_app_paths(monkeypatch):
+def test_find_browser_checks_standard_macos_app_paths(monkeypatch):
from xy import export
chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+ monkeypatch.delenv("XY_BROWSER", raising=False)
monkeypatch.delenv("XY_CHROMIUM", raising=False)
monkeypatch.setattr(export.shutil, "which", lambda _name: None)
monkeypatch.setattr(export.Path, "exists", lambda path: str(path) == chrome)
+ assert export.find_browser() == chrome
assert export.find_chromium() == chrome
-def test_to_png_missing_chromium_is_clear(monkeypatch):
- # The chromium engine, without a browser, names the fix (mirrors plotly
- # needing kaleido) and never silently returns bad bytes. (The default native
- # engine needs no browser at all.)
+def test_find_browser_explicit_missing_path_does_not_fall_back(monkeypatch):
from xy import export
- monkeypatch.setattr(export, "find_chromium", lambda explicit=None: None)
+ monkeypatch.setattr(
+ export.shutil,
+ "which",
+ lambda name: None if name.startswith("/") else "/installed/chrome",
+ )
+ monkeypatch.setattr(export.Path, "exists", lambda _path: False)
+
+ assert export.find_browser("/missing/browser") is None
+
+
+def test_find_browser_prefers_new_environment_variable(monkeypatch):
+ from xy import export
+
+ monkeypatch.setenv("XY_BROWSER", "/new/browser")
+ monkeypatch.setenv("XY_CHROMIUM", "/legacy/browser")
+ monkeypatch.setattr(export.Path, "exists", lambda path: str(path) == "/new/browser")
+
+ assert export.find_browser() == "/new/browser"
+
+
+def test_find_browser_discovers_edge_on_path(monkeypatch):
+ from xy import export
+
+ monkeypatch.delenv("XY_BROWSER", raising=False)
+ monkeypatch.delenv("XY_CHROMIUM", raising=False)
+ monkeypatch.setattr(export.Path, "exists", lambda _path: False)
+ monkeypatch.setattr(
+ export.shutil,
+ "which",
+ lambda name: "/usr/bin/microsoft-edge" if name == "microsoft-edge" else None,
+ )
+
+ assert export.find_browser() == "/usr/bin/microsoft-edge"
+
+
+def test_default_engine_never_looks_for_a_browser(monkeypatch):
+ from xy import export
+
+ def fail_lookup(explicit=None):
+ del explicit
+ raise AssertionError("Engine.default must remain browser-free")
+
+ monkeypatch.setattr(export, "find_browser", fail_lookup)
fig = Figure(width=200, height=150).scatter(np.arange(5.0), np.arange(5.0))
- with pytest.raises(RuntimeError, match="Chromium"):
- fig.to_png(engine="chromium")
+
+ assert fig.to_png(engine=export.Engine.default).startswith(b"\x89PNG")
+
+
+def test_to_png_missing_browser_is_clear(monkeypatch):
+ # Browser mode never silently falls back to native. The default native
+ # engine needs no installed browser at all.
+ from xy import export
+
+ monkeypatch.setattr(export, "find_browser", lambda explicit=None: None)
+ fig = Figure(width=200, height=150).scatter(np.arange(5.0), np.arange(5.0))
+ with pytest.raises(RuntimeError, match="browser PNG export"):
+ fig.to_png(engine=export.Engine.chromium)
+
+
+def test_to_png_string_engine_is_deprecated_alias(monkeypatch):
+ from xy import export
+
+ seen = {}
+
+ def fake_html_to_png(_html, _width, _height, **kwargs):
+ seen.update(kwargs)
+ return b"\x89PNG\r\n\x1a\nlegacy"
+
+ monkeypatch.setattr(export, "html_to_png", fake_html_to_png)
+ fig = Figure(width=200, height=150).scatter(np.arange(5.0), np.arange(5.0))
+ with pytest.warns(DeprecationWarning, match="string export engines"):
+ data = fig.to_png(engine="chromium")
+
+ assert data.endswith(b"legacy")
+ assert seen["sandbox"] is True
+
+
+def test_to_png_chromium_threads_custom_css_into_standalone_html(monkeypatch):
+ from xy import export
+
+ seen = {}
+
+ def fake_html_to_png(html, _width, _height, **_kwargs):
+ seen["html"] = html
+ return b"\x89PNG\r\n\x1a\ncss"
+
+ monkeypatch.setattr(export, "html_to_png", fake_html_to_png)
+ fig = Figure(width=200, height=150).line([0.0, 1.0], [1.0, 2.0])
+ css = '[data-fc-slot="title"] { color: rebeccapurple; }'
+
+ data = fig.to_png(engine=export.Engine.chromium, custom_css=css)
+
+ assert data.endswith(b"css")
+ assert f"" in seen["html"]
+
+
+def test_to_png_native_rejects_browser_only_custom_css():
+ from xy import export
+
+ fig = Figure(width=200, height=150).line([0.0, 1.0], [1.0, 2.0])
+ with pytest.raises(ValueError, match=r"custom_css requires engine=Engine.chromium"):
+ fig.to_png(engine=export.Engine.default, custom_css=".xy { color: red; }")
-def test_to_png_rejects_bad_export_geometry_before_chromium_lookup(monkeypatch):
+def test_to_png_rejects_bad_export_geometry_before_browser_lookup(monkeypatch):
from xy import export
def fail_lookup(explicit=None):
del explicit
- raise AssertionError("Chromium lookup should not run for invalid PNG export options")
+ raise AssertionError("browser lookup should not run for invalid PNG export options")
- monkeypatch.setattr(export, "find_chromium", fail_lookup)
+ monkeypatch.setattr(export, "find_browser", fail_lookup)
fig = Figure(width="100%", height="100%").line([0.0, 1.0], [1.0, 2.0])
cases = [
({"width": 0}, "PNG width"),
@@ -1846,14 +1945,14 @@ def fail_lookup(explicit=None):
fig.to_png(**kwargs)
-def test_html_to_png_rejects_bad_mechanism_options_before_chromium_lookup(monkeypatch):
+def test_html_to_png_rejects_bad_mechanism_options_before_browser_lookup(monkeypatch):
from xy import export
def fail_lookup(explicit=None):
del explicit
- raise AssertionError("Chromium lookup should not run for invalid PNG export options")
+ raise AssertionError("browser lookup should not run for invalid PNG export options")
- monkeypatch.setattr(export, "find_chromium", fail_lookup)
+ monkeypatch.setattr(export, "find_browser", fail_lookup)
cases = [
({"width": 0, "height": 200}, "PNG width"),
({"width": 320, "height": False}, "PNG height"),
@@ -1868,12 +1967,12 @@ def fail_lookup(explicit=None):
export.html_to_png("", **kwargs)
-def test_html_to_png_uses_chromium_sandbox_by_default(monkeypatch):
+def test_html_to_png_uses_browser_sandbox_by_default(monkeypatch):
from xy import export
seen = []
- monkeypatch.setattr(export, "find_chromium", lambda explicit=None: "/fake/chrome")
+ monkeypatch.setattr(export, "find_browser", lambda explicit=None: "/fake/chrome")
def fake_run(args, **kwargs):
del kwargs
@@ -1893,12 +1992,12 @@ def fake_run(args, **kwargs):
assert "--no-sandbox" in seen[1]
-def test_html_to_png_retries_without_sandbox_when_chromium_crashes(monkeypatch):
+def test_html_to_png_retries_without_sandbox_when_browser_crashes(monkeypatch):
from xy import export
seen = []
- monkeypatch.setattr(export, "find_chromium", lambda explicit=None: "/fake/chrome")
+ monkeypatch.setattr(export, "find_browser", lambda explicit=None: "/fake/chrome")
def fake_run(args, **kwargs):
del kwargs
diff --git a/tests/test_plot_families.py b/tests/test_plot_families.py
index b52c9d1c..0183abaa 100644
--- a/tests/test_plot_families.py
+++ b/tests/test_plot_families.py
@@ -81,7 +81,7 @@ def test_generic_segments_share_instanced_renderers() -> None:
assert spec["traces"][0]["kind"] == "segments"
assert spec["traces"][0]["n_marks"] == 2
assert " None:
@@ -104,7 +104,7 @@ def test_triangle_mesh_ships_per_triangle_color_and_renders_static_exports() ->
assert all(name in trace for name in ("x0", "y0", "x1", "y1", "x2", "y2"))
svg = fig.to_svg()
assert svg.count(" None:
diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py
index 4012d366..18c5069d 100644
--- a/tests/test_static_client_security.py
+++ b/tests/test_static_client_security.py
@@ -181,6 +181,10 @@ def test_client_applies_every_public_dom_slot() -> None:
"legend": '_applySlot(lg, "legend")',
"legend_item": '_applySlot(row, "legend_item")',
"legend_swatch": '_applySlot(sw, "legend_swatch")',
+ "colorbar": '_applySlot(box, "colorbar")',
+ "colorbar_bar": '_applySlot(bar, "colorbar_bar")',
+ "colorbar_tick": '_applySlot(tick, "colorbar_tick")',
+ "colorbar_title": '_applySlot(label, "colorbar_title")',
"tooltip": '_applySlot(this.tooltip, "tooltip")',
"modebar": '_applySlot(bar, "modebar")',
"modebar_button": '_applySlot(b, "modebar_button")',
diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py
index 4f613076..91ad3cda 100644
--- a/tests/test_type_surface.py
+++ b/tests/test_type_surface.py
@@ -11,6 +11,7 @@
import xy as fc
import xy._figure as figure_module
import xy.components as components
+from xy.export import Engine
ROOT = Path(__file__).resolve().parents[1]
@@ -74,9 +75,9 @@
CHROME_FACTORIES = (
"legend",
"tooltip",
+ "colorbar",
"modebar",
"theme",
- "mark_style",
"interaction_config",
)
CHART_READOUTS = (
@@ -158,7 +159,7 @@ def test_component_types_are_lazy_public_root_exports() -> None:
"Component",
"FacetChart",
"Mark",
- "MarkStyle",
+ "Colorbar",
"Annotation",
"Axis",
"Interaction",
@@ -169,6 +170,12 @@ def test_component_types_are_lazy_public_root_exports() -> None:
assert getattr(fc, name) is getattr(components, name)
+def test_export_engine_is_lazy_public_enum() -> None:
+ assert "Engine" in fc.__all__
+ assert fc.Engine is Engine
+ assert tuple(Engine) == (Engine.default, Engine.chromium)
+
+
def test_chart_dom_slots_are_public_styling_contract() -> None:
expected = (
"root",
@@ -179,6 +186,10 @@ def test_chart_dom_slots_are_public_styling_contract() -> None:
"legend",
"legend_item",
"legend_swatch",
+ "colorbar",
+ "colorbar_bar",
+ "colorbar_tick",
+ "colorbar_title",
"tooltip",
"modebar",
"modebar_button",
@@ -271,9 +282,9 @@ def test_public_component_factories_have_typed_signatures() -> None:
"y_axis": components.Axis,
"legend": components.Legend,
"tooltip": components.Tooltip,
+ "colorbar": components.Colorbar,
"modebar": components.Modebar,
"theme": components.Theme,
- "mark_style": components.MarkStyle,
"interaction_config": components.Interaction,
**{name: components.Chart for name in CHART_FACTORIES},
"facet_chart": components.FacetChart,
@@ -398,11 +409,13 @@ def test_chart_and_figure_png_export_types_stay_in_sync() -> None:
assert chart_hints["return"] is bytes
assert chart_hints == figure_hints
- for key in ("path", "width", "height", "chromium"):
+ for key in ("path", "width", "height", "custom_css"):
args = get_args(chart_hints[key])
assert type(None) in args, key
assert str in get_args(chart_hints["path"])
+ assert str in get_args(chart_hints["custom_css"])
assert chart_hints["scale"] is float
+ assert chart_hints["engine"] is Engine
assert chart_hints["sandbox"] is bool