From 404ce4e3c67230abf78940c14508eba8ed1fcc1e Mon Sep 17 00:00:00 2001 From: Alek Date: Tue, 14 Jul 2026 14:42:55 -0700 Subject: [PATCH 1/4] Add CSS-first styling and selectable PNG engines --- README.md | 21 +- benchmarks/README.md | 4 +- benchmarks/_launch_static.py | 4 +- benchmarks/bench_native_vs_agg.py | 2 +- benchmarks/bench_workflows.py | 35 ++- benchmarks/test_codspeed_kernels.py | 10 +- docs/api-examples.md | 8 +- docs/benchmark.md | 13 +- docs/chart-roadmap.md | 13 +- docs/design/reflex-shaped-api.md | 48 ++- docs/styling.md | 115 ++++++-- .../reflex/reflex_xy_app/reflex_xy_app.py | 5 - examples/reflex/scripts/build_charts.py | 5 - js/src/20_theme.js | 3 + js/src/40_gl.js | 9 +- js/src/45_lod.js | 1 + js/src/50_chartview.js | 52 +++- python/xy/__init__.py | 15 +- python/xy/_figure.py | 23 +- python/xy/_payload.py | 10 +- python/xy/_raster.py | 206 ++++++++++--- python/xy/_svg.py | 176 +++++++++-- python/xy/components.py | 155 +++++++--- python/xy/dom.py | 4 + python/xy/export.py | 189 ++++++++---- python/xy/facets.py | 13 +- python/xy/interaction.py | 2 + python/xy/marks.py | 160 +++++++++- python/xy/pyplot/_axes.py | 3 +- python/xy/static/index.js | 63 ++-- python/xy/static/standalone.js | 63 ++-- python/xy/styles.py | 276 ++++++++++++++++++ scripts/check_public_api.py | 2 +- scripts/png_export_smoke.py | 8 +- scripts/visual_regression_smoke.py | 2 +- tests/test_batch_export.py | 17 +- tests/test_components.py | 36 ++- tests/test_css_mark_styles.py | 215 ++++++++++++++ tests/test_docs_examples.py | 2 +- tests/test_figure.py | 118 ++++++-- tests/test_plot_families.py | 4 +- tests/test_static_client_security.py | 4 + tests/test_type_surface.py | 20 +- 43 files changed, 1726 insertions(+), 408 deletions(-) create mode 100644 python/xy/styles.py create mode 100644 tests/test_css_mark_styles.py diff --git a/README.md b/README.md index 7af4bb59..ecbe9d6d 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,17 @@ 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. 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..7645613e 100644 --- a/benchmarks/test_codspeed_kernels.py +++ b/benchmarks/test_codspeed_kernels.py @@ -67,7 +67,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() @@ -910,7 +910,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 +919,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 +943,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 +953,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..f5caaab4 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -30,9 +30,11 @@ 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`. ## 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 → `<linearGradient>`, 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..ca1c2018 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 @@ -74,6 +74,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 +111,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 +175,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: @@ -258,6 +282,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 +393,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": <rx.vstack ...>, "tooltip": <rx.box ...>} +# {"legend": <rx.vstack ...>, "tooltip": <rx.box ...>, "colorbar": <rx.vstack ...>} ``` The render objects are not serialized into standalone HTML. `show=False` @@ -656,6 +685,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..38a76c7f 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,59 @@ 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 | `color`, `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| area, error band | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; area also supports `stroke-dasharray` | +| scatter | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `opacity` | +| histogram, bar, column | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `border-radius`, `opacity` | +| segments, error bars, contour, stem | `color`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | +| box, violin | `color`, `fill`, `fill-opacity`, `opacity` | +| triangle mesh | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `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. + +### 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. + ## Slot reference Every element below is rendered with `data-fc-slot="<slot>"`, so @@ -53,6 +111,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 +267,16 @@ 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}`. ### Scatter markers — `symbol`, `stroke`, `stroke_width` @@ -220,26 +285,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 +311,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 +336,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,13 +361,16 @@ 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. 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 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..ac470836 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; 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..81bc9806 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -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), @@ -1177,20 +1182,19 @@ 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, 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).""" return export.to_png( self, path, @@ -1199,7 +1203,6 @@ def to_png( scale=scale, engine=engine, optimize=optimize, - chromium=chromium, 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..dd739ff5 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -105,6 +105,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.""" @@ -525,17 +533,33 @@ 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)), + ) + 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)), + ) for palette_i, t in enumerate(spec["traces"]): style = t.get("style") or {} @@ -566,42 +590,125 @@ 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)), + ) - text_c = _parse_color(_TEXT) + 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 + + 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("axis_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("axis_color"), default_axis)), + ) + + text_c = _parse_color(default_text) if not hide_x_labels: + x_tick_c = _parse_color(_css(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_size", 11)), + x_tick_c, + _tick_text(xa, v, xstep), + ) if not hide_y_labels: + y_tick_c = _parse_color(_css(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_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 +729,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 +844,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 +858,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 +868,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 +888,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 +915,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 +926,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 +954,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 +990,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 +1006,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 +1036,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 +1055,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 +1085,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 +1111,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 +1137,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 +1145,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 +1167,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 +1186,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..7ffcbbe2 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -27,6 +27,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)], @@ -547,6 +558,22 @@ 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 = (100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 1.0) + 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.""" @@ -864,7 +891,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 +909,9 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list px = float(sx(v)) grid.append( f'<line x1="{_num(px)}" y1="{_num(plot["y"])}" x2="{_num(px)}" ' - f'y2="{_num(plot["y"] + plot["h"])}" stroke="{grid_color}"/>' + 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)))}"/>' ) for v in yt: if hide_y: @@ -887,20 +919,39 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list py = float(sy(v)) grid.append( f'<line x1="{_num(plot["x"])}" y1="{_num(py)}" x2="{_num(plot["x"] + plot["w"])}" ' - f'y2="{_num(py)}" stroke="{grid_color}"/>' + f'y2="{_num(py)}" stroke="{escape(_css(ystyle.get("grid_color"), default_grid))}" ' + f'stroke-width="{_num(float(ystyle.get("grid_width", 1)))}"/>' ) 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'<text x="{_num(float(sx(v)))}" y="{_num(tick_y)}" ' - f'text-anchor="middle">{escape(_tick_text(xa, v, xstep))}</text>' + f'fill="{escape(_css(xstyle.get("tick_color"), default_text))}" ' + f'font-size="{_num(float(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))}</text>" ) if not hide_y_labels: for v in ylab: labels.append( f'<text x="{_num(plot["x"] - 8)}" y="{_num(float(sy(v)) + 4)}" ' - f'text-anchor="end">{escape(_tick_text(ya, v, ystep))}</text>' + f'fill="{escape(_css(ystyle.get("tick_color"), default_text))}" ' + f'font-size="{_num(float(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))}</text>" ) # -- marks -------------------------------------------------------------- @@ -909,7 +960,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 +1000,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'<path d="{joined}" fill="{fill}" fill-opacity="{_num(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 = style.get("line_color") or color outline_path = joined if style.get("stroke_perimeter") else top_path marks.append( @@ -992,19 +1043,21 @@ def line_attrs(style: dict[str, Any], color: str) -> str: chrome.append( f'<text x="{_num(width / 2)}" y="{_num(plot["y"] - (10 if compact else 12))}" ' f'text-anchor="middle" font-size="14" font-weight="600" ' - f'fill="{_TEXT}">{escape(str(spec["title"]))}</text>' + f'fill="{escape(default_text)}">{escape(str(spec["title"]))}</text>' ) if xa.get("label") and not hide_x: chrome.append( f'<text x="{_num(plot["x"] + plot["w"] / 2)}" y="{_num(plot["y"] + plot["h"] + 34)}" ' - f'text-anchor="middle" font-size="12" font-weight="500" ' - f'fill="{_TEXT}">{escape(str(xa["label"]))}</text>' + 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']))}</text>" ) if ya.get("label") and not hide_y: cx, cy = 14, plot["y"] + plot["h"] / 2 chrome.append( - f'<text x="{_num(cx)}" y="{_num(cy)}" text-anchor="middle" font-size="12" ' - f'font-weight="500" fill="{_TEXT}" ' + f'<text x="{_num(cx)}" y="{_num(cy)}" text-anchor="middle" ' + f'font-size="{_num(float(ystyle.get("label_size", 12)))}" ' + f'font-weight="500" fill="{escape(_css(ystyle.get("label_color"), default_text))}" ' f'transform="rotate(-90 {_num(cx)} {_num(cy)})">{escape(str(ya["label"]))}</text>' ) named = [t for t in spec["traces"] if t.get("name")] @@ -1029,7 +1082,9 @@ def line_attrs(style: dict[str, Any], color: str) -> str: if side in frame_sides: baselines += ( f'<line x1="{_num(x)}" y1="{_num(plot["y"])}" x2="{_num(x)}" ' - f'y2="{_num(plot["y"] + plot["h"])}" stroke="{_AXIS}"/>' + 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 +1092,52 @@ def line_attrs(style: dict[str, Any], color: str) -> str: baselines += ( f'<line x1="{_num(plot["x"])}" y1="{_num(y)}" ' f'x2="{_num(plot["x"] + plot["w"])}" y2="{_num(y)}" ' - f'stroke="{_AXIS}"/>' + 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'<line x1="{_num(x)}" y1="{_num(y1)}" x2="{_num(x)}" y2="{_num(y2)}" ' + f'stroke="{escape(_css(xstyle.get("axis_color"), default_axis))}" ' + f'stroke-width="{_num(tick_width)}"/>' + ) + 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'<line x1="{_num(x1)}" y1="{_num(y)}" x2="{_num(x2)}" y2="{_num(y)}" ' + f'stroke="{escape(_css(ystyle.get("axis_color"), default_axis))}" ' + f'stroke-width="{_num(tick_width)}"/>' + ) + clip_id = svg.uid("clip") svg.defs.append( f'<clipPath id="{clip_id}"><rect x="{_num(plot["x"])}" y="{_num(plot["y"])}" ' @@ -1053,7 +1151,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: f"<g>{''.join(grid)}</g>" f'<g clip-path="url(#{clip_id})">{"".join(marks)}</g>' f"{baselines}" - f'<g fill="{_TEXT}">{"".join(labels)}</g>' + f'<g fill="{escape(default_text)}">{"".join(labels)}</g>' f"{''.join(chrome)}" f"</svg>" ) @@ -1165,7 +1263,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 +1316,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 +1329,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'<g fill-opacity="{_num(op)}" stroke-opacity="{_num(op)}">'] if op < 1 else ["<g>"] + 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"<g{attrs}>"] 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 +1376,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 +1402,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 +1521,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 +1542,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..54282ed4 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, @@ -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,8 @@ 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, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -2014,7 +2056,6 @@ def to_png( scale=scale, engine=engine, optimize=optimize, - chromium=chromium, sandbox=sandbox, gl=gl, ) @@ -2108,6 +2149,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 +2355,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 +2531,8 @@ 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, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -2496,7 +2541,6 @@ def to_png( scale=scale, engine=engine, optimize=optimize, - chromium=chromium, sandbox=sandbox, gl=gl, ) @@ -2606,6 +2650,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 +2669,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 +2689,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 +2704,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 +2721,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 +2738,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 +2758,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 +2772,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 +2787,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 +2804,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 +2817,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 +2835,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 +2852,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 +2871,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 +2888,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 +2906,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 +2919,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 +2941,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 +2963,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..92f7ff72 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,19 +510,19 @@ def write_images( paths: list[str | PathLike[str]], *, scale: float = 2.0, - engine: str = "chromium", - chromium: Optional[str] = None, + engine: Engine = Engine.default, 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`.""" @@ -446,19 +531,17 @@ def write_images( 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": 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) + 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 @@ -483,22 +566,24 @@ 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, 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`). 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 +595,21 @@ 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": 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) + 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..73ae23a1 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -253,23 +253,22 @@ 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, sandbox: bool = True, ) -> 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.width, # Match the actual HTML height: panels + gaps + title strip. self.grid_height + self._title_height, scale=scale, - chromium=chromium, sandbox=sandbox, ) - elif engine == "native": + elif resolved_engine == "native": 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 +301,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..668075af 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; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index ad830787..21180b97 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; diff --git a/python/xy/styles.py b/python/xy/styles.py new file mode 100644 index 00000000..0e2dba47 --- /dev/null +++ b/python/xy/styles.py @@ -0,0 +1,276 @@ +"""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"}) + +_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 |= {"color", "stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} + elif kind in _SIMPLE_STROKE_KINDS: + props |= {"color", "stroke", "stroke-width", "stroke-opacity"} + elif kind in _AREA_KINDS: + props |= { + "color", + "fill", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + } + if kind == "error_band": + props.discard("stroke-dasharray") + elif kind in _POINT_KINDS: + props |= { + "color", + "fill", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-opacity", + } + elif kind in _RECT_KINDS: + props |= { + "color", + "fill", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-opacity", + "border-radius", + } + elif kind in _FILL_KINDS: + props |= {"color", "fill", "fill-opacity"} + elif kind in _MESH_KINDS: + props |= { + "color", + "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 in {"fill", "color"}: + 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 _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_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/test_batch_export.py b/tests/test_batch_export.py index 6998eb6e..6ec4d6a6 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,16 @@ 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") + + +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", + ) diff --git a/tests/test_components.py b/tests/test_components.py index 731c7595..1b7584f0 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"] @@ -1535,9 +1537,8 @@ def fake_to_png( width=None, height=None, scale=2.0, - engine="native", + engine=fc.Engine.default, optimize=False, - chromium=None, sandbox=True, gl="software", ): @@ -1550,7 +1551,6 @@ def fake_to_png( "scale": scale, "engine": engine, "optimize": optimize, - "chromium": chromium, "sandbox": sandbox, "gl": gl, } @@ -1564,9 +1564,8 @@ def fake_to_png( width=320, height=200, scale=1.5, - engine="chromium", + engine=fc.Engine.chromium, optimize=True, - chromium="/chrome", sandbox=False, gl="hardware", ) @@ -1578,9 +1577,8 @@ def fake_to_png( "width": 320, "height": 200, "scale": 1.5, - "engine": "chromium", + "engine": fc.Engine.chromium, "optimize": True, - "chromium": "/chrome", "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..da50dc24 --- /dev/null +++ b/tests/test_css_mark_styles.py @@ -0,0 +1,215 @@ +"""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 +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_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, + "axis_color": "#0000ff", + "axis_width": 2, + "tick_length": 6, + "tick_width": 2, + "tick_color": "#00aa00", + "tick_size": 13, + "label_color": "#aa00aa", + "label_size": 15, + }, + ), + ).figure() + + svg = fig.to_svg() + assert 'stroke="#ff0000" stroke-width="3"' in svg + assert 'stroke="#0000ff" stroke-width="2"' in svg + assert 'fill="#00aa00" 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_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_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_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_figure.py b/tests/test_figure.py index efe86136..0ee1df5d 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,107 @@ 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)) + + 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="Chromium"): - fig.to_png(engine="chromium") + 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_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 +1918,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 +1940,12 @@ def fail_lookup(explicit=None): export.html_to_png("<!doctype html>", **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 +1965,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 "<line" in fig.to_svg() - assert fig.to_png(engine="native").startswith(b"\x89PNG") + assert fig.to_png(engine=fc.Engine.default).startswith(b"\x89PNG") def test_triangle_mesh_ships_per_triangle_color_and_renders_static_exports() -> 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("<polygon") == 2 - assert fig.to_png(engine="native").startswith(b"\x89PNG") + assert fig.to_png(engine=fc.Engine.default).startswith(b"\x89PNG") def test_triangle_mesh_filters_nonfinite_geometry_and_color_rows() -> 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..2d1e05f8 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,12 @@ 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"): args = get_args(chart_hints[key]) assert type(None) in args, key assert str in get_args(chart_hints["path"]) assert chart_hints["scale"] is float + assert chart_hints["engine"] is Engine assert chart_hints["sandbox"] is bool From 2927c4520d192332d5971af9143c945241e9351b Mon Sep 17 00:00:00 2001 From: Alek <alek@reflex.dev> Date: Tue, 14 Jul 2026 15:02:52 -0700 Subject: [PATCH 2/4] Close styling and export review gaps --- CHANGELOG.md | 15 +++++ README.md | 4 +- docs/api-examples.md | 4 +- docs/design/reflex-shaped-api.md | 9 +-- docs/styling.md | 32 ++++++---- python/xy/_figure.py | 5 +- python/xy/_raster.py | 2 + python/xy/_svg.py | 105 +++++++++++++++++++++++++++++-- python/xy/components.py | 4 ++ python/xy/export.py | 19 ++++-- python/xy/facets.py | 7 ++- python/xy/styles.py | 12 ++-- tests/test_batch_export.py | 42 +++++++++++++ tests/test_components.py | 4 ++ tests/test_css_mark_styles.py | 38 +++++++++++ tests/test_facets.py | 12 ++++ tests/test_figure.py | 27 ++++++++ tests/test_type_surface.py | 3 +- 18 files changed, 306 insertions(+), 38 deletions(-) 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 ecbe9d6d..911fa8d3 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,9 @@ 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. Legacy string engine values remain deprecated +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/docs/api-examples.md b/docs/api-examples.md index f5caaab4..18af600a 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -34,7 +34,9 @@ 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`. +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/design/reflex-shaped-api.md b/docs/design/reflex-shaped-api.md index ca1c2018..eea130b3 100644 --- a/docs/design/reflex-shaped-api.md +++ b/docs/design/reflex-shaped-api.md @@ -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 @@ -207,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() ``` @@ -456,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 | @@ -565,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 diff --git a/docs/styling.md b/docs/styling.md index 38a76c7f..a90ac6b3 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -74,17 +74,21 @@ fc.bar( | Mark family | Supported CSS properties | | --- | --- | -| line, step, stairs, ECDF | `color`, `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | -| area, error band | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; area also supports `stroke-dasharray` | -| scatter | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `opacity` | -| histogram, bar, column | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `border-radius`, `opacity` | -| segments, error bars, contour, stem | `color`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | -| box, violin | `color`, `fill`, `fill-opacity`, `opacity` | -| triangle mesh | `color`, `fill`, `fill-opacity`, `stroke`, `stroke-width`, `opacity` | +| 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 @@ -370,11 +374,17 @@ so small labels are slightly less refined than a browser's. 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. Legacy string engine values remain deprecated aliases. +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/python/xy/_figure.py b/python/xy/_figure.py index 81bc9806..90c02429 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1184,6 +1184,7 @@ def to_png( scale: float = 2.0, engine: export.Engine = export.Engine.default, optimize: bool = False, + custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -1194,7 +1195,8 @@ def to_png( 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).""" + "hardware" (real GPU). `custom_css` is Chromium-only and injects an + author stylesheet into the captured document.""" return export.to_png( self, path, @@ -1203,6 +1205,7 @@ def to_png( scale=scale, engine=engine, optimize=optimize, + custom_css=custom_css, sandbox=sandbox, gl=gl, ) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index dd739ff5..702c3c8c 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -28,6 +28,7 @@ _corner_radii, _css, _lut, + _resolve_static_css_vars, _Scale, _step_arrays, _tick_text, @@ -505,6 +506,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"]) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 7ffcbbe2..84a22359 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 @@ -575,14 +577,106 @@ def _paint_rgba8(css: Any) -> tuple[int, int, int, int]: 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(".") @@ -877,6 +971,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"]) diff --git a/python/xy/components.py b/python/xy/components.py index 54282ed4..0adff31b 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -2046,6 +2046,7 @@ def to_png( scale: float = 2.0, engine: export.Engine = export.Engine.default, optimize: bool = False, + custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -2056,6 +2057,7 @@ def to_png( scale=scale, engine=engine, optimize=optimize, + custom_css=custom_css, sandbox=sandbox, gl=gl, ) @@ -2533,6 +2535,7 @@ def to_png( scale: float = 2.0, engine: export.Engine = export.Engine.default, optimize: bool = False, + custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -2541,6 +2544,7 @@ def to_png( scale=scale, engine=engine, optimize=optimize, + custom_css=custom_css, sandbox=sandbox, gl=gl, ) diff --git a/python/xy/export.py b/python/xy/export.py index 92f7ff72..6f55e654 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -511,6 +511,7 @@ def write_images( *, scale: float = 2.0, engine: Engine = Engine.default, + custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> list[bytes]: @@ -525,7 +526,8 @@ def write_images( 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") @@ -533,10 +535,13 @@ def write_images( gl = _gl_option(gl) 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=Engine.default) for fig, path in zip(figs, paths, strict=True) ] + _custom_css_block(custom_css) exe = find_browser() if exe is None: raise RuntimeError( @@ -552,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) @@ -568,6 +573,7 @@ def to_png( scale: float = 2.0, engine: Engine = Engine.default, optimize: bool = False, + custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> bytes: @@ -580,8 +586,9 @@ def to_png( 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`). Former string engine values - remain deprecated aliases. + 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( @@ -597,11 +604,13 @@ def to_png( sandbox = _bool_option(sandbox, "PNG sandbox") 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) + doc = to_html(fig, custom_css=custom_css) data = html_to_png( doc, w, diff --git a/python/xy/facets.py b/python/xy/facets.py index 73ae23a1..2f5d0569 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -255,20 +255,25 @@ def to_png( scale: float = 2.0, engine: export.Engine = export.Engine.default, optimize: bool = False, + custom_css: Optional[str] = None, sandbox: bool = True, + gl: str = "software", ) -> bytes: optimize = export._bool_option(optimize, "facet PNG optimize") 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, sandbox=sandbox, + gl=gl, ) 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] = [] diff --git a/python/xy/styles.py b/python/xy/styles.py index 0e2dba47..df6a9ffe 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -151,12 +151,11 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: raise ValueError(f"unknown mark kind {kind!r}; expected one of {_MARK_KINDS}") props = {"opacity"} if kind in _LINE_KINDS: - props |= {"color", "stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} + props |= {"stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} elif kind in _SIMPLE_STROKE_KINDS: - props |= {"color", "stroke", "stroke-width", "stroke-opacity"} + props |= {"stroke", "stroke-width", "stroke-opacity"} elif kind in _AREA_KINDS: props |= { - "color", "fill", "fill-opacity", "stroke", @@ -168,7 +167,6 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: props.discard("stroke-dasharray") elif kind in _POINT_KINDS: props |= { - "color", "fill", "fill-opacity", "stroke", @@ -177,7 +175,6 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: } elif kind in _RECT_KINDS: props |= { - "color", "fill", "fill-opacity", "stroke", @@ -186,10 +183,9 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: "border-radius", } elif kind in _FILL_KINDS: - props |= {"color", "fill", "fill-opacity"} + props |= {"fill", "fill-opacity"} elif kind in _MESH_KINDS: props |= { - "color", "fill", "fill-opacity", "stroke", @@ -237,7 +233,7 @@ def compile_mark_style( prop, seen, ) - elif prop in {"fill", "color"}: + 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}") diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py index 6ec4d6a6..ff0cf335 100644 --- a/tests/test_batch_export.py +++ b/tests/test_batch_export.py @@ -40,6 +40,12 @@ 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): @@ -53,3 +59,39 @@ def test_write_images_chromium_engine_is_deprecated_alias(tmp_path, monkeypatch) [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"<style>{css}</style>" in seen[0][0] + assert seen[0][1] == 2.0 diff --git a/tests/test_components.py b/tests/test_components.py index 1b7584f0..dc388883 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -1539,6 +1539,7 @@ def fake_to_png( scale=2.0, engine=fc.Engine.default, optimize=False, + custom_css=None, sandbox=True, gl="software", ): @@ -1551,6 +1552,7 @@ def fake_to_png( "scale": scale, "engine": engine, "optimize": optimize, + "custom_css": custom_css, "sandbox": sandbox, "gl": gl, } @@ -1566,6 +1568,7 @@ def fake_to_png( scale=1.5, engine=fc.Engine.chromium, optimize=True, + custom_css=".chart { color: rebeccapurple; }", sandbox=False, gl="hardware", ) @@ -1579,6 +1582,7 @@ def fake_to_png( "scale": 1.5, "engine": fc.Engine.chromium, "optimize": True, + "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 index da50dc24..6bc21105 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -81,6 +81,27 @@ def test_css_style_wins_over_legacy_appearance_aliases() -> None: 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( @@ -186,6 +207,23 @@ def test_css_variables_remain_reflex_owned_dom_values() -> None: 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( 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 0ee1df5d..debe1a92 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -1893,6 +1893,33 @@ def fake_html_to_png(_html, _width, _height, **kwargs): 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"<style>{css}</style>" 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_browser_lookup(monkeypatch): from xy import export diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index 2d1e05f8..91ad3cda 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -409,10 +409,11 @@ 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"): + 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 From 6797b640787b7a196582a0cdf136707c91094e34 Mon Sep 17 00:00:00 2001 From: Alek <alek@reflex.dev> Date: Tue, 14 Jul 2026 15:31:47 -0700 Subject: [PATCH 3/4] Address styling review follow-ups --- docs/styling.md | 41 ++++++++++++++++++++++++ js/src/50_chartview.js | 44 +++++++++++++++++++++----- python/xy/_figure.py | 6 ++-- python/xy/_raster.py | 36 ++++++++++++++------- python/xy/_svg.py | 35 ++++++++++++++------ python/xy/components.py | 4 +-- python/xy/static/index.js | 43 ++++++++++++++++++++----- python/xy/static/standalone.js | 43 ++++++++++++++++++++----- python/xy/styles.py | 58 ++++++++++++++++++++++++++++++++++ tests/test_components.py | 20 +++++++++++- tests/test_css_mark_styles.py | 58 ++++++++++++++++++++++++++++++++-- tests/test_css_validation.py | 4 +-- 12 files changed, 337 insertions(+), 55 deletions(-) diff --git a/docs/styling.md b/docs/styling.md index a90ac6b3..3843d4a8 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -98,6 +98,42 @@ 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="<slot>"`, so @@ -282,6 +318,11 @@ stays proportional), and the antialiased corner/stroke coverage. `area` also has 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` `scatter` markers take a `symbol` — `circle` (default), `square`, `diamond`, diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index ac470836..fe38c492 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -2543,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 @@ -2612,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; @@ -2729,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); @@ -2741,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); @@ -2750,6 +2765,8 @@ class ChartView { ctx.lineTo(p.x + p.w, y); } ctx.stroke(); + ctx.globalAlpha = 1; + ctx.setLineDash([]); this._drawAnnotationShapes(ctx); @@ -2759,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); }; @@ -2803,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) { @@ -2814,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"); } } } @@ -2826,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. @@ -2852,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/_figure.py b/python/xy/_figure.py index 90c02429..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"] @@ -961,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": diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 702c3c8c..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, @@ -83,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() @@ -92,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)), @@ -553,14 +555,22 @@ def render_raster( cmd.stroke( [(gx, py0), (gx, py1)], float(xstyle.get("grid_width", 1)), - _parse_color(_css(xstyle.get("grid_color"), default_grid)), + _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)], float(ystyle.get("grid_width", 1)), - _parse_color(_css(ystyle.get("grid_color"), default_grid)), + _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"]): @@ -647,7 +657,7 @@ def tick_span(style): cmd.stroke( [(x, y0), (x, y1)], float(xstyle.get("tick_width", 1)), - _parse_color(_css(xstyle.get("axis_color"), default_axis)), + _parse_color(_css(xstyle.get("tick_color"), default_axis)), ) if not hide_y: inward, outward = tick_span(ystyle) @@ -663,30 +673,34 @@ def tick_span(style): cmd.stroke( [(x0, y), (x1, y)], float(ystyle.get("tick_width", 1)), - _parse_color(_css(ystyle.get("axis_color"), default_axis)), + _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_color"), default_text)) + 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, - float(xstyle.get("tick_size", 11)), + 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_color"), default_text)) + 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, - float(ystyle.get("tick_size", 11)), + float(ystyle.get("tick_label_size", ystyle.get("tick_size", 11))), y_tick_c, _tick_text(ya, v, ystep), ) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 84a22359..62e6892c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -298,6 +298,13 @@ def _stroke_opacity(style: dict[str, Any], default: float = 1.0) -> float: _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], +} # --------------------------------------------------------------------------- @@ -566,7 +573,7 @@ def _paint_rgba8(css: Any) -> tuple[int, int, int, int]: _status, rgba = kernels.css_check(kernels.CSS_COLOR, str(css)) if rgba is None: - rgba = (100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 1.0) + rgba = _STATIC_COLOR_FALLBACK red, green, blue, alpha = rgba return ( int(round(red * 255)), @@ -681,6 +688,14 @@ 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 @@ -1006,7 +1021,8 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list f'<line x1="{_num(px)}" y1="{_num(plot["y"])}" x2="{_num(px)}" ' 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'stroke-width="{_num(float(xstyle.get("grid_width", 1)))}"' + f"{_axis_grid_attrs(xstyle)}/>" ) for v in yt: if hide_y: @@ -1015,15 +1031,16 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list grid.append( f'<line x1="{_num(plot["x"])}" y1="{_num(py)}" x2="{_num(plot["x"] + plot["w"])}" ' f'y2="{_num(py)}" stroke="{escape(_css(ystyle.get("grid_color"), default_grid))}" ' - f'stroke-width="{_num(float(ystyle.get("grid_width", 1)))}"/>' + 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'<text x="{_num(float(sx(v)))}" y="{_num(tick_y)}" ' - f'fill="{escape(_css(xstyle.get("tick_color"), default_text))}" ' - f'font-size="{_num(float(xstyle.get("tick_size", 11)))}" ' + 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"]))} ' @@ -1037,8 +1054,8 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list for v in ylab: labels.append( f'<text x="{_num(plot["x"] - 8)}" y="{_num(float(sy(v)) + 4)}" ' - f'fill="{escape(_css(ystyle.get("tick_color"), default_text))}" ' - f'font-size="{_num(float(ystyle.get("tick_size", 11)))}" ' + 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"]))} ' @@ -1213,7 +1230,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: ) baselines += ( f'<line x1="{_num(x)}" y1="{_num(y1)}" x2="{_num(x)}" y2="{_num(y2)}" ' - f'stroke="{escape(_css(xstyle.get("axis_color"), default_axis))}" ' + f'stroke="{escape(_css(xstyle.get("tick_color"), default_axis))}" ' f'stroke-width="{_num(tick_width)}"/>' ) if not hide_y: @@ -1229,7 +1246,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: ) baselines += ( f'<line x1="{_num(x1)}" y1="{_num(y)}" x2="{_num(x2)}" y2="{_num(y)}" ' - f'stroke="{escape(_css(ystyle.get("axis_color"), default_axis))}" ' + f'stroke="{escape(_css(ystyle.get("tick_color"), default_axis))}" ' f'stroke-width="{_num(tick_width)}"/>' ) diff --git a/python/xy/components.py b/python/xy/components.py index 0adff31b..d2fb0d95 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -1457,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"), ) @@ -1508,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"), ) diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 668075af..a51bfcff 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -3871,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 @@ -3933,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; @@ -4034,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); @@ -4045,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); @@ -4054,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); }; @@ -4099,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) { @@ -4110,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"); } } } @@ -4121,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)};`; @@ -4144,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 21180b97..46b2f1ff 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -3872,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 @@ -3934,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; @@ -4035,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); @@ -4046,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); @@ -4055,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); }; @@ -4100,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) { @@ -4111,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"); } } } @@ -4122,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)};`; @@ -4145,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 index df6a9ffe..797a678e 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -37,6 +37,15 @@ _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 @@ -257,6 +266,54 @@ def compile_mark_style( 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 { @@ -267,6 +324,7 @@ def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]: __all__ = [ "StyleMapping", "StyleValue", + "compile_axis_style", "compile_mark_style", "normalize_css_style", ] diff --git a/tests/test_components.py b/tests/test_components.py index dc388883..1d247687 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -658,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, diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 6bc21105..3f79d0b8 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -6,7 +6,7 @@ import pytest import xy as fc -from xy import _raster +from xy import _raster, _svg from xy._figure import Figure from xy.styles import compile_mark_style, normalize_css_style @@ -167,11 +167,14 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: 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, @@ -180,13 +183,62 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: ).figure() svg = fig.to_svg() - assert 'stroke="#ff0000" stroke-width="3"' in 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 'fill="#00aa00" font-size="13" text-anchor="middle"' 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"): 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) From 0b098c2e92a5359b54f5407e1d02688de1b9d288 Mon Sep 17 00:00:00 2001 From: Alek <alek@reflex.dev> Date: Tue, 14 Jul 2026 17:23:44 -0700 Subject: [PATCH 4/4] Stabilize CI performance guardrails --- benchmarks/test_codspeed_kernels.py | 9 +++++++-- tests/pyplot/test_perf_guardrail.py | 14 ++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/benchmarks/test_codspeed_kernels.py b/benchmarks/test_codspeed_kernels.py index 7645613e..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 @@ -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): 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: