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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -341,14 +341,19 @@ axis labels, trace names, legends, series names, and categories are escaped
before entering inline JSON or `<title>`, 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

Expand Down
4 changes: 2 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions benchmarks/_launch_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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".
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/bench_native_vs_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 22 additions & 13 deletions benchmarks/bench_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import argparse
import json
import os
import statistics
import sys
import time
Expand Down Expand Up @@ -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,
),
):
Expand All @@ -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


Expand Down
19 changes: 12 additions & 7 deletions benchmarks/test_codspeed_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")


Expand All @@ -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")


Expand All @@ -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")


Expand All @@ -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")


Expand Down
10 changes: 7 additions & 3 deletions docs/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 7 additions & 6 deletions docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
13 changes: 6 additions & 7 deletions docs/chart-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 → `<linearGradient>`, smooth curves → exact cubic Béziers,
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading