diff --git a/README.md b/README.md index e92ba736..2bfcd053 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,11 @@ methodology, and raw results. Customize marks and chart chrome with Python, CSS, or Tailwind. See the [styling guide](docs/styling/index.md). +What each mechanism reaches — per property, per chrome slot, per renderer, and +where it stops — is the [capability matrix](spec/api/capability-matrix.md), +generated from `python/xy/styling/capabilities.py` and checked against the +implementation. + ```python chart = xy.line_chart( xy.line(x, y, color="#7c3aed", width=3), diff --git a/docs/advanced/custom-marks.md b/docs/advanced/custom-marks.md new file mode 100644 index 00000000..905ef335 --- /dev/null +++ b/docs/advanced/custom-marks.md @@ -0,0 +1,108 @@ +--- +title: Custom Marks +description: Add a chart kind XY does not ship by composing its built-in marks, without forking the renderer. +--- + +# Custom Marks + +XY ships twenty mark kinds. When you need one it does not have — a candlestick, +a high-low band, a ribbon, a dumbbell — you can register it instead of waiting +for it or forking the renderer. + +A mark plugin is two functions and a name: + +- **`calc`** turns your input columns into the columns you want to draw. It runs + once, on arrays, before anything is built. +- **`build`** returns ordinary XY marks. Not shaders, not draw calls — the same + `xy.segments(...)`, `xy.scatter(...)`, `xy.line(...)` you would write by hand. + +That second constraint is the point rather than a limitation. Because a plugin's +output is ordinary traces, it reuses the built-in rendering, picking, and export +paths — including native PNG and SVG, which have no browser — rather than +reimplementing them. + +## A worked example + +~~~python +import numpy as np +import xy + + +def _calc(columns): + """Columns in, columns out. Add whatever `build` needs to draw.""" + return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0} + + +def _build(ctx): + """Return built-in marks. `ctx.columns` is `_calc`'s output.""" + return [ + xy.segments( + x0=ctx.columns["t"], + x1=ctx.columns["t"], + y0=ctx.columns["low"], + y1=ctx.columns["high"], + name=ctx.name, + style=ctx.style, + ), + xy.scatter( + x=ctx.columns["t"], + y=ctx.columns["mid"], + size=ctx.options.get("mid_size", 6), + ), + ] + + +xy.register_mark( + xy.MarkPlugin( + name="hilo", + columns=("t", "low", "high"), + calc=_calc, + build=_build, + doc="A high-low band with a midpoint marker.", + ) +) +~~~ + +Use it like any other mark: + +~~~python +chart = xy.chart( + xy.mark("hilo", t="day", low="low", high="high", data=frame, name="Range"), + xy.y_axis(label="price"), +) +~~~ + +Fields you named in `columns` behave exactly like a built-in mark's `x` and `y`: +a string names a column in `data=`, anything else is used as values directly, +and they reach `calc` as arrays. Every other keyword — `mid_size` above — arrives +in `ctx.options` untouched. + +## What a plugin can and cannot do + +| Can | Cannot | +| --- | --- | +| Compute new columns from its inputs | Reach the `Figure`, the trace list, or the column store | +| Emit any number of built-in marks | Emit another plugin's mark (composition is one level deep) | +| Read the caller's `style`, `name`, and options | Ship its own GLSL or WGSL | +| Draw on a named axis via `xy.mark(..., y_axis="y2")` | Add a new GPU primitive | + +The last two are the real boundary, and it is deliberate rather than temporary +scaffolding. A plugin that composes built-in marks cannot draw anything the +engine could not already draw, which is what lets it reuse the engine's +existing paths. A plugin carrying its own shader would reuse none of them and +would have to reimplement decimation, picking, and three export paths itself. +See [§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md). + +## Registry rules + +`register_mark` refuses two things outright, both because the alternative is a +bug someone debugs at runtime: + +- **Shadowing a built-in.** `xy.register_mark(MarkPlugin(name="scatter", ...))` + raises. A plugin cannot change what `xy.scatter` means. +- **Silently replacing another plugin.** Two libraries registering + `"candlestick"` is a conflict their user needs to see, not a race that import + order settles. Pass `replace=True` when that is genuinely what you want. + +`xy.registered_marks()` lists what is contributed from outside; +`xy.unregister_mark(name)` removes one, which is mostly useful in tests. diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 9b02faf8..07d76c35 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -53,13 +53,33 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. ## Styling and Export Boundaries +The per-renderer inventory of what can be styled, and how far each mechanism +travels, is the [Capability Matrix](/docs/xy/styling/capabilities/) — generated +from `python/xy/styling/capabilities.py` and checked against the +implementation. The bullets below are the boundaries that page's rows imply. + - Browser chrome accepts CSS and Tailwind classes through stable DOM slots. WebGL/native marks accept a validated CSS subset through `style=`; arbitrary selectors do not paint mark geometry. - “Your styles win” applies to themeable browser chrome defaults, not every structural layout rule, mark renderer, annotation shape, or native export. -- Native PNG cannot apply author `custom_css`. Use renderable chart/mark styles - or `Engine.chromium` for browser CSS fidelity. +- **Styling does not survive every export path equally, and the boundary is + published rather than left to be discovered.** Mark, axis, and chart-level + `style=` reach all three renderers. Per-slot `styles={slot: {...}}` and + `class_names={slot: "..."}` are **browser-only and are dropped, silently, by + the native raster, SVG, and PDF writers** — raising instead would break every + native export of a chart that carries Tailwind classes for its live view, so + the behavior is contracted and tested instead. `xy.legend(style=...)` is the + one slot with a partial native channel (six keys); `xy.colorbar(style=...)` + has none. The full matrix is + [Static export §9](https://github.com/reflex-dev/xy/blob/main/spec/api/export.md), + pinned by `tests/test_export_style_survival.py`. +- Native PNG cannot apply author `custom_css`, and neither can native SVG, PDF, + JPEG, or WebP. Unlike the per-slot case this one *raises* rather than + dropping: an author stylesheet has no honest partial application. Use + `Engine.chromium` for browser CSS fidelity — except for SVG, which rejects + `custom_css` under every engine because a browser screenshot cannot produce + vector output. - Declarative `colorbar()` derives built-in chrome from supported continuous marks. It intentionally omits constant/categorical color, truecolor grids, and density scatter after that tier drops per-row color values. diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index cd96e971..8fbee70e 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -608,7 +608,7 @@ def test_what_is_xy_restores_the_sdf_hero_and_ends_with_a_short_pitch() -> None: content = (DOCS_ROOT / "index.md").read_text(encoding="utf-8") heading = content.index("# What is `xy`?") - styling = content.index("**Completely customizable.**") + styling = content.index("**Styled by your CSS, not ours.**") hero = content.index("~~~python demo-only exec") early_alpha = content.index("**Early alpha.**") start_here = content.index("## Start here") diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 36006525..199132c1 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -46,6 +46,7 @@ ("Customize Each Part", "/styling/customize/"), ("Animations", "/styling/animations/"), ("Themes and Export", "/styling/themes-and-tokens/"), + ("Capability Matrix", "/styling/capabilities/"), ("Advanced Styling Gallery", "/styling/gallery/"), ), ), @@ -132,6 +133,7 @@ "network", ( ("XY Architecture", "/advanced/"), + ("Custom Marks", "/advanced/custom-marks/"), ( "Runtime and Deployment", "/advanced/runtime-and-deployment/", diff --git a/docs/index.md b/docs/index.md index 5b446571..9bc60385 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,15 +6,18 @@ description: XY is a fast Python charting library for interactive data visualiza # What is `xy`? XY is a Python charting library for interactive 2D visualizations that stay -smooth at millions of points and is completely customizable. Two ideas shape -the library: +smooth at millions of points and take your design system seriously. Two ideas +shape the library: - **Fast, even with lots of data.** XY draws the detail you can actually see instead of every row at once, so pan, zoom, and hover stay responsive as your data grows. -- **Completely customizable.** Style titles, axes, legends, tooltips, and controls - with CSS or Tailwind, and keep the same look in interactive charts, SVGs, and - PNGs. +- **Styled by your CSS, not ours.** Titles, axes, legends, tooltips, and controls + are addressable with plain CSS or Tailwind through 23 stable slots, and your + design tokens reach the marks themselves — in the browser, in SVG, and in + native PNG. What each mechanism reaches is published per renderer in the + [Capability Matrix](/docs/xy/styling/capabilities/), including where it + stops. ~~~python demo-only exec diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md new file mode 100644 index 00000000..1eb417b2 --- /dev/null +++ b/docs/styling/capabilities.md @@ -0,0 +1,106 @@ +--- +title: Capability Matrix +description: What XY can be styled and extended with, per renderer, generated from the capability registry. +--- + +# Capability Matrix + + + +Every styling question about XY has the same two halves: *can I change this*, +and *does the change survive where I need it*. This page answers both from the +registry the implementation is checked against. + +- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **23** stable chrome slots for CSS and Tailwind in the browser. +- **1** way to add a mark kind XY does not ship, without forking it. + +## Mark style properties + +What a mark's `style=` accepts. Anything outside this raises while the chart is +built — one renderer never silently ignores what another draws. + +| property | vocabulary | mark kinds | webgl | svg | native | status | +|---|---|---|---|---|---|---| +| `opacity` | css | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `heatmap`, `hexbin`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `fill` | svg | `area`, `bar`, `box`, `column`, `error_band`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `heatmap`, `hexbin`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `stroke` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-opacity` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-width` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-dasharray` | svg | `area`, `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | +| `stroke-linecap` | svg | `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | +| `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | +| `marker-shape` | xy | `scatter` | full | full | full | shipped | + +### Notes + +- **`opacity`** — Multiplies the mark's own alpha in every renderer. +- **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program. +- **`fill-opacity`** — Independent of `opacity`; the two multiply. +- **`stroke`** — The paint for line-like geometry, and the border for filled marks. +- **`stroke-width`** — CSS px; a bare number is px, matching the chrome style convention. +- **`stroke-dasharray`** — 2-8 positive px lengths, or `none`. The WebGL client tracks arc length on the CPU so dashes stay continuous across segments and constant on screen through zoom. +- **`stroke-linecap`** — Line family only — a cap is open-path geometry. XY's default is `round`, not CSS's `butt`, because the native rasterizer has always drawn round and is the reference for static export. Verified per renderer: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. +- **`border-radius`** — Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately. +- **`marker-shape`** — 17 shapes, drawn as analytic signed-distance fields in all three renderers. An XY vocabulary name: CSS has no shape keyword for a non-DOM point mark, and the CSS spelling and `symbol=` compile to the same value. + +## Chrome slots + +Stable `data-xy-slot` names that take `class_names=` and `styles=`. The native +raster and vector writers have no cascade, so per-slot styling is a browser +mechanism; put anything that must survive export in the chart-level `style=` +token bag or in mark and axis `style=`, which every renderer reads. + +| slot | browser | native raster | native vector | +|---|---|---|---| +| `root` | full | partial | partial | +| `title` | full | none | none | +| `chrome` | full | none | none | +| `canvas` | full | none | none | +| `labels` | full | none | none | +| `legend` | full | partial | partial | +| `legend_item` | full | none | none | +| `legend_swatch` | full | none | none | +| `colorbar` | full | none | none | +| `colorbar_bar` | full | none | none | +| `colorbar_tick` | full | none | none | +| `colorbar_title` | full | none | none | +| `tooltip` | full | none | none | +| `modebar` | full | none | none | +| `modebar_button` | full | none | none | +| `selection` | full | none | none | +| `crosshair_x` | full | none | none | +| `crosshair_y` | full | none | none | +| `badge` | full | none | none | +| `badge_item` | full | none | none | +| `tick_label` | full | none | none | +| `axis_title` | full | none | none | +| `annotation_label` | full | none | none | + +### Notes + +- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. +- **`legend`** (via `xy.legend(style=...)`) — Written twice: to chrome_styles for the browser and to legend_options['style'], which `_svg` and `_raster` do read — but only `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em`. The chart-level styles={'legend': ...} spelling reaches none of it, so two spellings that agree in the browser disagree in a PNG. + +## Extension points + +See [Custom Marks](/docs/xy/advanced/custom-marks/) for the worked example. + +| extension point | status | entry point | limits | +|---|---|---|---| +| mark_plugin_composition | shipped | `xy.register_mark / xy.MarkPlugin / xy.mark` | composes built-in marks only, one level deep; cannot reach the Figure, the trace list, or the column store; cannot add a GPU primitive | +| mark_plugin_shader | planned | `—` | — | +| custom_renderer | planned | `—` | — | + +## Where renderers still disagree + +Differences in the defaults that no property selects. They are listed because +an undocumented difference reads as a bug; a documented one is a contract. + +| what | browser | svg | native png | visible when | +|---|---|---|---|---| +| Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | + +For what is still alpha, see +[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 41bc16a6..bbae4f95 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -240,6 +240,33 @@ XY rejects strings that could break out of that style element. The same option works for Chromium PNG capture; native PNG has no browser cascade and rejects `custom_css`. +### What survives which export + +Slot styling is a browser mechanism, and the native writers have no cascade to +apply it with. Rather than leave that to be discovered, it is a contract: + +| You wrote | Browser (HTML, widget, Chromium capture) | Native PNG/JPEG/WebP | Native SVG/PDF | +| --- | --- | --- | --- | +| mark / axis `style=` | yes | yes | yes | +| chart-level `style=` (design tokens) | yes | yes | yes | +| `styles={slot: {...}}` | yes, all 23 slots | dropped | dropped | +| `class_names={slot: "..."}` | yes, all 23 slots | dropped | dropped | +| `custom_css=` | yes | raises | raises | +| `xy.legend(style=...)` | yes | 6 keys | 6 keys | +| `xy.colorbar(style=...)` | yes | dropped | dropped | + +The two "dropped" rows are deliberate. Raising instead would break every native +export of a chart that carries Tailwind classes for its live view, which is the +normal way to use both surfaces together — so the behavior is contracted and +tested rather than enforced. `custom_css` raises because there is no honest +partial application of an author stylesheet, and the error names +`Engine.chromium` as the fix. + +A chart that must look identical on screen and in a PNG should carry its design +decisions in chart-level `style=` tokens and mark/axis `style=`, which every +renderer reads, and use slot classes only for things the browser alone shows — +tooltips, the modebar, hover chrome. + ## Cascade and structural layout Built-in visual rules live in the low-priority `base` cascade layer and use diff --git a/docs/styling/customize.md b/docs/styling/customize.md index dfc444a2..3e3dc3e4 100644 --- a/docs/styling/customize.md +++ b/docs/styling/customize.md @@ -95,7 +95,20 @@ def customize_mark_paint_preview(): Use `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, and `opacity` only on mark families that support them. Lines use stroke properties; areas, points, bars, and columns also support fill properties. Bar-like marks -add `border-radius`, while line-like marks add `stroke-dasharray`. +add `border-radius`. `line`, `step`, `stairs`, and `ecdf` add +`stroke-dasharray` and `stroke-linecap`; `area` adds `stroke-dasharray` only, +because a cap is open-path geometry. `scatter` adds `marker-shape`. + +`stroke-linecap` (`butt`, `round`, `square`) carries its standard SVG meaning, +and XY defaults it to `round` rather than to the CSS initial value — the native +rasterizer has always drawn round caps and it is the reference for static +export. `marker-shape` is the CSS spelling of `symbol=` and takes any of the 17 +built-in marker names. + +~~~python +xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) +xy.scatter(x, y, size=12, style={"marker-shape": "diamond"}) +~~~ ## Axes, grid, and ticks diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index 0750e5ea..266fb7c9 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -14,9 +14,9 @@ renderer cannot silently ignore a declaration that another honors. | Mark family | Supported properties in `style=` | | --- | --- | -| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `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` | +| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` | | `histogram`, `bar`, `column` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` | | `segments`, `errorbar`, `contour`, `stem` | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | `box`, `violin` | `fill`, `fill-opacity`, `opacity` | @@ -58,6 +58,36 @@ surfaces set the same rendered property. Inside `style`, use `stroke` for line-like geometry and `fill` for filled geometry; `color` is deliberately not a CSS paint alias there. +## Stroke geometry: line caps + +`stroke-linecap` (`butt`, `round`, `square`) shapes the two ends of a polyline +and each dash end. It is polyline geometry, so only the line family accepts it — +a `bar` or a `scatter` raises rather than accepting a declaration no renderer +can draw. + +XY defaults to `round`, **not** to the CSS initial value `butt`: the native +rasterizer has always drawn round caps and it is the reference for static +export. Set the property to opt into the CSS initial value. + +~~~python +xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) +~~~ + +Joins are always round and are not selectable. + +## Marker shape + +`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`, +`square`, `diamond`, `triangle`, `cross`, `hexagon`, `pentagon`, `star`, +`triangle_down`, `triangle_left`, `triangle_right`, `x`, `point`, `pixel`, +`thin_diamond`, `plus_line`, `x_line` — and is the CSS spelling of the existing +`symbol=` argument. It is an XY vocabulary name rather than a standard CSS +property: CSS has no shape keyword for a non-DOM point mark. + +~~~python +xy.scatter(x, y, size=12, style={"marker-shape": "diamond", "fill": "#22c55e"}) +~~~ + ## Combine mark styles This example combines the main paint paths in one chart: a gradient area, a diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 782e7f16..719076eb 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -469,16 +469,21 @@ void main() { outColor = vec4(rgb * u_opacity, u_opacity); }`; +// Polylines: one instanced quad per segment. `u_cap` is the compiled +// stroke-linecap value (see LINE_CAP_MODES); XY defaults it to round, which is +// what the native rasterizer's clamped segment distance field draws +// (src/raster.rs), so all three renderers agree. export const LINE_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float a_prevx; in float a_prevy; in float a_prevx1; in float a_prevy1; uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; uniform int u_colorMode; +uniform int u_cap; uniform int u_capSegments; uniform float u_transitionProgress; uniform int u_transitionActive; uniform float u_revealProgress; uniform float u_revealSegments; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; in float a_len0; in float a_len1; -out float v_off; out float v_dash; +out float v_off; out float v_dash; out vec2 v_cap; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -498,45 +503,74 @@ void main() { vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; float half_w = u_width * 0.5 + 0.5; - vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; + // Along-segment coordinate in px. Interior joints keep the 0.5px overlap that + // hides the seam between consecutive quads; the polyline's two outer ends + // instead grow by half_w when the cap reaches past them (round semicircle, + // square extension), giving LINE_FS room to shape it. + bool first = gl_InstanceID == 0; + bool last = gl_InstanceID == u_capSegments - 1; + float capExt = u_cap == 0 ? 0.5 : half_w; + float t = mix(first ? -capExt : -0.5, len + (last ? capExt : 0.5), c.x); + vec2 pos = pix0 + dir * t + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; + // Signed px overrun past the polyline's start/end, far negative where that + // end is an interior joint instead; LINE_FS takes the max of the two. + v_cap = vec2(first ? -t : -1e4, last ? t - len : -1e4); // Cumulative screen-space arc length at this fragment (device px), fed from // CPU-computed per-vertex lengths so dashes stay continuous across segments - // and constant on screen through zoom. - v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x); + // and constant on screen through zoom. Driven off t rather than c.x so the + // overhang carries the pattern on at 1px of arc per px of screen instead of + // stretching the segment's slice of it over the widened quad. + float dashEnd = mix(a_len0, a_len1, reveal); + v_dash = a_len0 + t * (len > 1e-3 ? (dashEnd - a_len0) / len : 1.0); }`; export const LINE_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; +uniform vec4 u_color; uniform float u_width; uniform int u_cap; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_dash; +in float v_off; in float v_dash; in vec2 v_cap; out vec4 outColor; void main() { float half_w = u_width * 0.5; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + // How far past the nearest end of painted stroke this fragment lies, along + // the path. Two kinds of end contribute and the cap shapes both: the + // polyline's own ends (v_cap) and, when dashed, the ends of the dash run — + // the distance to the nearer dash boundary is exactly the overrun a cap has + // to bridge, so the greater of the two is the one that governs. + float axial = max(v_cap.x, v_cap.y); if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; - float on = 0.0; + float sd = 0.0; for (int i = 0; i < 8; i++) { if (i >= u_dashCount) break; float next = acc + u_dashArr[i]; if (m < next) { - // 0.6px feather at each dash start/end so edges aren't aliased. float d = min(m - acc, next - m); - on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0); + sd = (i % 2 == 0) ? d : -d; // + inside an "on" run, - inside a gap break; } acc = next; } - alpha *= on; + axial = max(axial, -sd); } + // round = semicircle of radius half_w about the end, so the distance field + // picks up the overrun; butt = flush; square = flush pushed out by half_w. + float radial = u_cap == 1 ? length(vec2(max(axial, 0.0), v_off)) : abs(v_off); + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, radial)) * u_color.a; + if (u_cap == 0) alpha *= 1.0 - smoothstep(-0.5, 0.5, axial); + else if (u_cap == 2) alpha *= 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, axial); if (alpha <= 0.001) discard; outColor = vec4(u_color.rgb * alpha, alpha); }`; +// Wire spellings of stroke-linecap → the u_cap int LINE_FS switches on. A +// trace omits the key at XY's default (round), so an unset style must resolve +// to `round`, not to the CSS initial value `butt`. +export const LINE_CAP_MODES = { butt: 0, round: 1, square: 2 }; + // Segment marks (errorbar/stem/box whiskers/contour isolines): independent // endpoint pairs with per-column axis metas and an optional per-segment LUT // color. A separate program keeps the polyline path (LINE_VS/LINE_FS) free of diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 420ae75f..68f2a40d 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,7 +2,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; -import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -4031,7 +4031,12 @@ export class ChartView { const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1)); gl.uniform1f(u("u_revealProgress"), reveal); gl.uniform1f(u("u_revealSegments"), g.n - 1); - gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr); + const lineWidth = (width ?? g.trace.style.width ?? 1.5) * this.dpr; + gl.uniform1f(u("u_width"), lineWidth); + // Absent cap/join keys mean XY's default, which is round for both — the + // trace only carries them when they differ from it (marks._stroke_geometry). + const cap = LINE_CAP_MODES[g.trace.style.linecap] ?? LINE_CAP_MODES.round; + gl.uniform1i(u("u_cap"), cap); const [r, gg, b, a] = color || g.color; const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1) * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity); @@ -4060,6 +4065,7 @@ export class ChartView { } ); const segments = Math.max(0, Math.min(g.n - 1, Math.ceil((g.n - 1) * reveal))); + gl.uniform1i(u("u_capSegments"), segments); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, segments); } diff --git a/python/xy/__init__.py b/python/xy/__init__.py index b6f81c3d..de2b35cf 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -41,6 +41,8 @@ "Interaction": ".components", "Legend": ".components", "Mark": ".components", + "MarkContext": ".plugins", + "MarkPlugin": ".plugins", "Modebar": ".components", "Selection": "._figure", "Spring": ".components", @@ -73,6 +75,7 @@ "hexbin": ".components", "hexbin_chart": ".components", "heatmap": ".components", + "mark": ".components", "heatmap_chart": ".components", "hline": ".components", "hist": ".components", @@ -81,6 +84,9 @@ "interaction_config": ".components", "label": ".components", "legend": ".components", + "register_mark": ".plugins", + "registered_marks": ".plugins", + "unregister_mark": ".plugins", "line": ".components", "line_chart": ".components", "marker": ".components", @@ -129,6 +135,8 @@ "Interaction", "Legend", "Mark", + "MarkContext", + "MarkPlugin", "Modebar", "Selection", "Spring", @@ -172,8 +180,11 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", + "register_mark", + "registered_marks", "scatter", "scatter_chart", "segments", @@ -192,6 +203,7 @@ "tooltip", "triangle_mesh", "triangle_mesh_chart", + "unregister_mark", "violin", "violin_chart", "vline", diff --git a/python/xy/_native.py b/python/xy/_native.py index b9c07c41..c2cc5e31 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 41 +ABI_VERSION = 42 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 5733b54a..a0ccc3c4 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -80,6 +80,10 @@ # (right-margin titles, matplotlib rotation=270). _TEXT_ROT_CCW = 0x80 _TEXT_ROT_CW = 0x40 +# stroke-linecap — must match CAP_* in src/raster.rs. XY's default is round, +# which is the geometry the rasterizer's capsule distance field has always +# drawn. Joins are always round and carry no wire field. +_CAP_CODES = {"butt": 0, "round": 1, "square": 2} _SYMBOLS = { "circle": 0, "square": 1, @@ -223,6 +227,7 @@ def stroke( color: tuple[int, ...], closed: bool = False, dash: Sequence[float] | None = None, + cap: str = "round", ) -> None: if len(pts) < 2 or width <= 0: return @@ -242,6 +247,7 @@ def stroke( self._u32(len(dash)) for d in dash: self._f(d) + self.buf.append(_CAP_CODES[cap]) def point( self, @@ -489,6 +495,7 @@ def smooth_stroke( width: float, color: tuple[int, ...], dash: Sequence[float] | None = None, + cap: str = "round", ) -> None: """Native monotone-Hermite flattening + stroke for affine axes.""" n = len(xv) @@ -515,6 +522,7 @@ def smooth_stroke( self._u32(len(dash)) for value in dash: self._f(value) + self.buf.append(_CAP_CODES[cap]) def image( self, @@ -1052,11 +1060,17 @@ def _emit_line( xv, yv = _step_arrays(xv, yv, style["step"]) c = _rgba(style.get("color"), color, _stroke_opacity(style)) width = float(style.get("width", 1.5)) + # `render_raster` takes a plain spec dict, so a hand-built or round-tripped + # one can carry a value `compile_mark_style` would have rejected. Fall back + # to the documented default rather than raising a bare KeyError from inside + # the byte packer. + cap = str(style.get("linecap", "round")) + cap = cap if cap in _CAP_CODES else "round" 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")) + cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap) else: pts = _scene.curve_points(xv, yv, sx, sy, False) - cmd.stroke(pts, width, c, dash=style.get("dash")) + cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap) def _annotation_point( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 1227afa2..50ab8d28 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1076,6 +1076,23 @@ def _star_path(cx: float, cy: float, r: float, points: int, inner: float, start_ return f' str: + """Polyline stroke geometry, always written out rather than inherited. + + SVG's initial values are `butt`/`miter`; XY's are `round`/`round`, and the + trace only carries `linecap` when it differs (`marks._stroke_geometry`). + The join is not selectable, but it is still named on every stroked path: + leaving it out let the format's `miter` default through, and `_pdf` reads + these attributes straight back out of this markup, so an unnamed join meant + SVG and PDF disagreeing with the rasterizer for free. + """ + cap = style.get("linecap", "round") + attrs = f' stroke-linecap="{escape(str(cap))}"' + if join: + attrs = ' stroke-linejoin="round"' + attrs + return attrs + + def _dash_attr(style: dict[str, Any]) -> str: dash = style.get("dash") if not dash: @@ -1591,7 +1608,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: op = _stroke_opacity(style) return ( f'stroke="{escape(color)}" stroke-width="{_num(w)}" fill="none" ' - f'stroke-linejoin="round" stroke-linecap="round"' + + _cap_join_attrs(style) + (f' stroke-opacity="{_num(op)}"' if op < 1 else "") + _dash_attr(style) ) @@ -1640,7 +1657,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str: outline_path = joined if style.get("stroke_perimeter") else top_path marks.append( f'" diff --git a/python/xy/components.py b/python/xy/components.py index 21b82c63..689ed412 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -43,7 +43,7 @@ import numpy as np -from . import _validate, channels, export, styles +from . import _validate, channels, export, plugins, styles from ._figure import Figure, Selection from ._typing import ArrayLike, ColorLike, Scalar, TableLike from .dom import CHART_DOM_SLOTS, validate_dom_slots @@ -113,6 +113,7 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", "scatter", @@ -3260,7 +3261,7 @@ def figure(self) -> Figure: colorbar_candidates: list[dict[str, Any]] = [] for m in marks: data = m.data if m.data is not None else self.data - applier = _MARK_APPLIERS.get(m.kind) + applier = _MARK_APPLIERS.get(m.kind) or _plugin_applier(m.kind) if applier is None: raise TypeError(f"no applier registered for mark kind {m.kind!r}") x_axis_id, y_axis_id = _mark_axis_ids(m, axes) @@ -5199,6 +5200,96 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def _plugin_column(values: Any) -> Any: + """A plugin's declared column, as canonical f64 when it is numeric. + + `np.asarray` alone preserves float32, which would let a plugin's `calc` do + its arithmetic at f32 and hand the rounded result to a built-in mark that + then widens it back to f64 — precision lost between two f64 endpoints for + no reason. Canonical data is CPU-side f64 (§27), and the conversion is not + extra work: the column store would do it at ingest anyway. Non-numeric + columns (categorical strings, datetimes) pass through untouched. + """ + if values is None: + return values + try: + array = np.asarray(values) + if np.issubdtype(array.dtype, np.number) and not np.issubdtype(array.dtype, np.bool_): + return np.ascontiguousarray(array, dtype=np.float64) + return array + except (TypeError, ValueError): # pragma: no cover - exotic column objects + return values + + +def _plugin_applier(kind: str) -> Optional[Callable[[Figure, Mark, Any], None]]: + """An applier for a registered mark plugin, or None if `kind` is unknown. + + Resolved per compile rather than folded into `_MARK_APPLIERS` so a plugin + can never shadow a built-in, and so `registered_marks()` stays the single + answer to what is contributed from outside. + """ + resolved = plugins.get_mark_plugin(kind) + if resolved is None: + return None + plugin = resolved + + def apply(fig: Figure, m: Mark, data: Any) -> None: + # Declared columns arrive as arrays, never as the raw list a caller + # happened to pass: a calc function is arithmetic over columns (§24), + # and making every plugin author write np.asarray first would be a + # papercut that shows up as a TypeError in their code, not ours. + columns = { + column: _plugin_column(_resolve(data, m.props.get(column), context=f"{kind}.{column}")) + for column in plugin.columns + } + if plugin.calc is not None: + calculated = plugin.calc(columns) + if not isinstance(calculated, Mapping): + raise TypeError( + f"mark plugin {kind!r} calc must return a mapping of columns, " + f"got {type(calculated).__name__}" + ) + columns = dict(calculated) + # Axis ids are chart plumbing, already applied by the compile loop. + options = { + k: v + for k, v in m.props.items() + if k not in plugin.columns and k not in {"x_axis", "y_axis"} + } + built = plugin.build( + plugins.MarkContext( + columns=columns, + options=options, + name=m.name, + style=dict(m.style or {}), + class_name=m.class_name, + ) + ) + if isinstance(built, (Mark, str, bytes)) or not isinstance(built, Sequence): + raise TypeError( + f"mark plugin {kind!r} build must return a sequence of marks, " + f"got {type(built).__name__}" + ) + for child in built: + if not isinstance(child, Mark): + raise TypeError( + f"mark plugin {kind!r} build returned {type(child).__name__}, " + "expected marks built by xy's mark constructors" + ) + child_applier = _MARK_APPLIERS.get(child.kind) + if child_applier is None: + # One level only: a plugin composes built-in primitives. Letting + # plugins compose each other turns a registry into a dependency + # graph with cycles, for a case nothing has asked for yet. + raise TypeError( + f"mark plugin {kind!r} build returned mark kind {child.kind!r}, " + f"which is not a built-in; plugins compose built-in marks only" + ) + child_applier(fig, child, child.data if child.data is not None else data) + + return apply + + _ANNOTATION_APPLIERS: dict[str, Callable[[Figure, Annotation], None]] = { "arrow": _apply_arrow_annotation, "band": _apply_band_annotation, @@ -5209,6 +5300,57 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def mark( + kind: str, + *, + data: TableLike = None, + name: Optional[str] = None, + style: Optional[dict[str, StyleValue]] = None, + class_name: Optional[str] = None, + key: Any = None, + animation: "Animation | bool | None" = None, + x_axis: str = "x", + y_axis: str = "y", + **fields: Any, +) -> Mark: + """A mark contributed by a registered plugin (`xy.register_mark`). + + `kind` names the plugin. Fields it declared in `MarkPlugin.columns` accept a + column name resolved from ``data`` or values directly, exactly like a + built-in mark's ``x``/``y``; every other keyword reaches the plugin as an + option untouched. + + Args: + kind: Registered plugin name. + data: Table used to resolve column-name inputs. + name: Series label used by legends and tooltips. + style: Mark style overrides, passed to the plugin verbatim. + class_name: Adapter-only trace metadata. + key: Stable row identities, or a column name resolved from ``data``. + animation: Per-mark animation override; ``False`` disables animation. + x_axis: Identifier of the x axis used by this mark. + y_axis: Identifier of the y axis used by this mark. + **fields: The plugin's declared columns and options. + """ + if plugins.get_mark_plugin(kind) is None: + known = ", ".join(plugins.registered_marks()) or "none registered" + raise ValueError(f"unknown mark plugin {kind!r}; registered plugins: {known}") + return Mark( + kind=kind, + data=data, + name=_optional_string(name, "mark name"), + class_name=_optional_string(class_name, "mark class_name"), + style=styles.normalize_css_style(style, "mark style"), + key=key, + animation=animation, + props={ + **fields, + "x_axis": _axis_id(x_axis, "mark x_axis"), + "y_axis": _axis_id(y_axis, "mark y_axis"), + }, + ) + + def chart(*children: Component, **props: Any) -> Chart: """A neutral single-panel chart for overlays and mixed mark composition.""" return Chart("chart", children, **props) diff --git a/python/xy/marks.py b/python/xy/marks.py index e77331f7..6c0c5a13 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -12,7 +12,7 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Optional, Union import numpy as np @@ -93,6 +93,19 @@ def _direct_symbols(value: Any, n: int, style_channels: dict[str, channels.Style return "circle" +def _stroke_geometry(css: Mapping[str, Any]) -> dict[str, str]: + """The polyline cap key from compiled CSS, omitted at its default. + + Every renderer already draws XY's default `round`, so a spec that never + asks for another cap stays byte-identical to one built before the property + existed. + """ + value = css.get("linecap") + if value is None or value == styles.DEFAULT_LINE_CAP: + return {} + return {"linecap": str(value)} + + def _stroke_channel( value: Any, n: int, label: str ) -> tuple[Optional[str], Optional[channels.ColorChannel]]: @@ -816,6 +829,7 @@ def line( yc = self.store.ingest(yc.values[order]) style: dict[str, Any] = {"color": color, "width": width, "opacity": opacity} style.update(styles._opacity_channels(css)) + style.update(_stroke_geometry(css)) if curve != "linear": style["curve"] = curve if dash_spec is not None: @@ -1149,6 +1163,7 @@ def step( ) self.traces[-1].style["step"] = where self.traces[-1].style.update(styles._opacity_channels(css)) + self.traces[-1].style.update(_stroke_geometry(css)) return self @@ -1373,6 +1388,7 @@ def scatter( opacity = css.get("opacity", opacity) stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) + symbol = css.get("symbol", symbol) name = self._optional_text(name, "scatter name") zoom_size_factor = self._nonnegative_scalar(zoom_size_factor, "scatter zoom_size_factor") if zoom_size_factor == 0.0: diff --git a/python/xy/plugins.py b/python/xy/plugins.py new file mode 100644 index 00000000..68227055 --- /dev/null +++ b/python/xy/plugins.py @@ -0,0 +1,160 @@ +"""Third-party mark plugins — the v0 of the dossier's §24 extensibility story. + +§24 describes a registered mark plugin as three things: a calc function over +columns, *either* a composition of built-in GPU primitives *or* a WGSL/GLSL +snippet pair, and hover/a11y descriptors. This module ships the first and the +composition half of the second, and deliberately not the shader half. + +The reason is not schedule. A plugin that composes built-in marks cannot draw +anything the engine could not already draw, so its traces move through the +paths that already exist: LOD and decimation (§28), picking and hover, the wire +protocol's f32 discipline (§29), and every export path including the two that +have no browser. It reuses them rather than reimplementing them. A plugin +carrying its own shader would reuse none of that, so shaders are a second +system and can wait until something real needs one. + + import numpy as np + import xy + + def _calc(columns): + low, high = columns["low"], columns["high"] + return {**columns, "mid": (low + high) / 2.0} + + def _build(ctx): + return [ + xy.segments( + x0=ctx.columns["t"], x1=ctx.columns["t"], + y0=ctx.columns["low"], y1=ctx.columns["high"], + style=ctx.style, + ), + xy.scatter(x=ctx.columns["t"], y=ctx.columns["mid"], size=4), + ] + + xy.register_mark( + xy.MarkPlugin(name="hilo", columns=("t", "low", "high"), calc=_calc, build=_build) + ) + + chart = xy.chart(xy.mark("hilo", t=ts, low=lows, high=highs, data=frame)) + +A plugin's `build` returns built-in `Mark` objects and nothing else; it never +sees the `Figure`, the trace list, or the column store. `tests/test_mark_plugins.py` +holds that boundary: the composed marks go through the same appliers, the same +axis assignment, and the same post-processing as marks written by hand, because +they *are* marks written by hand — just written by someone else's function. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - import cycle at runtime + from .components import Mark + + +@dataclass(frozen=True) +class MarkContext: + """Everything a plugin's `build` is allowed to see. + + Deliberately not a `Figure`: a plugin composes marks, it does not drive the + engine. `columns` holds the plugin's declared columns after `calc` has run, + with any string values already resolved against the chart's `data=`. + """ + + columns: Mapping[str, Any] + options: Mapping[str, Any] + name: str | None = None + style: Mapping[str, Any] = field(default_factory=dict) + class_name: str | None = None + + +@dataclass(frozen=True) +class MarkPlugin: + """A mark kind contributed from outside the core. + + `columns` names the fields `xy.mark(...)` will resolve against `data=`; + everything else a caller passes lands in `MarkContext.options` untouched. + `calc` runs once over the resolved columns and returns the columns `build` + sees — this is §24's "calc function over columns → columns", running in + Python today rather than in the worker. + """ + + name: str + build: Callable[[MarkContext], "Sequence[Mark]"] + columns: tuple[str, ...] = () + calc: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None + doc: str = "" + + +#: `xy.mark()` binds these itself, so a column with one of these names could +#: never be passed: the caller's value would land on the `mark()` parameter and +#: the column would silently resolve to None. Rejecting the schema at +#: registration turns a confusing runtime result into an import-time error. +_MARK_CONTROL_FIELDS: frozenset[str] = frozenset( + {"kind", "data", "name", "style", "class_name", "key", "animation", "x_axis", "y_axis"} +) + +_REGISTRY: dict[str, MarkPlugin] = {} + + +def register_mark(plugin: MarkPlugin, *, replace: bool = False) -> MarkPlugin: + """Register a mark plugin under its name. + + Refuses to shadow a built-in kind, and refuses to silently replace another + plugin: two libraries registering `"candlestick"` is a conflict their user + needs to know about, not a race the import order settles. + """ + from .components import _MARK_APPLIERS + + if not isinstance(plugin, MarkPlugin): + raise TypeError(f"register_mark expects a MarkPlugin, got {type(plugin).__name__}") + if not plugin.name or not plugin.name.isidentifier(): + raise ValueError(f"mark plugin name must be an identifier, got {plugin.name!r}") + if plugin.name in _MARK_APPLIERS: + raise ValueError(f"{plugin.name!r} is a built-in mark kind and cannot be replaced") + if plugin.name in _REGISTRY and not replace: + raise ValueError( + f"mark plugin {plugin.name!r} is already registered; " + "pass replace=True if that is intended" + ) + if not callable(plugin.build): + raise TypeError(f"mark plugin {plugin.name!r} build must be callable") + if plugin.calc is not None and not callable(plugin.calc): + raise TypeError(f"mark plugin {plugin.name!r} calc must be callable or None") + duplicates = sorted({c for c in plugin.columns if plugin.columns.count(c) > 1}) + if duplicates: + raise ValueError(f"mark plugin {plugin.name!r} repeats column(s) {duplicates}") + reserved = sorted(set(plugin.columns) & _MARK_CONTROL_FIELDS) + if reserved: + raise ValueError( + f"mark plugin {plugin.name!r} declares column(s) {reserved}, which " + f"xy.mark() binds itself; rename them" + ) + _REGISTRY[plugin.name] = plugin + return plugin + + +def unregister_mark(name: str) -> None: + """Remove a registered plugin. Unknown names are a no-op.""" + _REGISTRY.pop(name, None) + + +def registered_marks() -> tuple[str, ...]: + """Names of every registered mark plugin, sorted.""" + return tuple(sorted(_REGISTRY)) + + +def get_mark_plugin(name: str) -> MarkPlugin | None: + """The registered plugin for `name`, or None if nothing claims it.""" + return _REGISTRY.get(name) + + +__all__ = [ + "MarkContext", + "MarkPlugin", + "get_mark_plugin", + "register_mark", + "registered_marks", + "unregister_mark", +] diff --git a/python/xy/styles.py b/python/xy/styles.py index c24a1515..be59d2d9 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -46,6 +46,23 @@ _AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"}) _AXIS_DIRECTIONS = frozenset({"in", "out", "inout"}) +# Polyline stroke geometry. All three mark renderers (WebGL, SVG, native +# rasterizer) draw these caps identically; XY defaults to `round` rather than +# the CSS initial value `butt` because the native rasterizer is the reference +# for static export and has always drawn round. Set the property to opt into +# the CSS initial value. +# +# `stroke-linejoin` is deliberately absent. SVG and the native rasterizer both +# implement all three joins, but the WebGL client draws polylines as one +# instanced quad per segment with no join geometry at all, so a `miter` there +# would render as the overlap the segments already produce. Accepting a +# declaration one renderer silently ignores is exactly what this module exists +# to prevent, so the property waits for the client. The gap — including the +# joins the three renderers already disagree on by default — is a row in +# `xy.styling.capabilities`. +LINE_CAPS = frozenset({"butt", "round", "square"}) +DEFAULT_LINE_CAP = "round" + _MARK_KINDS = tuple( sorted( _LINE_KINDS @@ -170,6 +187,12 @@ def _dasharray(value: StyleValue, label: str) -> list[float] | None: return lengths +def _keyword(value: StyleValue, allowed: frozenset[str], label: str) -> str: + if not isinstance(value, str) or value not in allowed: + raise ValueError(f"{label} must be one of {sorted(allowed)}") + return value + + def _paint(value: StyleValue, label: str) -> str: return _validate.css_color(value, label) @@ -195,7 +218,13 @@ 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 |= {"stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} + props |= { + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + } elif kind in _SIMPLE_STROKE_KINDS: props |= {"stroke", "stroke-width", "stroke-opacity"} elif kind in _AREA_KINDS: @@ -216,6 +245,7 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: "stroke", "stroke-width", "stroke-opacity", + "marker-shape", } elif kind in _RECT_KINDS: props |= { @@ -302,6 +332,10 @@ def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> di _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 == "stroke-linecap": + _set(out, "linecap", _keyword(raw, LINE_CAPS, f"{label}['stroke-linecap']"), prop, seen) + elif prop == "marker-shape": + _set(out, "symbol", _validate.point_symbol(raw, f"{label}['marker-shape']"), prop, seen) elif prop == "border-radius": _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen) return out @@ -371,6 +405,8 @@ def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]: __all__ = [ + "DEFAULT_LINE_CAP", + "LINE_CAPS", "StyleMapping", "StyleValue", "compile_axis_style", diff --git a/python/xy/styling/__init__.py b/python/xy/styling/__init__.py new file mode 100644 index 00000000..dc644318 --- /dev/null +++ b/python/xy/styling/__init__.py @@ -0,0 +1,13 @@ +"""Machine-checkable records about XY's styling surface. + +`capabilities` is the one that matters: what can be styled, in which renderer, +and how far it travels. It is imported by the docs generator and pinned by +`tests/test_capability_registry.py`, so a claim about customization can be +checked against it rather than against a reading of `styles.py`. +""" + +from __future__ import annotations + +from . import capabilities + +__all__ = ["capabilities"] diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py new file mode 100644 index 00000000..1b4aed19 --- /dev/null +++ b/python/xy/styling/capabilities.py @@ -0,0 +1,402 @@ +"""An inventory of what XY can be styled with, and where each thing reaches. + +One entry per CSS-addressable DOM slot and one per mark style property, each +carrying its support level in the WebGL client, the SVG writer, and the native +rasterizer. The styling surface is spread across `styles.py`, `dom.py`, the +three renderers, and the export paths, so answering "can I change this, and +will it survive `to_png()`" otherwise means reading all of them. + +Two rules keep it accurate, both enforced by `tests/test_capability_registry.py`: + +1. **It cannot drift.** The registry must cover exactly `dom.CHART_DOM_SLOTS` + and exactly the property set `styles._supported_mark_style_properties` + compiles — no more, no fewer. Adding a property without a registry entry + fails the suite, and so does keeping an entry for a property that was + removed. +2. **It does not restate what code already knows.** Which mark kinds accept a + property is *derived* from `styles.py` at import, never typed out here, so + the two cannot disagree. + +Support levels are deliberately coarse: `full` means the renderer draws the +property as specified, `partial` means it draws something the notes have to +qualify, and `none` means it does not draw it at all. `none` is not a bug +report — several are deliberate, and the `notes` field says which. + +Keep `id` values stable: they are the join key for the generated table. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +from .. import styles +from ..dom import CHART_DOM_SLOTS + +#: The three mark renderers. Native PDF is intentionally absent: it is produced +#: from the SVG writer's output (`_pdf.svg_to_pdf`), so it inherits the `svg` +#: column exactly and a fourth column would imply an independent implementation +#: that does not exist. +RENDERERS: tuple[str, ...] = ("webgl", "svg", "native") + +#: Where chrome slots can be styled. `browser` covers standalone HTML, the +#: notebook iframe, the widget, the Reflex adapter, and Chromium capture — all +#: of them render the same document with the same client. +SURFACES: tuple[str, ...] = ("browser", "native_raster", "native_vector") + +SUPPORT_LEVELS: frozenset[str] = frozenset({"full", "partial", "none"}) +STATUSES: frozenset[str] = frozenset({"shipped", "partial", "planned"}) +VOCABULARIES: frozenset[str] = frozenset({"css", "svg", "xy"}) + + +@dataclass(frozen=True) +class MarkStyleProperty: + """One property accepted by a mark's `style=` mapping.""" + + id: str + vocabulary: str + compiles_to: str + support: dict[str, str] + status: str + notes: str + + @property + def kinds(self) -> tuple[str, ...]: + """Mark kinds that accept this property, read from `styles.py`.""" + return tuple( + kind + for kind in styles._MARK_KINDS + if self.id in styles._supported_mark_style_properties(kind) + ) + + +@dataclass(frozen=True) +class SlotCapability: + """One stable DOM slot and how far its styling travels.""" + + id: str + support: dict[str, str] + notes: str + channel: str = "" + + +@dataclass(frozen=True) +class RendererDivergence: + """A default the three renderers do not agree on. + + Typed like every other registry entry rather than left as a bare dict: a + mistyped key in a dict fails silently at render time instead of raising. + """ + + id: str + what: str + webgl: str + svg: str + native: str + visible_when: str + tracked_by: str + + +@dataclass(frozen=True) +class ExtensionPoint: + """A way to add behavior XY does not ship, without forking it.""" + + id: str + status: str + entry_point: str + notes: str + limits: tuple[str, ...] = field(default_factory=tuple) + + +MARK_STYLE_PROPERTIES: tuple[MarkStyleProperty, ...] = ( + MarkStyleProperty( + id="opacity", + vocabulary="css", + compiles_to="opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Multiplies the mark's own alpha in every renderer.", + ), + MarkStyleProperty( + id="fill", + vocabulary="svg", + compiles_to="color / fill", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "A plain color compiles to the mark's paint; a `linear-gradient(...)` " + "compiles to a gradient and is accepted only by area and rect kinds, " + "which are the ones with a gradient program." + ), + ), + MarkStyleProperty( + id="fill-opacity", + vocabulary="svg", + compiles_to="fill_opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Independent of `opacity`; the two multiply.", + ), + MarkStyleProperty( + id="stroke", + vocabulary="svg", + compiles_to="color / line_color / stroke", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="The paint for line-like geometry, and the border for filled marks.", + ), + MarkStyleProperty( + id="stroke-opacity", + vocabulary="svg", + compiles_to="stroke_opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="", + ), + MarkStyleProperty( + id="stroke-width", + vocabulary="svg", + compiles_to="width / line_width / stroke_width", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="CSS px; a bare number is px, matching the chrome style convention.", + ), + MarkStyleProperty( + id="stroke-dasharray", + vocabulary="svg", + compiles_to="dash", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "2-8 positive px lengths, or `none`. The WebGL client tracks arc " + "length on the CPU so dashes stay continuous across segments and " + "constant on screen through zoom." + ), + ), + MarkStyleProperty( + id="stroke-linecap", + vocabulary="svg", + compiles_to="linecap", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "Line family only — a cap is open-path geometry. XY's default is " + "`round`, not CSS's `butt`, because the native rasterizer has always " + "drawn round and is the reference for static export. Verified per " + "renderer: a Rust coverage test, a rasterized-ink test, and three " + "Chromium screenshots that hash differently per cap." + ), + ), + MarkStyleProperty( + id="border-radius", + vocabulary="css", + compiles_to="corner_radius", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately.", + ), + MarkStyleProperty( + id="marker-shape", + vocabulary="xy", + compiles_to="symbol", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "17 shapes, drawn as analytic signed-distance fields in all three " + "renderers. An XY vocabulary name: CSS has no shape keyword for a " + "non-DOM point mark, and the CSS spelling and `symbol=` compile to " + "the same value." + ), + ), +) + + +#: Cross-renderer differences that exist in the *defaults* — no style property +#: selects them, so they are invisible until someone diffs two exports. They +#: belong in the registry for exactly that reason. +KNOWN_RENDERER_DIVERGENCES: tuple[RendererDivergence, ...] = ( + RendererDivergence( + id="polyline_join_default", + what="Interior vertices of a wide polyline", + webgl="the notch two overlapping segment quads leave", + svg="round (the writer names it explicitly)", + native="round (the capsule distance field fills the vertex)", + visible_when="stroke-width above ~4px at a sharp angle", + tracked_by="no style property selects a join; the default is the whole contract", + ), +) + + +#: Slots whose styling reaches the native writers through some channel other +#: than `styles={slot: ...}`, which none of them read. Everything else is +#: browser-only, and that is the answer for 22 of the 23. +_SLOT_EXCEPTIONS: dict[str, tuple[str, str, str]] = { + "legend": ( + "partial", + "xy.legend(style=...)", + "Written twice: to chrome_styles for the browser and to " + "legend_options['style'], which `_svg` and `_raster` do read — but only " + "`background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, " + "and `padding`/`rowGap` in `em`. The chart-level styles={'legend': ...} " + "spelling reaches none of it, so two spellings that agree in the browser " + "disagree in a PNG.", + ), + "root": ( + "partial", + "chart style=", + "`styles={'root': ...}` is browser-only, but the chart-level `style=` " + "token bag targets the same element and every renderer reads it " + "(`spec['dom']['style']`). Prefer it for anything that must survive " + "export.", + ), +} + +CHART_SLOTS: tuple[SlotCapability, ...] = tuple( + SlotCapability( + id=slot, + support={ + "browser": "full", + "native_raster": _SLOT_EXCEPTIONS.get(slot, ("none",))[0], + "native_vector": _SLOT_EXCEPTIONS.get(slot, ("none",))[0], + }, + channel=_SLOT_EXCEPTIONS[slot][1] if slot in _SLOT_EXCEPTIONS else "", + notes=_SLOT_EXCEPTIONS[slot][2] if slot in _SLOT_EXCEPTIONS else "", + ) + for slot in CHART_DOM_SLOTS +) + + +#: Ways to add behavior the core does not ship. This is the leg XY lost +#: outright before `xy.register_mark` existed, and the honest entry is still +#: narrower than Matplotlib's custom `Artist`. +EXTENSION_POINTS: tuple[ExtensionPoint, ...] = ( + ExtensionPoint( + id="mark_plugin_composition", + status="shipped", + entry_point="xy.register_mark / xy.MarkPlugin / xy.mark", + notes=( + "A calc over declared columns plus a build that returns built-in " + "marks. Its output is ordinary traces, so it reuses the built-in " + "rendering, picking, and export paths rather than reimplementing " + "them." + ), + limits=( + "composes built-in marks only, one level deep", + "cannot reach the Figure, the trace list, or the column store", + "cannot add a GPU primitive", + ), + ), + ExtensionPoint( + id="mark_plugin_shader", + status="planned", + entry_point="", + notes=( + "§24's WGSL/GLSL snippet pair. Deferred: a plugin with its own " + "shader reuses none of the built-in rendering, picking, or export " + "paths and would have to reimplement them." + ), + limits=(), + ), + ExtensionPoint( + id="custom_renderer", + status="planned", + entry_point="", + notes="No way to add a fourth renderer or replace one of the three.", + limits=(), + ), +) + + +def markdown_mark_property_table( + properties: Iterable[MarkStyleProperty] = MARK_STYLE_PROPERTIES, +) -> list[str]: + """One row per style property, with its per-renderer support.""" + lines = [ + "| property | vocabulary | mark kinds | webgl | svg | native | status |", + "|---|---|---|---|---|---|---|", + ] + for prop in properties: + kinds = ", ".join(f"`{kind}`" for kind in prop.kinds) or "—" + lines.append( + f"| `{prop.id}` | {prop.vocabulary} | {kinds} | " + f"{prop.support['webgl']} | {prop.support['svg']} | " + f"{prop.support['native']} | {prop.status} |" + ) + return lines + + +def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[str]: + """One row per chrome slot, with how far its styling travels.""" + lines = [ + "| slot | browser | native raster | native vector |", + "|---|---|---|---|", + ] + for slot in slots: + lines.append( + f"| `{slot.id}` | {slot.support['browser']} | " + f"{slot.support['native_raster']} | {slot.support['native_vector']} |" + ) + return lines + + +def markdown_extension_table(points: Iterable[ExtensionPoint] = EXTENSION_POINTS) -> list[str]: + """One row per extension point, with its declared limits.""" + lines = ["| extension point | status | entry point | limits |", "|---|---|---|---|"] + for point in points: + limits = "; ".join(point.limits) or "—" + lines.append(f"| {point.id} | {point.status} | `{point.entry_point or '—'}` | {limits} |") + return lines + + +def axis_style_keys() -> tuple[str, ...]: + """The axis `style=` vocabulary, read from `styles.py` rather than listed. + + A fresh-agent evaluation of this repo caught the comparison document + quoting 15 of these after a 16th shipped. Prose cannot hold a count; the + registry derives it and `summary()` publishes it. + """ + return tuple( + sorted( + styles._AXIS_COLOR_PROPERTIES + | styles._AXIS_LENGTH_PROPERTIES + | styles._AXIS_SIZE_PROPERTIES + | styles._AXIS_COMPAT_PROPERTIES + | {"tick_direction", "tick_label_anchor"} + ) + ) + + +def summary() -> dict[str, object]: + """Counts a release note can quote without anyone recounting by hand.""" + shipped = [p for p in MARK_STYLE_PROPERTIES if p.status == "shipped"] + return { + "axis_style_keys": len(axis_style_keys()), + "mark_style_properties": len(MARK_STYLE_PROPERTIES), + "mark_style_properties_shipped": len(shipped), + "mark_kinds": len(styles._MARK_KINDS), + "chart_slots": len(CHART_SLOTS), + "slots_styleable_natively": sum( + 1 for s in CHART_SLOTS if s.support["native_raster"] != "none" + ), + "extension_points_shipped": sum(1 for e in EXTENSION_POINTS if e.status == "shipped"), + "known_renderer_divergences": len(KNOWN_RENDERER_DIVERGENCES), + } + + +__all__ = [ + "CHART_SLOTS", + "EXTENSION_POINTS", + "KNOWN_RENDERER_DIVERGENCES", + "MARK_STYLE_PROPERTIES", + "RENDERERS", + "SURFACES", + "ExtensionPoint", + "MarkStyleProperty", + "RendererDivergence", + "SlotCapability", + "axis_style_keys", + "markdown_extension_table", + "markdown_mark_property_table", + "markdown_slot_table", + "summary", +] diff --git a/scripts/check_claim_guardrails.py b/scripts/check_claim_guardrails.py index 47a133e2..f7d4f4d2 100644 --- a/scripts/check_claim_guardrails.py +++ b/scripts/check_claim_guardrails.py @@ -17,6 +17,8 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_DOCS = ( + "README.md", + "CLAUDE.md", "pyproject.toml", "SECURITY.md", "CONTRIBUTING.md", @@ -43,6 +45,22 @@ r")\b", re.IGNORECASE, ) +# Customization is the second axis people overclaim on, and it went unguarded +# while performance was fenced in. These shapes are unqualifiable: the styling +# surface is a bounded, enumerated subset (`spec/api/capability-matrix.md`), so +# "anything" and "most" are wrong however the sentence is framed. +CUSTOMIZATION_SUPERLATIVE_RE = re.compile( + r"\b(" + r"most\s+(?:customi[sz]able|themeable|styl(?:e)?able|extensible|flexible)|" + r"(?:fully|completely|infinitely|endlessly|totally)\s+customi[sz]able|" + r"customi[sz]e\s+(?:everything|anything)|" + r"style\s+(?:everything|anything)|" + r"unlimited\s+(?:styling|customi[sz]ation)|" + r"(?:more|as)\s+(?:customi[sz]able|themeable|extensible)\s+than\s+" + r"(?:all|every|any|everything|anything)" + r")\b", + re.IGNORECASE, +) COMPARATIVE_RE = re.compile( r"\b(?:faster\s+than|beats?|outperforms?)\s+" r"(?:plotly|matplotlib|bokeh|altair|datashader|holoviews|hvplot|seaborn)\b", @@ -62,7 +80,7 @@ STALE_REPO_RE = re.compile(r"github\.com/Alek99|app\.codspeed\.io/Alek99|charts-exp", re.IGNORECASE) POLICY_WORDS = re.compile( - r"\b(do not|don't|must|should|guardrail|policy|claim|goal|planned|target|" + r"\b(do not|don't|never|no amount of|must|should|guardrail|policy|claim|goal|planned|target|" r"blurry|rather than|not\s+(?:a|the|safe|same|one)|without naming|" r"needs qualification)\b", re.IGNORECASE, @@ -134,6 +152,15 @@ def _findings_for_file(path: Path) -> list[Finding]: line, ) ) + if CUSTOMIZATION_SUPERLATIVE_RE.search(line) and not _is_policy_or_negative_context(window): + findings.append( + Finding( + path, + index + 1, + "customization superlative is not defensible from the capability matrix", + line, + ) + ) if COMPARATIVE_RE.search(line) and not ( _is_policy_or_negative_context(window) or _has_claim_qualifiers(window) ): diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py new file mode 100644 index 00000000..7f21b903 --- /dev/null +++ b/scripts/gen_capability_matrix.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Generate `spec/api/capability-matrix.md` from `xy.styling.capabilities`. + +The committed table is an artifact, never hand-edited. `--check` fails when it +has fallen behind the registry, and `tests/test_capability_registry.py` runs +that check — so the document cannot drift from the registry, and the registry +cannot drift from `styles.py`. + + uv run python scripts/gen_capability_matrix.py --write + uv run python scripts/gen_capability_matrix.py --check +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from xy.styling import capabilities as caps # noqa: E402 + +REPORT = ROOT / "spec" / "api" / "capability-matrix.md" +PUBLIC = ROOT / "docs" / "styling" / "capabilities.md" + + +def _shipped_renderer_claim() -> str: + """How the summary may describe shipped properties, read from the registry. + + The sentence used to hard-code "drawn by all three renderers". If a shipped + property ever lands `partial` somewhere, that prose would overclaim in the + generated document — the exact failure the registry exists to prevent. + """ + shipped = [p for p in caps.MARK_STYLE_PROPERTIES if p.status == "shipped"] + if all(level == "full" for prop in shipped for level in prop.support.values()): + return "drawn by all three renderers" + partial = sorted(prop.id for prop in shipped if any(v != "full" for v in prop.support.values())) + return "not all drawn identically everywhere — see " + ", ".join(f"`{p}`" for p in partial) + + +def render() -> str: + """The engineering-facing capability matrix, as markdown.""" + counts = caps.summary() + claim = _shipped_renderer_claim() + lines = [ + "# Capability matrix", + "", + "", + "", + "What XY can be styled and extended with, per renderer. Generated from", + "`python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py`", + "pins to `styles.py` and `dom.py`, so it cannot list a property the", + "implementation does not compile or omit one it does.", + "", + "`full` means the renderer draws the property as specified. `partial` means it", + "draws something the notes have to qualify. `none` means it does not draw it —", + "which is sometimes deliberate, and the notes say which.", + "", + "## In one line", + "", + f"- **{counts['mark_style_properties_shipped']}** mark style properties across " + f"**{counts['mark_kinds']}** mark kinds, {claim}.", + f"- **{counts['chart_slots']}** stable chrome slots, CSS- and Tailwind-addressable " + "in the browser; " + f"**{counts['slots_styleable_natively']}** of them reach the native writers, " + "through a channel other than per-slot styles.", + f"- **{counts['extension_points_shipped']}** shipped extension point.", + f"- **{counts['known_renderer_divergences']}** known default divergence between " + "renderers, listed below rather than left to be discovered.", + "", + "## Mark style properties", + "", + "The subset a mark's `style=` mapping accepts. Anything outside it raises", + "before data is ingested, so no renderer silently drops a declaration another", + "one honors.", + "", + ] + lines += caps.markdown_mark_property_table() + lines += ["", "### Notes", ""] + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.notes: + lines.append(f"- **`{prop.id}`** — {prop.notes}") + lines += [ + "", + "## Chrome slots", + "", + "Stable `data-xy-slot` names that accept `class_names=` and `styles=` in the", + "browser. The native raster and vector writers have no cascade: they read the", + "chart-level `style=` token bag and nothing per-slot. That boundary is", + "contracted in [export.md](export.md) §9 and pinned by", + "`tests/test_export_style_survival.py`.", + "", + ] + lines += caps.markdown_slot_table() + lines += ["", "### Notes", ""] + for slot in caps.CHART_SLOTS: + if slot.notes: + lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") + lines += [ + "", + "## Extension points", + "", + "Ways to add behavior the core does not ship, without forking it.", + "", + ] + lines += caps.markdown_extension_table() + lines += ["", "### Notes", ""] + for point in caps.EXTENSION_POINTS: + lines.append(f"- **{point.id}** — {point.notes}") + lines += [ + "", + "## Known renderer divergences", + "", + "Differences in the *defaults*, which no style property selects — invisible", + "until someone diffs two exports. Listed here for that reason.", + "", + "| what | webgl | svg | native | visible when | tracked by |", + "|---|---|---|---|---|---|", + ] + for div in caps.KNOWN_RENDERER_DIVERGENCES: + lines.append( + f"| {div.what} | {div.webgl} | {div.svg} | {div.native} | " + f"{div.visible_when} | {div.tracked_by} |" + ) + lines += [ + "", + "## Regenerating", + "", + "```bash", + "uv run python scripts/gen_capability_matrix.py --write", + "```", + "", + "`--check` fails if this file is stale, and the test suite runs it.", + "", + ] + return "\n".join(lines) + + +def render_public() -> str: + """The same tables, as a public docs page. + + Generated from the same registry as the spec version rather than written + twice: a hand-kept public copy is exactly the drift this whole chain exists + to prevent. + """ + counts = caps.summary() + claim = _shipped_renderer_claim() + lines = [ + "---", + "title: Capability Matrix", + "description: What XY can be styled and extended with, per renderer, " + "generated from the capability registry.", + "---", + "", + "# Capability Matrix", + "", + "", + "", + "Every styling question about XY has the same two halves: *can I change this*,", + "and *does the change survive where I need it*. This page answers both from the", + "registry the implementation is checked against.", + "", + f"- **{counts['mark_style_properties_shipped']}** mark style properties across " + f"**{counts['mark_kinds']}** mark kinds, {claim}.", + f"- **{counts['chart_slots']}** stable chrome slots for CSS and Tailwind in the browser.", + f"- **{counts['extension_points_shipped']}** way to add a mark kind XY does not " + "ship, without forking it.", + "", + "## Mark style properties", + "", + "What a mark's `style=` accepts. Anything outside this raises while the chart is", + "built — one renderer never silently ignores what another draws.", + "", + ] + lines += caps.markdown_mark_property_table() + lines += ["", "### Notes", ""] + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.notes: + lines.append(f"- **`{prop.id}`** — {prop.notes}") + lines += [ + "", + "## Chrome slots", + "", + "Stable `data-xy-slot` names that take `class_names=` and `styles=`. The native", + "raster and vector writers have no cascade, so per-slot styling is a browser", + "mechanism; put anything that must survive export in the chart-level `style=`", + "token bag or in mark and axis `style=`, which every renderer reads.", + "", + ] + lines += caps.markdown_slot_table() + lines += ["", "### Notes", ""] + for slot in caps.CHART_SLOTS: + if slot.notes: + lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") + lines += [ + "", + "## Extension points", + "", + "See [Custom Marks](/docs/xy/advanced/custom-marks/) for the worked example.", + "", + ] + lines += caps.markdown_extension_table() + lines += [ + "", + "## Where renderers still disagree", + "", + "Differences in the defaults that no property selects. They are listed because", + "an undocumented difference reads as a bug; a documented one is a contract.", + "", + "| what | browser | svg | native png | visible when |", + "|---|---|---|---|---|", + ] + for div in caps.KNOWN_RENDERER_DIVERGENCES: + lines.append( + f"| {div.what} | {div.webgl} | {div.svg} | {div.native} | {div.visible_when} |" + ) + lines += [ + "", + "For what is still alpha, see", + "[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/).", + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + """Print, write, or check the generated capability documents.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed matrix") + parser.add_argument( + "--check", action="store_true", help="fail if the committed matrix is stale" + ) + parser.add_argument("--json", action="store_true", help="print the summary counts as JSON") + args = parser.parse_args(argv) + + if args.json: + print(json.dumps(caps.summary(), indent=2)) + return 0 + + targets = ((REPORT, render()), (PUBLIC, render_public())) + if args.write: + for path, document in targets: + path.write_text(document, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)}") + return 0 + if args.check: + stale = [ + path + for path, document in targets + if (path.read_text(encoding="utf-8") if path.exists() else "") != document + ] + if stale: + names = ", ".join(str(p.relative_to(ROOT)) for p in stale) + print( + f"{names} is stale; run scripts/gen_capability_matrix.py --write", + file=sys.stderr, + ) + return 1 + print("capability matrix is current") + return 0 + print(targets[0][1]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_local.py b/scripts/verify_local.py index e4acb0cf..b59303fe 100644 --- a/scripts/verify_local.py +++ b/scripts/verify_local.py @@ -80,6 +80,11 @@ def _base_checks( "public performance-claim guardrails", (py, "scripts/check_claim_guardrails.py"), ), + Check( + "capability_matrix", + "generated capability matrix is current", + (py, "scripts/gen_capability_matrix.py", "--check"), + ), Check( "benchmark_harness", "benchmark metadata, report, regression, and claim guardrail tests", @@ -284,6 +289,7 @@ def _base_checks( "python_floor", "public_api", "claim_guardrails", + "capability_matrix", "ci_workflow", "ruff_check", "ruff_format", diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index 40587cd1..e0fdb2ab 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -88,7 +88,10 @@ "python/xy/interaction.py", "python/xy/kernels.py", "python/xy/lod.py", + "python/xy/plugins.py", "python/xy/py.typed", + "python/xy/styling/__init__.py", + "python/xy/styling/capabilities.py", "python/xy/static/index.js", "python/xy/static/standalone.js", "python/xy/widget.py", @@ -102,6 +105,7 @@ "examples/reflex/xy_reflex_demo/xy_reflex_demo.py", "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", + "scripts/gen_capability_matrix.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index e8b85104..bde52d7d 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,6 +24,11 @@ The public surface: what callers can build, style, export, and interact with. `tests/test_docs_examples.py`, so API drift fails the suite. - [`chart-kind-contract.md`](api/chart-kind-contract.md) — how to add a 2D chart type: what the shared machinery provides and what a new kind must supply. +- [`capability-matrix.md`](api/capability-matrix.md) — **generated**: the + inventory of what can be styled and extended, per renderer, from + `python/xy/styling/capabilities.py`. + Regenerate with `scripts/gen_capability_matrix.py --write`; the test suite + fails if it is stale. - [`chart-roadmap.md`](api/chart-roadmap.md) — the single 2D-first chart-type coverage backlog, prioritized by popularity and primitive reuse. - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md new file mode 100644 index 00000000..66a9a863 --- /dev/null +++ b/spec/api/capability-matrix.md @@ -0,0 +1,122 @@ +# Capability matrix + + + +What XY can be styled and extended with, per renderer. Generated from +`python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py` +pins to `styles.py` and `dom.py`, so it cannot list a property the +implementation does not compile or omit one it does. + +`full` means the renderer draws the property as specified. `partial` means it +draws something the notes have to qualify. `none` means it does not draw it — +which is sometimes deliberate, and the notes say which. + +## In one line + +- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **23** stable chrome slots, CSS- and Tailwind-addressable in the browser; **2** of them reach the native writers, through a channel other than per-slot styles. +- **1** shipped extension point. +- **1** known default divergence between renderers, listed below rather than left to be discovered. + +## Mark style properties + +The subset a mark's `style=` mapping accepts. Anything outside it raises +before data is ingested, so no renderer silently drops a declaration another +one honors. + +| property | vocabulary | mark kinds | webgl | svg | native | status | +|---|---|---|---|---|---|---| +| `opacity` | css | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `heatmap`, `hexbin`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `fill` | svg | `area`, `bar`, `box`, `column`, `error_band`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `heatmap`, `hexbin`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `stroke` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-opacity` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-width` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-dasharray` | svg | `area`, `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | +| `stroke-linecap` | svg | `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped | +| `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | +| `marker-shape` | xy | `scatter` | full | full | full | shipped | + +### Notes + +- **`opacity`** — Multiplies the mark's own alpha in every renderer. +- **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program. +- **`fill-opacity`** — Independent of `opacity`; the two multiply. +- **`stroke`** — The paint for line-like geometry, and the border for filled marks. +- **`stroke-width`** — CSS px; a bare number is px, matching the chrome style convention. +- **`stroke-dasharray`** — 2-8 positive px lengths, or `none`. The WebGL client tracks arc length on the CPU so dashes stay continuous across segments and constant on screen through zoom. +- **`stroke-linecap`** — Line family only — a cap is open-path geometry. XY's default is `round`, not CSS's `butt`, because the native rasterizer has always drawn round and is the reference for static export. Verified per renderer: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. +- **`border-radius`** — Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately. +- **`marker-shape`** — 17 shapes, drawn as analytic signed-distance fields in all three renderers. An XY vocabulary name: CSS has no shape keyword for a non-DOM point mark, and the CSS spelling and `symbol=` compile to the same value. + +## Chrome slots + +Stable `data-xy-slot` names that accept `class_names=` and `styles=` in the +browser. The native raster and vector writers have no cascade: they read the +chart-level `style=` token bag and nothing per-slot. That boundary is +contracted in [export.md](export.md) §9 and pinned by +`tests/test_export_style_survival.py`. + +| slot | browser | native raster | native vector | +|---|---|---|---| +| `root` | full | partial | partial | +| `title` | full | none | none | +| `chrome` | full | none | none | +| `canvas` | full | none | none | +| `labels` | full | none | none | +| `legend` | full | partial | partial | +| `legend_item` | full | none | none | +| `legend_swatch` | full | none | none | +| `colorbar` | full | none | none | +| `colorbar_bar` | full | none | none | +| `colorbar_tick` | full | none | none | +| `colorbar_title` | full | none | none | +| `tooltip` | full | none | none | +| `modebar` | full | none | none | +| `modebar_button` | full | none | none | +| `selection` | full | none | none | +| `crosshair_x` | full | none | none | +| `crosshair_y` | full | none | none | +| `badge` | full | none | none | +| `badge_item` | full | none | none | +| `tick_label` | full | none | none | +| `axis_title` | full | none | none | +| `annotation_label` | full | none | none | + +### Notes + +- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. +- **`legend`** (via `xy.legend(style=...)`) — Written twice: to chrome_styles for the browser and to legend_options['style'], which `_svg` and `_raster` do read — but only `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em`. The chart-level styles={'legend': ...} spelling reaches none of it, so two spellings that agree in the browser disagree in a PNG. + +## Extension points + +Ways to add behavior the core does not ship, without forking it. + +| extension point | status | entry point | limits | +|---|---|---|---| +| mark_plugin_composition | shipped | `xy.register_mark / xy.MarkPlugin / xy.mark` | composes built-in marks only, one level deep; cannot reach the Figure, the trace list, or the column store; cannot add a GPU primitive | +| mark_plugin_shader | planned | `—` | — | +| custom_renderer | planned | `—` | — | + +### Notes + +- **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its output is ordinary traces, so it reuses the built-in rendering, picking, and export paths rather than reimplementing them. +- **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred: a plugin with its own shader reuses none of the built-in rendering, picking, or export paths and would have to reimplement them. +- **custom_renderer** — No way to add a fourth renderer or replace one of the three. + +## Known renderer divergences + +Differences in the *defaults*, which no style property selects — invisible +until someone diffs two exports. Listed here for that reason. + +| what | webgl | svg | native | visible when | tracked by | +|---|---|---|---|---|---| +| Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | no style property selects a join; the default is the whole contract | + +## Regenerating + +```bash +uv run python scripts/gen_capability_matrix.py --write +``` + +`--check` fails if this file is stale, and the test suite runs it. diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index a662c694..b9452ebe 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -199,6 +199,24 @@ this contract): `pointPick` (participates in the point-geometry GPU pick pass), colors, §36). The registry and `markOf()` are exported (`xy.MARK_KINDS`) — it is the public extension surface. +## Contributing a kind from outside the repo + +The checklist above is for kinds that join the core: six touch points across +Python, the client, and the docs. A kind that only needs to *compose* existing +primitives does not have to pay it. `xy.register_mark` (`python/xy/plugins.py`, +dossier §24) takes a `calc` over declared columns plus a `build` that returns +built-in `Mark` objects, and `components._plugin_applier` runs the result +through the same appliers, axis assignment, and post-processing as a hand-built +mark. `_MARK_APPLIERS` is consulted first, so a plugin can never shadow a +built-in. + +The dividing line is whether the kind needs a **new primitive**. A candlestick, +a dumbbell, a ribbon, a high-low band — all compositions, all plugin territory. +A kind that needs geometry no shader draws yet is a core kind and takes the +checklist. Composition is one level deep on purpose: plugins compose built-ins, +not each other, which keeps the registry a lookup rather than a dependency +graph. + ## Extension points not yet generalized (do it when the case lands) These are still shaped for the marks that exist. Generalize them when a real new diff --git a/spec/api/export.md b/spec/api/export.md index 870bdfaa..8d7fbab3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -227,3 +227,54 @@ Writes are atomic per file (same-directory temp file, fsync, `os.replace`), so a reader never observes a partial image. Failure mid-batch is not transactional: files already written stay on disk. The return value is the list of written byte strings, in input order. +## 9. What styling survives which export path + +XY has five styling mechanisms (`spec/api/styling.md` § The five ways to style) +and three rendering families. They do not intersect uniformly, and until this +section existed the gaps were discoverable only by exporting and looking. An +undocumented asymmetry reads as a bug; a documented one is a contract. + +The families are **browser** (standalone HTML, the notebook iframe, the widget, +the Reflex adapter, and Chromium capture, which screenshots the same document), +**native raster** (`_raster.render_raster` → PNG/JPEG/WebP), and **native +vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). + +| Mechanism | Browser | Native raster | Native vector | Enforcement | +| --- | --- | --- | --- | --- | +| `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` | +| `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` | +| `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` | +| `styles={slot: {...}}` (per-slot inline) | yes, all 23 slots | **dropped** | **dropped** | silent — see below | +| `class_names={slot: "..."}` | yes, all 23 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | +| `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` | +| `xy.legend(style=...)` | yes | 6 keys | 6 keys | parallel `legend_options["style"]` channel | +| `xy.colorbar(style=...)` | yes | **dropped** | **dropped** | no native channel exists | + +### Why two of those rows are silent, and why that is the right default + +`custom_css` raises because it is an author stylesheet: there is no honest +partial application of it, and the caller can switch to `Engine.chromium` in one +edit. The message says so. SVG rejects it for *every* engine, because a browser +screenshot cannot produce vector output — that row is a hard "never", not a +default. + +`class_names` and per-slot `styles` are dropped instead, because raising would +break every native export of a chart that carries Tailwind classes for its live +view, which is the normal way to use both surfaces together. The cost of that +choice is exactly this table: the behavior has to be written down and tested, +which is what `tests/test_export_style_survival.py` does — including that the +two native writers agree with each other and not merely with this page. + +### The legend's parallel channel + +`xy.legend(style=...)` is written twice: into `chrome_styles["legend"]` for the +browser and into `fig.legend_options["style"]`, which the native writers do +read (`_svg.py:2827`, `_raster.py:1970`). Native honors `background`, +`boxShadow`, `borderRadius`, and `--xy-legend-frame-alpha`, plus `padding` and +`rowGap` when they carry an `em` unit. The chart-level `styles={"legend": ...}` +spelling never reaches `legend_options`, so the two spellings that look +equivalent in the browser are not equivalent in a PNG. `colorbar` has no such +channel at all. + +This is an asymmetry worth closing, not just documenting; it is tracked as a +row in the capability registry rather than as prose here. diff --git a/spec/api/styling.md b/spec/api/styling.md index 0f33fc28..aaff7c5a 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -83,9 +83,9 @@ xy.bar( | Mark family | Supported CSS properties | | --- | --- | -| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `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` | +| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `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` | @@ -103,6 +103,48 @@ A mark's `class_name` is adapter-only trace metadata. It does not create a DOM node and is not interpreted as a paint selector by the shipped browser, Reflex, SVG, or native renderers. +### Polyline stroke geometry + +`stroke-linecap` (`butt` | `round` | `square`) carries its standard SVG +semantics: it shapes the two ends of an open polyline and each dash end. It is +accepted **only** by the line family, because it describes stroked open-path +geometry; every other mark rejects it at build time rather than accepting a +declaration no renderer would draw. + +![Line caps before and after: the native rasterizer capped round while the WebGL +client capped flat; both now cap round.](../assets/linecap-cross-renderer-before-after.png) + +![The three stroke-linecap values: butt, round, and square.](../assets/linecap-values.png) + +XY's default is `round`, deliberately not the CSS initial value `butt`. Before +this vocabulary existed the three renderers silently disagreed — the native +rasterizer capped round from its clamped segment distance field +(`src/raster.rs`), the WebGL client capped butt with a half-pixel bleed, and +the SVG writer hardcoded `round` on line paths while the area outline inherited +SVG's `butt`. Round is now the contract in all three, because the native +rasterizer is the reference for static export. + +`styles.DEFAULT_LINE_CAP` names that default and `marks._stroke_geometry` omits +a key that equals it, so a spec that never asks for another cap stays +byte-identical to one built before the change. + +Joins are always round and are not selectable. That was already the geometry +the native rasterizer produced, so nothing changed there; what did change is +that the SVG writer now *names* the join on every stroked path instead of +letting the format's `miter` default through — `_pdf` reads these attributes +straight back out of that markup, so an unnamed join meant SVG and PDF +disagreeing with the rasterizer at no benefit. + +### Marker shape + +`marker-shape` selects one of the 17 renderer-backed scatter symbols and is the +CSS spelling of the existing `symbol=` argument — both resolve to the same +`symbol` trace-style value, so the two spellings produce identical specs. It is +an **XY vocabulary name, not a standard CSS property**: CSS has no shape keyword +for a non-DOM point mark, and the alternative (a `-xy-` vendor prefix) would +force an unusable `_xy_marker_shape` Python alias. The distinction is recorded +per property rather than encoded in the name. + ### Reflex integration boundary Reflex owns reactive `Var` values, conditions, application state, event diff --git a/spec/assets/area-outline-cap-before-after.png b/spec/assets/area-outline-cap-before-after.png new file mode 100644 index 00000000..b8277041 Binary files /dev/null and b/spec/assets/area-outline-cap-before-after.png differ diff --git a/spec/assets/linecap-cross-renderer-before-after.png b/spec/assets/linecap-cross-renderer-before-after.png new file mode 100644 index 00000000..f940b127 Binary files /dev/null and b/spec/assets/linecap-cross-renderer-before-after.png differ diff --git a/spec/assets/linecap-values.png b/spec/assets/linecap-values.png new file mode 100644 index 00000000..51e6eb00 Binary files /dev/null and b/spec/assets/linecap-values.png differ diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 313aadf4..b5b951c0 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -652,7 +652,7 @@ missing entirely.* | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | | 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | -| 15 | No extensibility story (Plotly has custom traces) | Minor | Custom-mark API sketch (§24) | +| 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | ## 16. Numeric precision & deep zoom @@ -902,6 +902,7 @@ partial-bundle pain). Neither exists today. `supported | mapped-with-difference | unsupported`, the shim **warns loudly** on unsupported attributes (never silently drops), and the docs publish the coverage table per release. "Drop-in for the common 80%" becomes checkable, not vibes. + - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density @@ -909,6 +910,17 @@ partial-bundle pain). Neither exists today. (c) hover/a11y descriptors so §17/§20 work uncalled-for. Plotly's moat is breadth; a plugin API is how breadth arrives without the core team writing all 40 traces. + **Shipped (v0):** `xy.register_mark` / `xy.MarkPlugin` / `xy.mark` in + `python/xy/plugins.py`. It ships (a) and the composition half of (b); the + shader half is deferred. `build` returns built-in `Mark` objects and cannot + reach the `Figure`, the trace list, or the column store, so a plugin cannot + draw anything the engine could not already draw — and its output being + ordinary traces is what lets it reuse the built-in rendering, picking, and + export paths instead of reimplementing them. Composition is one level deep: + plugins compose built-ins, not each other. The shader half stays deferred for + the same reason the composition half works: a plugin carrying its own shader + reuses none of that. + ## 25. Milestone amendments (audit-driven) - **Phase 0** additionally proves: offset-encoding precision on ms-timestamp data diff --git a/src/lib.rs b/src/lib.rs index 0d893374..2dd2c048 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,7 +82,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 41; +pub const ABI_VERSION: u32 = 42; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] diff --git a/src/raster.rs b/src/raster.rs index 5da85840..38954295 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -427,10 +427,83 @@ fn fill_poly(cv: &mut Canvas, pts: &[(f32, f32)], mut color_at: impl FnMut(f32, } } -// ---- stroke (distance field, round caps/joins) ------------------------------ +// ---- stroke (distance field, round caps/joins by default) ------------------- type StrokeSegment = ((f32, f32), (f32, f32)); +// stroke-linecap, in the wire order python/xy/styles.py compiles: +// butt/round/square. XY's default is round, which is what the clamped segment +// distance field below has always drawn, so `stroke` stays the fast path and +// byte-for-byte unchanged; the other two route through `stroke_shaped`. +// +// Joins are always round. That is what the capsule field produced before caps +// were selectable, and it is what every renderer draws today; `stroke-linejoin` +// is not part of the style vocabulary, so there is nothing to select. +pub const CAP_BUTT: u8 = 0; +pub const CAP_ROUND: u8 = 1; +pub const CAP_SQUARE: u8 = 2; + +#[inline] +fn cov_from_sd(sd: f32) -> f32 { + // Same 1px ramp `seg_coverage` uses, expressed on a signed distance so the + // box, disc, and wedge primitives below all antialias identically. + (0.5 - sd).clamp(0.0, 1.0) +} + +/// Signed distance to an oriented box of half-width `hw` around segment `a`-`b` +/// — a capsule with both ends cut flush, i.e. a butt cap. +fn box_sd(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return f32::INFINITY; + } + let (ux, uy) = (dx / len, dy / len); + let (mx, my) = ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5); + let (rx, ry) = (p.0 - mx, p.1 - my); + let along = (rx * ux + ry * uy).abs() - len * 0.5; + let across = (rx * -uy + ry * ux).abs() - hw; + let outside = (along.max(0.0).powi(2) + across.max(0.0).powi(2)).sqrt(); + outside + along.max(across).min(0.0) +} + +/// One coverage primitive of a shaped stroke, in the order they are painted. +enum StrokePiece { + Box(StrokeSegment), + Disc((f32, f32)), +} + +impl StrokePiece { + fn bounds(&self, hw: f32) -> ((f32, f32), (f32, f32)) { + let pad = hw + 1.0; + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let mut extend = |p: (f32, f32)| { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + }; + match self { + StrokePiece::Box((a, b)) => { + extend(*a); + extend(*b); + } + StrokePiece::Disc(c) => extend(*c), + } + ((x0 - pad, y0 - pad), (x1 + pad, y1 + pad)) + } + + fn coverage(&self, p: (f32, f32), hw: f32) -> f32 { + match self { + StrokePiece::Box((a, b)) => cov_from_sd(box_sd(p, *a, *b, hw)), + StrokePiece::Disc(c) => { + cov_from_sd(((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt() - hw) + } + } + } +} + #[inline] fn seg_dist2(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { let (px, py) = p; @@ -461,6 +534,18 @@ fn seg_coverage(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { outer - distance2.sqrt() } +/// Dash lengths a stroke walker can safely step through. +/// +/// Both stroke paths advance by `drem.min(remain)` and subtract, which assumes +/// every entry is finite and positive. The public API cannot produce anything +/// else (`_validate.dash` requires positive px lengths), but `rasterize_into` +/// decodes arbitrary bytes, and this is where the rest of that validation +/// lives. A pattern with any non-positive or non-finite entry is treated as +/// solid rather than half-walked. +fn usable_dash(dash: &[f32]) -> bool { + !dash.is_empty() && dash.iter().all(|d| d.is_finite() && *d > 0.0) +} + /// Rasterize on-segments into a scratch coverage buffer (max-combined so /// overlapping joins don't double-darken), then composite once. fn stroke( @@ -474,6 +559,177 @@ fn stroke( stroke_with_threads(cv, pts, width, rgba, closed, dash, None); } +/// Stroke honoring an explicit cap. `round` is XY's default and is exactly what +/// the capsule field above draws, so it delegates and the common path keeps its +/// banding, its scratch-buffer reuse, and its bytes. Interior vertices always +/// get a round join, which is what the capsule field produced before the cap +/// became selectable. +fn stroke_shaped( + cv: &mut Canvas, + pts: &[(f32, f32)], + width: f32, + rgba: [f32; 4], + closed: bool, + dash: &[f32], + cap: u8, +) { + if cap == CAP_ROUND || pts.len() < 2 || width <= 0.0 { + stroke(cv, pts, width, rgba, closed, dash); + return; + } + let hw = width * 0.5; + let dash: &[f32] = if usable_dash(dash) { dash } else { &[] }; + let n = pts.len(); + let last = if closed { n } else { n - 1 }; + let raw: Vec = (0..last).map(|i| (pts[i], pts[(i + 1) % n])).collect(); + + // Each run is a maximal stretch of painted stroke: the whole polyline when + // undashed, one per "on" interval otherwise. Caps shape a run's two ends; + // joins shape the vertices inside it. SVG caps every dash the same way, so + // a dashed line gets one pair of caps per run rather than one per line. + let mut runs: Vec> = Vec::new(); + if dash.is_empty() { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let total: f32 = dash.iter().sum(); + if total <= 0.0 { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let (mut di, mut drem, mut on) = (0usize, dash[0], true); + let mut current: Vec<(f32, f32)> = Vec::new(); + for (a, b) in &raw { + let (mut ax, mut ay) = *a; + let seglen = ((b.0 - ax).powi(2) + (b.1 - ay).powi(2)).sqrt(); + if seglen <= 1e-9 { + continue; + } + let (ux, uy) = ((b.0 - a.0) / seglen, (b.1 - a.1) / seglen); + let mut remain = seglen; + while remain > 1e-6 { + let step = drem.min(remain); + let next = (ax + ux * step, ay + uy * step); + if on { + if current.is_empty() { + current.push((ax, ay)); + } + current.push(next); + } + ax = next.0; + ay = next.1; + remain -= step; + drem -= step; + if drem <= 1e-6 { + di = (di + 1) % dash.len(); + drem = dash[di]; + on = !on; + if !on && !current.is_empty() { + runs.push(std::mem::take(&mut current)); + } + } + } + } + if !current.is_empty() { + runs.push(current); + } + } + } + + let mut pieces: Vec = Vec::new(); + for run in &runs { + if run.len() < 2 { + continue; + } + let mut ends = run.clone(); + if cap == CAP_SQUARE { + // A square cap is a butt cap on a segment pushed out by hw, which + // is the whole difference between the two. + let extend = |from: (f32, f32), to: (f32, f32)| -> (f32, f32) { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return to; + } + (to.0 + dx / len * hw, to.1 + dy / len * hw) + }; + let head = extend(run[1], run[0]); + let tail = extend(run[run.len() - 2], run[run.len() - 1]); + ends[0] = head; + let end = ends.len() - 1; + ends[end] = tail; + } + for pair in ends.windows(2) { + pieces.push(StrokePiece::Box((pair[0], pair[1]))); + } + // Butt- and square-capped boxes meet edge-to-edge and leave a notch on + // the outside of every turn; a disc at the shared vertex is the round + // join that fills it. + for vertex in run.iter().take(run.len() - 1).skip(1) { + pieces.push(StrokePiece::Disc(*vertex)); + } + if closed && runs.len() == 1 && run.len() > 2 { + pieces.push(StrokePiece::Disc(run[0])); + } + } + paint_stroke_pieces(cv, &pieces, hw, rgba); +} + +/// Max-combine every piece's coverage into one scratch buffer, then composite +/// each touched pixel once — the same contract `stroke_with_threads` keeps, so +/// overlapping joins never double-darken a translucent stroke. +fn paint_stroke_pieces(cv: &mut Canvas, pieces: &[StrokePiece], hw: f32, rgba: [f32; 4]) { + if pieces.is_empty() { + return; + } + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + x0 = x0.min(px0); + y0 = y0.min(py0); + x1 = x1.max(px1); + y1 = y1.max(py1); + } + let (bx0, by0, bx1, by1) = cv.bbox(x0, y0, x1, y1); + if bx1 <= bx0 || by1 <= by0 { + return; + } + let (sw, sh) = (bx1 - bx0, by1 - by0); + let mut scratch = vec![0u8; sw * sh]; + let mut touched = Vec::::with_capacity(pieces.len().saturating_mul(8)); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + let sx0 = (px0.floor().max(bx0 as f32) as usize).max(bx0); + let sy0 = (py0.floor().max(by0 as f32) as usize).max(by0); + let sx1 = (px1.ceil().max(0.0) as usize).min(bx1); + let sy1 = (py1.ceil().max(0.0) as usize).min(by1); + for y in sy0..sy1 { + for x in sx0..sx1 { + let c = piece.coverage((x as f32 + 0.5, y as f32 + 0.5), hw); + if c <= 0.0 { + continue; + } + let index = (y - by0) * sw + (x - bx0); + let slot = &mut scratch[index]; + if *slot == 0 { + touched.push(index); + } + let coverage = to_u8(c); + if coverage > *slot { + *slot = coverage; + } + } + } + } + for index in touched { + let (row, col) = (index / sw, index % sw); + cv.blend(bx0 + col, by0 + row, rgba, scratch[index] as f32 / 255.0); + } +} + fn stroke_with_threads( cv: &mut Canvas, pts: &[(f32, f32)], @@ -486,6 +742,7 @@ fn stroke_with_threads( if pts.len() < 2 || width <= 0.0 { return; } + let dash: &[f32] = if usable_dash(dash) { dash } else { &[] }; if pts.len() == 2 && !closed && dash.is_empty() { stroke_segment(cv, pts[0], pts[1], width, rgba); return; @@ -1940,7 +2197,8 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - stroke(&mut cv, &pts, width, c, closed, &dash); + let cap = r.u8()?; + stroke_shaped(&mut cv, &pts, width, c, closed, &dash, cap); } OP_POINT => { let (cx, cy, rr) = (r.f32()?, r.f32()?, r.f32()?); @@ -2385,8 +2643,9 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } + let cap = r.u8()?; let points = smooth_points(xs, ys, n, x_scale, y_scale); - stroke(&mut cv, &points, width, color, false, &dash); + stroke_shaped(&mut cv, &points, width, color, false, &dash, cap); } _ => return None, } @@ -2563,12 +2822,73 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); // not closed cmd.extend(u32le(0)); // no dash + cmd.push(CAP_ROUND); let mut out = vec![0u8; 10 * 10 * 4]; assert!(rasterize_into(&cmd, 10, 10, &mut out)); assert!(px(&out, 10, 5, 5)[3] > 200); // on the line assert_eq!(px(&out, 10, 5, 0)[3], 0); // far from it } + /// stroke-linecap changes what is painted past the endpoint, and round — + /// XY's default — must keep drawing exactly what it drew before caps + /// existed, because it is the geometry every committed PNG expectation + /// was rendered with. + #[test] + fn stroke_caps_shape_the_ends() { + let ink = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); // width + cmd.extend([0, 0, 0, 255]); + cmd.push(0); // not closed + cmd.extend(u32le(0)); // no dash + cmd.push(cap); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + (0..40 * 40).filter(|i| out[i * 4 + 3] > 128).count() + }; + // A square cap adds a half-width block at each end; a round cap adds a + // semicircle, which is less; a butt cap adds nothing. + assert!(ink(CAP_BUTT) < ink(CAP_ROUND)); + assert!(ink(CAP_ROUND) < ink(CAP_SQUARE)); + + // Butt clips at the end plane: the pixel a half-width past the last + // point is painted for square, bare for butt. + let probe = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); + cmd.extend([0, 0, 0, 255]); + cmd.push(0); + cmd.extend(u32le(0)); + cmd.push(cap); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + px(&out, 40, 32, 20)[3] + }; + assert_eq!(probe(CAP_BUTT), 0); + assert!(probe(CAP_SQUARE) > 200); + } + + /// A dash pattern the public API cannot produce must not steer the walker. + #[test] + fn malformed_dash_patterns_fall_back_to_solid() { + assert!(usable_dash(&[6.0, 4.0])); + assert!(!usable_dash(&[])); + assert!(!usable_dash(&[6.0, -4.0]), "a negative entry would rewind the walker"); + assert!(!usable_dash(&[6.0, f32::NAN])); + assert!(!usable_dash(&[f32::INFINITY, 4.0])); + assert!(!usable_dash(&[0.0, 4.0])); + } + #[test] fn prepared_segment_path_matches_float_coverage_reference() { let cases = [ @@ -3091,6 +3411,7 @@ mod tests { expanded.extend(stroke_color); expanded.push(1); // closed expanded.extend(u32le(0)); // no dash + expanded.push(CAP_ROUND); } for opaque in [false, true] { diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py new file mode 100644 index 00000000..690ab9ed --- /dev/null +++ b/tests/test_capability_registry.py @@ -0,0 +1,118 @@ +"""The capability registry has to be checkable, or it is just a nicer README. + +`benchmarks/categories.py` set the pattern for performance and left one gap: +nothing asserts that the committed markdown table matches the registry, so +`spec/benchmarks/results.md` is hand-maintained and can drift. This module +closes that gap for customization — the registry is pinned to `styles.py` and +`dom.py`, and the generated document is pinned to the registry. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from xy import styles +from xy.dom import CHART_DOM_SLOTS +from xy.styling import capabilities as caps + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "spec" / "api" / "capability-matrix.md" + + +def _compiled_property_names() -> set[str]: + return { + prop + for kind in styles._MARK_KINDS + for prop in styles._supported_mark_style_properties(kind) + } + + +def test_registry_covers_exactly_the_properties_styles_compiles() -> None: + # The point of the registry is that it cannot quietly fall behind the + # implementation. A new property with no entry fails here; so does an entry + # for a property that was removed. + registered = {p.id for p in caps.MARK_STYLE_PROPERTIES if p.status != "planned"} + assert registered == _compiled_property_names() + + +def test_planned_properties_are_exactly_the_ones_style_still_refuses() -> None: + # There are no planned rows today; the registry lists what ships. The guard + # stays so that if one is ever added, it has to actually be refused — + # a `planned` row the implementation quietly accepts would understate the + # library, which is the direction that is easy to miss. + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.status != "planned": + continue + assert prop.id not in _compiled_property_names(), ( + f"{prop.id!r} is marked planned but styles.py now compiles it" + ) + + +def test_registry_covers_exactly_the_public_dom_slots() -> None: + assert tuple(slot.id for slot in caps.CHART_SLOTS) == CHART_DOM_SLOTS + + +def test_property_kinds_are_derived_not_restated() -> None: + # `kinds` reads from styles.py at access time rather than being typed out, + # so there is no second list to keep in sync. Guard that it stays that way. + for prop in caps.MARK_STYLE_PROPERTIES: + for kind in prop.kinds: + assert prop.id in styles._supported_mark_style_properties(kind) + if prop.status != "planned": + assert prop.kinds, f"{prop.id!r} is shipped but no mark kind accepts it" + + +def test_support_and_status_vocabularies_are_closed() -> None: + for prop in caps.MARK_STYLE_PROPERTIES: + assert set(prop.support) == set(caps.RENDERERS) + assert set(prop.support.values()) <= caps.SUPPORT_LEVELS + assert prop.status in caps.STATUSES + assert prop.vocabulary in caps.VOCABULARIES + for slot in caps.CHART_SLOTS: + assert set(slot.support) == set(caps.SURFACES) + assert set(slot.support.values()) <= caps.SUPPORT_LEVELS + for point in caps.EXTENSION_POINTS: + assert point.status in caps.STATUSES + + +def test_every_partial_or_missing_entry_explains_itself() -> None: + # "none" is allowed and several are deliberate — but an unexplained gap is + # indistinguishable from a bug, so anything short of `full` owes a reason. + for prop in caps.MARK_STYLE_PROPERTIES: + if any(level != "full" for level in prop.support.values()): + assert prop.notes.strip(), f"{prop.id!r} is not full everywhere and says nothing" + for slot in caps.CHART_SLOTS: + if slot.support["native_raster"] == "partial": + assert slot.notes.strip() and slot.channel.strip() + + +def test_the_shipped_extension_point_is_the_one_that_exists() -> None: + import xy + + shipped = {e.id for e in caps.EXTENSION_POINTS if e.status == "shipped"} + assert shipped == {"mark_plugin_composition"} + assert callable(xy.register_mark) + for point in caps.EXTENSION_POINTS: + if point.status == "shipped": + assert point.entry_point and point.limits, f"{point.id!r} claims no limits" + + +def test_the_committed_matrix_is_regenerated_not_hand_edited() -> None: + # The gap `benchmarks/categories.py` left open: a committed table nothing + # checks. Running the generator must be a no-op on a clean tree. + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "gen_capability_matrix.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_matrix_names_every_property_and_slot() -> None: + text = REPORT.read_text(encoding="utf-8") + for prop in caps.MARK_STYLE_PROPERTIES: + assert f"`{prop.id}`" in text + for slot in caps.CHART_SLOTS: + assert f"`{slot.id}`" in text diff --git a/tests/test_claim_guardrails.py b/tests/test_claim_guardrails.py index 93f1b7e9..3ae5024b 100644 --- a/tests/test_claim_guardrails.py +++ b/tests/test_claim_guardrails.py @@ -152,3 +152,24 @@ def test_claim_guardrail_rejects_stale_repo_identity(tmp_path: Path) -> None: findings = check_claim_guardrails.check_claims([path]) assert any("stale repository identity" in finding.message for finding in findings) + + +def test_claim_guardrail_rejects_customization_superlatives(tmp_path: Path) -> None: + # The styling surface is a bounded, enumerated subset — see + # spec/api/capability-matrix.md — so these shapes are wrong however the + # sentence is framed, and no amount of shipping earns them. + for line in ( + "XY is the most customizable charting library.\n", + "XY is fully customizable.\n", + "Style anything you like.\n", + "Customize everything about your chart.\n", + "More extensible than any Python plotting library.\n", + ): + findings = check_claim_guardrails.check_claims([_write(tmp_path, line)]) + assert any("customization superlative" in f.message for f in findings), line + + +def test_claim_guardrail_scans_the_readme(tmp_path: Path) -> None: + # The README was outside the default set, which is where a slogan is most + # likely to be written and least likely to be reviewed. + assert "README.md" in check_claim_guardrails.DEFAULT_DOCS diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 156d1e35..202c5ea3 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -307,3 +307,121 @@ def test_faceting_preserves_concrete_css_style() -> None: grid = chart.figure() assert all(figure.traces[0].style["color"] == "#7c3aed" for figure in grid.figures) + + +def test_line_cap_compiles_to_the_polyline_contract() -> None: + assert compile_mark_style("line", {"stroke-linecap": "butt"}) == {"linecap": "butt"} + + with pytest.raises(ValueError, match=r"must be one of \['butt', 'round', 'square'\]"): + compile_mark_style("line", {"stroke-linecap": "flat"}) + + +def test_line_cap_is_a_polyline_only_property() -> None: + # A cap is polyline geometry. Marks that are not stroked open paths reject + # it rather than accepting a declaration no renderer would draw. + for kind in ("bar", "scatter", "heatmap", "box", "segments"): + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style(kind, {"stroke-linecap": "butt"}) + + +def test_stroke_linejoin_is_not_offered_until_the_client_draws_joins() -> None: + # SVG and the native rasterizer implement all three joins; the WebGL client + # has no join geometry at all. Until it does, the property is refused + # rather than honored by two renderers out of three. + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"stroke-linejoin": "miter"}) + + +def test_default_cap_stays_off_the_wire() -> None: + # XY's default is round in every renderer, so a spec that asks for it + # explicitly must stay byte-identical to one that never mentions it. + plain = xy.chart(xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0])).figure() + explicit = xy.chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "round"}) + ).figure() + + assert "linecap" not in explicit.traces[0].style + assert plain.build_payload()[0] == explicit.build_payload()[0] + + +def test_cap_rides_the_spec_for_the_line_family() -> None: + for mark in ( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.step(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.ecdf(values=[0.0, 1.0, 2.0], style={"stroke-linecap": "square"}), + ): + spec, _ = xy.chart(mark).figure().build_payload() + assert spec["traces"][0]["style"]["linecap"] == "square" + + +def test_cap_reaches_svg_and_native_renderers() -> None: + fig = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke-width": "9px", "stroke-linecap": "butt"}, + ) + ).figure() + + svg = fig.to_svg() + assert 'stroke-linecap="butt"' in svg + # The SVG writer names the join too, so the PDF exporter (which reads these + # attributes straight back out) cannot fall through to SVG's `miter`. + assert 'stroke-linejoin="round"' in svg + + # The native rasterizer must actually draw a different shape, not just + # accept the keyword: a square cap paints past the endpoint, butt does not. + def _ink(cap: str) -> int: + figure = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke": "#ff0000", "stroke-width": "9px", "stroke-linecap": cap}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 0] > image[:, :, 2])) + + assert _ink("square") > _ink("butt") + + +def test_marker_shape_css_selects_the_scatter_symbol() -> None: + assert compile_mark_style("scatter", {"marker-shape": "diamond"}) == {"symbol": "diamond"} + with pytest.raises(ValueError, match="must be one of"): + compile_mark_style("scatter", {"marker-shape": "blob"}) + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"marker-shape": "diamond"}) + + fig = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=12, + style={"marker-shape": "square", "fill": "#22c55e"}, + ) + ).figure() + spec, blob = fig.build_payload() + assert spec["traces"][0]["style"]["symbol"] == "square" + + # A square marker fills its bounding box; a circle of the same size cannot. + def _ink(shape: str) -> int: + figure = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=24, + style={"marker-shape": shape, "fill": "#22c55e", "opacity": 1}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 1] > image[:, :, 2])) + + assert _ink("square") > _ink("circle") + + +def test_marker_shape_css_loses_to_no_one_but_agrees_with_the_symbol_argument() -> None: + css = xy.chart( + xy.scatter(x=[0.0], y=[1.0], symbol="circle", style={"marker-shape": "triangle"}) + ).figure() + argument = xy.chart(xy.scatter(x=[0.0], y=[1.0], symbol="triangle")).figure() + assert css.build_payload()[0] == argument.build_payload()[0] diff --git a/tests/test_export_style_survival.py b/tests/test_export_style_survival.py new file mode 100644 index 00000000..77682bc4 --- /dev/null +++ b/tests/test_export_style_survival.py @@ -0,0 +1,99 @@ +"""Which styling survives which export path is a published contract, not luck. + +Browser chrome is CSS-addressable; the native SVG/raster/PDF writers have no +cascade and read a strict subset of the same spec. Both facts are fine. What is +not fine is leaving the boundary undocumented, so this module pins it: every +assertion here corresponds to a row in `spec/api/export.md` § "What styling +survives which export path" and in +`docs/api-reference/limitations-and-alpha-status.md`. +""" + +from __future__ import annotations + +import re + +import pytest + +import xy +from xy import Engine, _raster +from xy.dom import CHART_DOM_SLOTS + + +def _styled_chart() -> xy.Chart: + return xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + title="title", + class_names={slot: f"cls-{slot}" for slot in CHART_DOM_SLOTS}, + styles={slot: {"outline_color": "#123456"} for slot in CHART_DOM_SLOTS}, + style={"--chart-bg": "#101820"}, + ) + + +def test_browser_spec_carries_every_slot_class_and_style() -> None: + # The browser client applies dom.class_names / dom.styles to all 23 slots + # (js/src/50_chartview.ts _applySlot), so the spec must carry all 23. + spec, _ = _styled_chart().figure().build_payload() + dom = spec["dom"] + + assert set(dom["class_names"]) == set(CHART_DOM_SLOTS) + assert set(dom["styles"]) == set(CHART_DOM_SLOTS) + + +def test_native_writers_read_chart_style_and_nothing_per_slot() -> None: + # python/xy/_svg.py:767,1481 and python/xy/_raster.py:662 read + # spec["dom"]["style"] — the chart-level token bag — and never + # spec["dom"]["styles"] or spec["dom"]["class_names"]. + figure = _styled_chart().figure() + svg = figure.to_svg() + + assert "#101820" in svg, "chart-level style tokens must reach native output" + assert "#123456" not in svg, "per-slot styles must not reach native output" + assert "class=" not in svg, "the SVG writer emits no class attributes" + for slot in CHART_DOM_SLOTS: + assert f"cls-{slot}" not in svg + + +def test_legend_is_the_one_slot_with_a_parallel_native_channel() -> None: + # xy.legend(style=...) is written twice: to chrome_styles (browser) and to + # legend_options["style"], which the native writers do read. The + # chart-level styles={"legend": ...} form only reaches the browser. + through_component = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + xy.legend(style={"background": "#123456"}), + ).figure() + assert "#123456" in through_component.to_svg() + + through_slot = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + styles={"legend": {"background": "#123456"}}, + ).figure() + assert "#123456" not in through_slot.to_svg() + + +def test_custom_css_is_refused_by_every_native_path() -> None: + # custom_css is a browser stylesheet. Native export rejects it by name + # rather than dropping it, and SVG rejects it for every engine because no + # browser can emit vector SVG. + chart = _styled_chart() + + for fmt in ("png", "pdf", "jpeg", "webp"): + with pytest.raises( + ValueError, match=re.escape("custom_css requires engine=Engine.chromium") + ): + chart.to_image(format=fmt, engine=Engine.default, custom_css=".x{color:red}") + for engine in (Engine.auto, Engine.default, Engine.chromium): + with pytest.raises(ValueError, match=r"SVG export is native-only|custom_css requires"): + chart.to_image(format="svg", engine=engine, custom_css=".x{color:red}") + + +def test_native_raster_matches_the_svg_writer_on_slot_styling() -> None: + # The two native writers must agree with each other, not only with the doc: + # neither honors a per-slot style, so the styled and unstyled renders are + # pixel-identical. + styled = _raster.render_raster(*_styled_chart().figure().build_payload(), scale=1) + plain = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + title="title", + style={"--chart-bg": "#101820"}, + ).figure() + assert (styled == _raster.render_raster(*plain.build_payload(), scale=1)).all() diff --git a/tests/test_mark_plugins.py b/tests/test_mark_plugins.py new file mode 100644 index 00000000..527fe2fc --- /dev/null +++ b/tests/test_mark_plugins.py @@ -0,0 +1,236 @@ +"""A mark plugin composes built-in marks and gets the engine for free (§24). + +The containment argument is the whole design: a plugin returns `Mark` objects +and never touches the `Figure`, the trace list, or the column store, so its +traces cannot differ from hand-written ones in decimation, picking, hover, a11y, +or any export path. These tests hold that argument rather than merely checking +that the registry accepts a callable. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy + + +def _hilo_plugin(name: str = "hilo") -> xy.MarkPlugin: + def calc(columns): + return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0} + + def build(ctx): + return [ + xy.segments( + x0=ctx.columns["t"], + x1=ctx.columns["t"], + y0=ctx.columns["low"], + y1=ctx.columns["high"], + name=ctx.name, + style=ctx.style, + ), + xy.scatter( + x=ctx.columns["t"], + y=ctx.columns["mid"], + size=ctx.options.get("mid_size", 6), + ), + ] + + return xy.MarkPlugin(name=name, columns=("t", "low", "high"), calc=calc, build=build) + + +@pytest.fixture +def hilo(): + plugin = xy.register_mark(_hilo_plugin()) + try: + yield plugin + finally: + xy.unregister_mark(plugin.name) + + +def test_a_plugin_mark_compiles_to_ordinary_traces(hilo) -> None: + chart = xy.chart( + xy.mark("hilo", t=[0.0, 1.0, 2.0], low=[1.0, 2.0, 1.5], high=[3.0, 4.0, 3.5], name="band") + ) + fig = chart.figure() + + # One plugin mark, two primitives, and nothing about them marks them as + # second-class: they are the same kinds the built-in constructors produce. + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + spec, _ = fig.build_payload() + assert {t["kind"] for t in spec["traces"]} == {"segments", "scatter"} + + +def test_calc_runs_before_build_and_its_columns_are_what_build_sees(hilo) -> None: + fig = xy.chart(xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 3.0], high=[3.0, 5.0])).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert list(scatter.y.values) == [2.0, 4.0] + + +def test_declared_columns_resolve_from_data_like_a_built_in_mark(hilo) -> None: + frame = {"t": [0.0, 1.0], "lo": [1.0, 2.0], "hi": [3.0, 4.0]} + fig = xy.chart(xy.mark("hilo", t="t", low="lo", high="hi", data=frame)).figure() + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + + with pytest.raises(ValueError, match=r"hilo.low column 'nope' not found"): + xy.chart(xy.mark("hilo", t="t", low="nope", high="hi", data=frame)).figure() + + +def test_undeclared_keywords_reach_the_plugin_as_options(hilo) -> None: + fig = xy.chart( + xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 2.0], high=[3.0, 4.0], mid_size=14) + ).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert scatter.size_ch is not None and scatter.size_ch.constant == pytest.approx(14.0) + + +def test_plugin_traces_reach_every_renderer(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0, 2.0], + low=[1.0, 2.0, 1.5], + high=[3.0, 4.0, 3.5], + style={"stroke": "#ff0000"}, + ) + ).figure() + + assert "#ff0000" in fig.to_svg() + from xy import _raster + + image = _raster.render_raster(*fig.build_payload(), scale=1) + assert np.any(image[:, :, 0] > image[:, :, 2]) + + +def test_a_plugin_mark_honors_named_axes(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0], + low=[1.0, 2.0], + high=[3.0, 4.0], + y_axis="y2", + ), + xy.y_axis(id="y2"), + ).figure() + assert all(t.y_axis == "y2" for t in fig.traces) + + +def test_the_registry_refuses_to_shadow_or_silently_replace() -> None: + with pytest.raises(ValueError, match="built-in mark kind"): + xy.register_mark(xy.MarkPlugin(name="scatter", build=lambda ctx: [])) + + first = xy.register_mark(_hilo_plugin("twice")) + try: + with pytest.raises(ValueError, match="already registered"): + xy.register_mark(_hilo_plugin("twice")) + assert xy.register_mark(_hilo_plugin("twice"), replace=True) is not first + finally: + xy.unregister_mark("twice") + + with pytest.raises(ValueError, match="must be an identifier"): + xy.register_mark(xy.MarkPlugin(name="not a name", build=lambda ctx: [])) + + +def test_registered_marks_lists_only_contributed_kinds(hilo) -> None: + assert "hilo" in xy.registered_marks() + assert "scatter" not in xy.registered_marks() + xy.unregister_mark("hilo") + assert "hilo" not in xy.registered_marks() + xy.register_mark(_hilo_plugin()) + + +def test_an_unknown_plugin_name_fails_at_construction_not_at_compile() -> None: + with pytest.raises(ValueError, match="unknown mark plugin 'nope'"): + xy.mark("nope", x=[0.0]) + + +def test_build_must_return_built_in_marks() -> None: + def bad_type(ctx): + return "not marks" + + def nested(ctx): + return [xy.mark("inner", x=[0.0])] + + xy.register_mark(xy.MarkPlugin(name="badtype", build=bad_type)) + xy.register_mark(xy.MarkPlugin(name="inner", build=lambda ctx: [])) + xy.register_mark(xy.MarkPlugin(name="nested", build=nested)) + try: + with pytest.raises(TypeError, match="must return a sequence of marks"): + xy.chart(xy.mark("badtype")).figure() + # One level only: plugins compose built-ins, not each other. + with pytest.raises(TypeError, match=re.escape("plugins compose built-in marks only")): + xy.chart(xy.mark("nested")).figure() + finally: + for name in ("badtype", "inner", "nested"): + xy.unregister_mark(name) + + +def test_calc_must_return_a_mapping() -> None: + xy.register_mark( + xy.MarkPlugin( + name="badcalc", columns=("x",), calc=lambda cols: [1, 2], build=lambda ctx: [] + ) + ) + try: + with pytest.raises(TypeError, match="calc must return a mapping"): + xy.chart(xy.mark("badcalc", x=[0.0])).figure() + finally: + xy.unregister_mark("badcalc") + + +def test_a_plugin_cannot_reach_the_figure(hilo) -> None: + seen = {} + + def build(ctx): + seen["ctx"] = ctx + return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])] + + xy.register_mark(xy.MarkPlugin(name="probe", columns=("t", "low"), build=build)) + try: + xy.chart(xy.mark("probe", t=[0.0, 1.0], low=[1.0, 2.0])).figure() + finally: + xy.unregister_mark("probe") + + ctx = seen["ctx"] + assert set(vars(ctx)) == {"columns", "options", "name", "style", "class_name"} + assert not any(hasattr(ctx, attr) for attr in ("figure", "traces", "store", "fig")) + + +def test_a_plugin_cannot_claim_a_name_that_xy_mark_binds_itself() -> None: + # `xy.mark(name=...)` is the series label, so a plugin column called `name` + # could never be passed — the value would bind to the parameter and the + # column would resolve to None. Reject the schema, not the call site. + for reserved in ("name", "style", "data", "x_axis", "key"): + with pytest.raises(ValueError, match=re.escape("xy.mark() binds itself")): + xy.register_mark( + xy.MarkPlugin(name="clashing", columns=(reserved,), build=lambda ctx: []) + ) + assert "clashing" not in xy.registered_marks() + + +def test_declared_columns_reach_calc_as_canonical_f64(hilo) -> None: + # Canonical data is CPU-side f64 (§27). A float32 input must widen before + # `calc` runs, or the arithmetic rounds at f32 and hands the loss to a + # built-in mark that stores f64 anyway. + seen = {} + + def build(ctx): + seen["dtypes"] = {k: v.dtype for k, v in ctx.columns.items()} + return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])] + + xy.register_mark(xy.MarkPlugin(name="dtypes", columns=("t", "low"), build=build)) + try: + xy.chart( + xy.mark( + "dtypes", + t=np.array([0.0, 1.0], dtype=np.float32), + low=np.array([1, 2], dtype=np.int16), + ) + ).figure() + finally: + xy.unregister_mark("dtypes") + + assert all(dtype == np.float64 for dtype in seen["dtypes"].values()) diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 2bd2f76b..b1dd0e1a 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -803,7 +803,9 @@ def test_client_renders_mark_level_styling() -> None: "xyMarkerSdf(d, u_symbol)", # scatter symbol shapes (circle/square/diamond/triangle/cross) "_pointMarkStyle(", # point stroke + symbol resolution "rgb = mix(rgb, sc.rgb, sc.a);", # selected/unselected recolor (mark_style) - "v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x);", # fractional reveal preserves line dashes + "float dashEnd = mix(a_len0, a_len1, reveal);", # fractional reveal preserves line dashes + "uniform int u_cap; uniform int u_capSegments;", # stroke-linecap on the polyline's ends + "LINE_CAP_MODES", # cap keywords resolve to the shared wire codes "_lineDash(g)", "_resolveMarkFill(", "_setRectStyleUniforms(",