From b8be62c77259f6c6a7a146fbb8335e18571edf74 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 15:36:57 -0700 Subject: [PATCH 1/9] Add funnel charts to the core declarative API (protocol v13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xy.funnel_chart(stages, values)` / `xy.funnel(...)` draw one centered segment per stage of an ordered process. Stage order is the DECLARED order — a funnel is a categorical business process and is never sorted — and the conversion arithmetic ships with every stage rather than being left to the reader. The kernel (python/xy/_funnel.py) owns all of it at build time, the way `_sankey` owns the Sankey placement: validation, conversion/drop-off math, quad construction and the label ladder. Renderers only ever see quads plus semantic rows. Geometry is explicit because the two classic funnel drawings encode differently: `geometry="area"` tapers each segment toward the next stage (so painted area is NOT proportional to value, and the docs say so), while `geometry="bar"` gives constant-width segments whose widths carry the values exactly. `neck="rect"|"taper"` decides the spout, `gap` resolves per geometry (0 for area, 0.2 for bar), and `min_width` floors DRAWN widths so a zero stage stays visible and hoverable while its reported value stays exact. Arithmetic: overall share, previous-stage conversion, and drop-off, with `None` — rendered as an em dash — wherever a denominator is zero, never an invented number or an infinity. Increasing stages are legal and drawn honestly (conversion > 1, a signed `+12%` boundary label); negative and missing values are refused by stage name. Renderers: a new `funnel` kind. The client sweeps a 4-vertex strip per stage through FUNNEL_VS sharing RIBBON_FS — that fragment stage is where the slanted edges get their fwidth coverage, without which the long diagonals staircase on the antialias:false context. Both exporters build their corners from `_scene.funnel_quad`, the single reference the golden tests pin, so SVG and PNG cannot drift from each other or from the client. Interaction: CPU trapezoid containment returns the STAGE index with its semantic row (picking stays off, so box/lasso selection is absent rather than wrong, as for ribbon); the tooltip follows the cursor inside a segment because a segment is an area, not a point; legend rows toggle a stage's geometry AND its labels, which needed an annotation ownership tag on the wire; and `stageNav` puts the per-stage centers into keyboard traversal so a screen reader hears "Stage 2 of 5" plus that stage's arithmetic. Formatting is Python's alone: `value_format`/`percent_format` are applied once and shipped as preformatted `*_text` twins beside the numeric event fields, so a label, a tooltip and a static export cannot disagree. Two parity bugs fixed on the way, both pre-existing and both visible the moment a funnel drew its own labels: `_scene`/`_svg` had a load-order cycle that broke the capability-matrix generator, and annotation labels in both exporters ignored `--chart-annotation-text`/`--chart-text` while the live client honored them — a themed dark chart printed its labels in the light-mode default. PROTOCOL_VERSION 12 -> 13 in lockstep with the client: markOf() falls back to scatter for unknown kinds, so a v12 client would silently render funnel quads as a point cloud. Tests: 71 new (stage math incl. zero/increasing/repeated/negative, declared-order determinism, quad geometry, label ladder, wire shape, event semantics, styling surface, legend ownership, export parity against the scene reference) plus a funnel probe in the render smoke asserting per-stage palette ink, containment hover, and a11y stage nav. Suite 3,904 passing, docs-site suite green, ruff/ty clean, capability matrix regenerated. --- CHANGELOG.md | 18 + docs/app/tests/test_docs_site.py | 17 +- docs/app/xy_docs/api_reference.py | 2 + docs/app/xy_docs/config.py | 1 + docs/app/xy_docs/gallery.py | 4 + docs/app/xy_docs/sidebar.py | 2 +- docs/charts/funnel-chart.md | 237 ++++++++ docs/components/marks.md | 2 + docs/overview/gallery.md | 3 +- docs/styling/capabilities.md | 12 +- docs/styling/mark-styles.md | 1 + js/src/00_header.ts | 4 +- js/src/40_gl.ts | 41 ++ js/src/50_chartview.ts | 233 +++++++- js/src/51_annotations.ts | 17 +- js/src/52_tooltip.ts | 48 +- js/src/53_interaction.ts | 6 +- js/src/55_marks.ts | 24 + python/xy/__init__.py | 6 + python/xy/_annotations.py | 17 +- python/xy/_figure.py | 10 + python/xy/_funnel.py | 375 ++++++++++++ python/xy/_payload.py | 59 ++ python/xy/_raster.py | 92 ++- python/xy/_scene.py | 25 + python/xy/_svg.py | 97 ++- python/xy/components.py | 227 +++++++ python/xy/config.py | 5 +- python/xy/marks.py | 292 +++++++++ python/xy/styles.py | 21 +- scripts/render_smoke_nonumpy.py | 56 +- spec/api/capability-matrix.md | 12 +- spec/api/chart-kind-contract.md | 67 ++- spec/api/chart-roadmap.md | 2 +- spec/design/wire-protocol.md | 8 +- tests/pyplot/test_tick_side_rendering.py | 2 +- tests/test_accessibility_contract.py | 2 +- tests/test_api_parity.py | 2 + tests/test_check_typing.py | 2 +- tests/test_funnel.py | 723 +++++++++++++++++++++++ tests/test_polar_phase7_api.py | 4 +- tests/test_sankey.py | 2 +- tests/test_type_surface.py | 1 + 43 files changed, 2722 insertions(+), 59 deletions(-) create mode 100644 docs/charts/funnel-chart.md create mode 100644 python/xy/_funnel.py create mode 100644 tests/test_funnel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e41d41..4997ece2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ in the README). ## [Unreleased] +### Added +- Funnel charts joined the core declarative API (protocol v13): + `xy.funnel_chart(stages, values)` / `xy.funnel(...)` draw one centered + segment per stage in declared order — never sorted — with explicit + `geometry="area"|"bar"` modes, `neck="rect"|"taper"`, per-geometry segment + gaps, and a `min_width` floor that keeps zero/tiny stages visible without + touching their reported values. Conversion arithmetic (value, prior, overall + share, previous-stage conversion, drop-off; `None` over zero denominators) + rides labels with a documented inside/outside/hidden collision ladder, + hover tooltips, click events, and ordered keyboard traversal with + screen-reader announcements. Per-stage colors are a categorical channel over + the stage names (theme `palette={...}` mappings pin by stage name; legend + rows opt in via `xy.legend(...)`), and per-trace `stroke`/`stroke-width`/ + opacity style compiles to all three renderers. The client draws + antialiased quads through a dedicated funnel program sharing the ribbon + fragment stage; SVG/PNG/PDF exports emit the same `_scene.funnel_quad` + geometry, pinned by golden tests. + ## [0.0.5] - 2026-07-31 ### Added diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index 94544dc4..43fe4c2a 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -1279,18 +1279,18 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( chart_section = next( leaves for title, _landing_route, _icon, leaves in DOCS_SECTIONS if title == "Chart Gallery" ) - assert len(chart_section) == 20 + assert len(chart_section) == 21 assert "XYChart" not in rendered - assert rendered.count("dangerouslySetInnerHTML") == 34 + assert rendered.count("dangerouslySetInnerHTML") == 35 assert rendered.count('id:"xy-chart-gallery"') == 1 assert rendered.count("main:has(#xy-chart-gallery) > div:has(#toc-navigation)") == 1 assert rendered.count("main:has(#xy-chart-gallery) > div:has(article #xy-chart-gallery)") == 1 assert rendered.count("display: none") == 1 assert rendered.count("max-width: 88rem") == 1 assert rendered.count("2xl:grid-cols-3") == 9 - assert rendered.count("aspect-[320/232]") == 34 - assert rendered.count("shadow-large") == 34 - assert rendered.count("transition-bg") == 34 + assert rendered.count("aspect-[320/232]") == 35 + assert rendered.count("shadow-large") == 35 + assert rendered.count("transition-bg") == 35 assert "--gallery-preview-surface: #fff" in rendered assert "--gallery-preview-fill: #efeaff" in rendered assert "--gallery-preview-soft: #dccfff" in rendered @@ -1301,7 +1301,7 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( assert "object-contain" not in rendered assert "object-center" not in rendered assert "xy-tailwind-bridge" not in rendered - assert rendered.count("size:14") == 34 + assert rendered.count("size:14") == 35 assert "size:6" not in rendered for chart_type in ( "Line", @@ -1372,7 +1372,7 @@ def test_chart_gallery_inline_svgs_share_the_component_preview_style() -> None: for item in group.items } - assert len(previews) == 34 + assert len(previews) == 35 for svg in previews.values(): assert 'viewBox="0 0 320 232"' in svg assert ' None: titles = {item.title for group in _GALLERY_GROUPS for item in group.items} section_titles = [group.title for group in _GALLERY_GROUPS] - assert len(titles) == 34 + assert len(titles) == 35 assert section_titles[:3] == [ "Line and Area", "Distributions", @@ -1566,6 +1566,7 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: ("Stem", "/charts/stem-plot/"), ("Segments", "/charts/segments/"), ("Sankey", "/charts/sankey/"), + ("Funnel", "/charts/funnel-chart/"), ("Triangle Mesh", "/components/triangle-mesh/"), ] assert {"Step + Stairs", "Bar + Column"} <= titles diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index e295e13e..9631ce40 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -70,6 +70,7 @@ xy.stem_chart, xy.segments_chart, xy.sankey_chart, + xy.funnel_chart, xy.triangle_mesh_chart, ), ), @@ -114,6 +115,7 @@ xy.segments, xy.ribbon, xy.sankey, + xy.funnel, xy.triangle_mesh, ) diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index d74f65e4..c574103e 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -76,6 +76,7 @@ ("Stem", "/charts/stem-plot/"), ("Segments", "/charts/segments/"), ("Sankey", "/charts/sankey/"), + ("Funnel", "/charts/funnel-chart/"), ), ), ( diff --git a/docs/app/xy_docs/gallery.py b/docs/app/xy_docs/gallery.py index bd788131..42ab2fc5 100644 --- a/docs/app/xy_docs/gallery.py +++ b/docs/app/xy_docs/gallery.py @@ -177,6 +177,7 @@ class GalleryGroup: GalleryItem("Stem", route="/charts/stem-plot/"), GalleryItem("Segments", route="/charts/segments/"), GalleryItem("Sankey", route="/charts/sankey/"), + GalleryItem("Funnel", route="/charts/funnel-chart/"), GalleryItem("Triangle Mesh", route="/components/triangle-mesh/"), ), ), @@ -275,6 +276,9 @@ class GalleryGroup: """, "Sankey": """ +""", + "Funnel": """ + """, "Triangle Mesh": """ diff --git a/docs/app/xy_docs/sidebar.py b/docs/app/xy_docs/sidebar.py index 3e697731..fc41228e 100644 --- a/docs/app/xy_docs/sidebar.py +++ b/docs/app/xy_docs/sidebar.py @@ -63,7 +63,7 @@ "shapes", tuple( (title, _chart_gallery_routes[title]) - for title in ("Uncertainty", "Stem", "Segments", "Sankey") + for title in ("Uncertainty", "Stem", "Segments", "Sankey", "Funnel") ), ), ) diff --git a/docs/charts/funnel-chart.md b/docs/charts/funnel-chart.md new file mode 100644 index 00000000..940f0b17 --- /dev/null +++ b/docs/charts/funnel-chart.md @@ -0,0 +1,237 @@ +--- +title: Funnel Chart in Python +description: Create interactive funnel charts in Python with xy. Show stage-based conversion and drop-off with ordered stages, honest geometry modes, and per-stage labels. +components: + - xy.funnel_chart +--- + +# Funnel Charts in Python + +A **funnel chart** shows how a quantity survives an ordered process — visitors +becoming signups becoming customers, candidates advancing through interviews, +tickets moving toward resolution. Each stage is one centered segment, and the +narrowing silhouette makes conversion and drop-off legible at a glance. + +With `xy`, pass stage names and values to `funnel_chart`. Stage order is +**always the declared order** — a funnel is a categorical business process, +and XY never sorts it. Conversion and drop-off arithmetic ship with every +stage: labels, tooltips, and click events all carry the stage name, value, +prior value, overall share, previous-stage conversion, and drop-off. + +Jump to [the basic chart](#create-a-funnel-chart), +[geometry modes](#choose-honest-geometry), +[horizontal funnels](#run-the-funnel-horizontally), +[necks, gaps, and floors](#shape-the-silhouette), or +[styling](#style-a-funnel). + +## Create a Funnel Chart + +~~~python demo exec +import reflex_xy +import xy + +signup_funnel = xy.funnel_chart( + ["Visit", "Signup", "Activate", "Trial", "Pay"], + [9_800, 6_200, 3_100, 2_200, 1_450], + show_dropoff=True, + title="Signup funnel", +) + + +def funnel_chart_demo(): + return reflex_xy.chart(signup_funnel, height="440px") +~~~ + +Each segment shows its value and overall conversion (`show_conversion` +appends the share of the first stage), and `show_dropoff=True` writes the +signed stage-over-stage change at each boundary. Hovering a segment reads the +full arithmetic: value, overall share, previous-stage conversion, and +drop-off. A label that cannot fit inside its segment moves beside it, and a +stage pitch too short for a text line hides the labels rather than +overlapping them — the tooltip always carries every number. + +## Choose Honest Geometry + +`geometry` is explicit because the two classic funnel drawings encode +differently: + +- `"area"` (default) draws the tapering silhouette — each segment's far edge + previews the **next** stage's width, so drop-off is visible as slope. The + painted area of a segment is therefore *not* proportional to its value. +- `"bar"` draws centered constant-width segments whose widths carry the + values exactly — the faithful-width encoding. + +~~~python demo exec +import reflex_xy +import xy + +activation_bars = xy.funnel_chart( + ["Install", "Open", "Signup", "Invite", "Re-engage", "Subscribe"], + [92_000, 64_000, 30_500, 12_200, 13_300, 5_100], + geometry="bar", + gap=0.3, + show_dropoff=True, + title="Activation — note the re-engagement bulge", +) + + +def funnel_geometry_demo(): + return reflex_xy.chart(activation_bars, height="460px") +~~~ + +Increasing stages are legal and drawn honestly: `Re-engage` is wider than +`Invite`, its conversion is above one, and its boundary label reads `+9%`. +Negative and missing values are refused by stage name; a zero stage draws +nothing (or a floor sliver — see `min_width`) but keeps its labels, tooltip, +and keyboard stop. + +## Run the Funnel Horizontally + +~~~python demo exec +import reflex_xy +import xy + +ticket_flow = xy.funnel_chart( + ["Opened", "Triaged", "Escalated", "Eng fix", "Refunded"], + [48_210, 31_600, 8_200, 0, 1_240], + orientation="horizontal", + geometry="bar", + min_width=0.03, + value_format="{:,.0f}", + title="Support ticket flow (30 days)", +) + + +def funnel_horizontal_demo(): + return reflex_xy.chart(ticket_flow, height="380px") +~~~ + +`orientation="horizontal"` runs stage 0 from the left; vertical funnels put +stage 0 on top (the stage axis is reversed exactly like a Sankey's). The +cross axis is layout, not data — segments center on zero — so `funnel_chart` +hides it. Here `min_width=0.03` keeps the zero-valued `Eng fix` stage visible +and hoverable as a floor sliver: drawn geometry is clamped, but every label, +tooltip, and event value stays exact. + +## Shape the Silhouette + +- `gap` separates segments along the stage axis as a fraction of the stage + pitch. It resolves per geometry when unset: `0` for `"area"` (a continuous + silhouette), `0.2` for `"bar"` (bar-chart spacing). +- `neck` decides the last area segment's far edge: `"rect"` (default) holds + the stage's own width; `"taper"` runs it to a point — the classic spout. +- `min_width` floors drawn cross widths at a fraction of the widest stage so + tiny stages stay visible. The taper spout deliberately ignores the floor. + +~~~python demo exec +import reflex_xy +import xy + +checkout = xy.funnel_chart( + ["Cart", "Address", "Payment", "Review", "Placed"], + [30_400, 21_100, 15_800, 14_100, 13_900], + xy.theme( + background="#0b1020", + plot_background="#0b1020", + text_color="#e2e8f0", + grid_color="#1f2a44", + axis_color="#334155", + ), + colors=["#38bdf8", "#22d3ee", "#2dd4bf", "#34d399", "#4ade80"], + neck="taper", + show_dropoff=True, + title="Checkout completion", +) + + +def funnel_neck_demo(): + return reflex_xy.chart(checkout, height="440px") +~~~ + +## Style a Funnel + +Per-stage paint is a channel, not a style: pass `colors=` for one CSS color +per stage, `color=` for a single constant, or let the theme palette assign +colors in declared stage order. A `xy.theme(palette={...})` mapping pins +colors by stage *name*, so a stage keeps its color across charts. Inside +labels pick a light or dark text color from each segment's own fill. + +Trace-level style stays per-trace, the ribbon contract: `opacity`, +`fill-opacity`, `stroke`, `stroke-width`, and `stroke-opacity` (an omitted +stroke color outlines each segment with its own fill). `fill` is deliberately +rejected — per-stage paint rides the channel so every renderer draws it. + +~~~python demo exec +import reflex_xy +import xy + +recruiting = xy.funnel_chart( + ["Sourced", "Phone screen", "Onsite", "Offer", "Hired"], + [1_840, 920, 388, 152, 121], + xy.theme( + palette={ + "Sourced": "#6366f1", + "Phone screen": "#8b5cf6", + "Onsite": "#a855f7", + "Offer": "#d946ef", + "Hired": "#ec4899", + }, + ), + stroke="#ffffff", + stroke_width=2.0, + gap=0.03, + show_dropoff=True, + percent_format="{:.1%}", + title="Recruiting pipeline — Q3", +) + + +def funnel_styling_demo(): + return reflex_xy.chart(recruiting, height="460px") +~~~ + +Chart chrome — title, axis ticks, tooltip, legend, and the funnel's own +value/drop-off labels (`annotation_label`) — styles through the standard +[chrome slots](/docs/xy/styling/chrome-slots/) with CSS classes, Tailwind +utilities, or `styles={...}`: + +```python +xy.funnel_chart( + stages, + values, + class_names={ + "title": "text-xl font-semibold tracking-tight", + "annotation_label": "tabular-nums", + "tooltip": "rounded-xl shadow-lg", + }, +) +``` + +`value_format` and `percent_format` are `str.format` templates, and the +kernel applies them once for every surface — segment labels, hover tooltips, +and static exports all print the same string, because the client is handed +the formatted text rather than re-implementing the format spec. + +## Interact With a Funnel + +The legend is off by default — the stage axis already names every stage — and +an explicit `xy.legend(...)` child brings back one row per stage from the +categorical stage encoding. Those rows are live: clicking one hides that +stage's segment **and its labels**, leaving the remaining stages and their +arithmetic untouched (a funnel's stage values are the data, not a running +total to recompute), and clicking again restores it. Hovering a row +emphasizes its stage and dims the rest. + +Hover reads the full arithmetic for a stage, and because a segment covers an +area rather than a point, the tooltip follows the cursor within it. Clicking +emits `xy:click` carrying the stage name, value, prior value, overall share, +conversion, and drop-off — the same semantic row the tooltip shows, with +ratios over a zero denominator arriving as `null` rather than infinity. Box +and lasso selection are deliberately absent rather than approximate. + +Stable `key=` identities opt segments into key-matched animation. Keyboard +navigation walks the stages in declared order — arrow keys move stage to +stage, `Home`/`End` jump to the ends, `Enter` activates, `Escape` dismisses — +and the screen-reader announcement reads "Stage 2 of 5" followed by that +stage's conversion arithmetic, so the funnel is heard as the ordered process +it is. diff --git a/docs/components/marks.md b/docs/components/marks.md index 1312cbda..240fb446 100644 --- a/docs/components/marks.md +++ b/docs/components/marks.md @@ -22,6 +22,7 @@ components: - xy.segments - xy.ribbon - xy.sankey + - xy.funnel - xy.triangle_mesh --- @@ -83,6 +84,7 @@ built. | Uncertainty | `errorbar`, `error_band` | | Explicit geometry | `stem`, `segments`, `triangle_mesh` | | Directed flows | `ribbon`, `sankey` | +| Ordered processes | `funnel` | The [Chart Gallery](/docs/xy/overview/gallery/) explains expected data shapes and family-specific choices. diff --git a/docs/overview/gallery.md b/docs/overview/gallery.md index cd72e7bf..c332136d 100644 --- a/docs/overview/gallery.md +++ b/docs/overview/gallery.md @@ -47,7 +47,8 @@ Looking for a specific family? [wind roses](/docs/xy/charts/wind-rose/) for directional distributions - Specialized: [stem](/docs/xy/charts/stem-plot/), [segments](/docs/xy/charts/segments/), - [Sankey](/docs/xy/charts/sankey/), and + [Sankey](/docs/xy/charts/sankey/), + [funnel](/docs/xy/charts/funnel-chart/), and [triangle mesh](/docs/xy/components/triangle-mesh/) - [Annotations](/docs/xy/components/annotations/) for rules, bands, labels, arrows, callouts, and threshold zones diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 59558079..88ad0b0d 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -11,7 +11,7 @@ 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. -- **11** mark style properties across **21** mark kinds, drawn by all three renderers. +- **11** mark style properties across **22** mark kinds, drawn by all three renderers. - **48** stable chrome slots for CSS and Tailwind in the browser. - **1** way to add a mark kind XY does not ship, without forking it. @@ -22,12 +22,12 @@ 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`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `opacity` | css | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `heatmap`, `hexbin`, `hist`, `histogram`, `line`, `ribbon`, `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`, `ribbon`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `funnel`, `heatmap`, `hexbin`, `hist`, `histogram`, `ribbon`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `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 | diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index c6f6f72b..1fcf5d26 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -24,6 +24,7 @@ renderer cannot silently ignore a declaration that another honors. | `triangle_mesh` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | `heatmap`, `hexbin` | `fill-opacity`, `opacity` | | `ribbon`, `sankey` | `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; Sankey styles apply to link ribbons | +| `funnel` | `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; per-stage paint is the categorical stage channel (`colors=`/theme palette), never `fill` | Use canonical CSS kebab-case when sharing styles with web code; Python snake_case aliases remain accepted. diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 9f1482eb..6f787f1c 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -46,7 +46,9 @@ // v12: polar angular axes carry `sector`/`grid_shape`, and radial axes carry // `hole`/`r_origin`. A v11 client would accept those fields but silently draw // full circular, centre-origin geometry. -export const PROTOCOL = 12; +// v13: the `funnel` trace kind. markOf() falls back to scatter for unknown +// kinds, so a v12 client would silently render funnel quads as a point cloud. +export const PROTOCOL = 13; // Every GL buffer field a built trace — or a drill / sample-overlay clone of // one — can own. Teardown reads this list instead of a hand-kept subset: the diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index dc33bac3..9254c692 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -1019,6 +1019,47 @@ void main() { v_t = t; }`; +// One funnel segment per instance: a 4-vertex strip between two cross spans +// at two stage-axis positions — a straight, transposable ribbon. It shares +// RIBBON_FS (same v_side/v_t/v_rgba contract), which is where the slanted +// edges get their fwidth coverage: the GL context is antialias:false, so +// without it the funnel's long diagonals staircase. Each of the six columns +// keeps its own meta uniform, so nothing is re-encoded client-side. +export const FUNNEL_VS = `#version 300 es +in float ax0; in float ax1; in float ay0; in float ay1; in float ax2; in float ay2; +in vec4 a_rgba; +uniform vec2 u_pmap; uniform vec2 u_cmap; +uniform vec2 u_p0meta; uniform vec2 u_p1meta; +uniform vec2 u_l0meta; uniform vec2 u_h0meta; uniform vec2 u_l1meta; uniform vec2 u_h1meta; +uniform int u_pmode; uniform float u_pconstant; uniform int u_cmode; uniform float u_cconstant; +uniform int u_horizontal; +out vec4 v_rgba; +flat out vec4 v_rgba0; +out float v_side; +out float v_t; +${AXIS_GLSL} +void main() { + float P0 = xyMap(ax0, u_pmap, u_p0meta, u_pmode, u_pconstant); + float P1 = xyMap(ax1, u_pmap, u_p1meta, u_pmode, u_pconstant); + float L0 = xyMap(ay0, u_cmap, u_l0meta, u_cmode, u_cconstant); + float H0 = xyMap(ay1, u_cmap, u_h0meta, u_cmode, u_cconstant); + float L1 = xyMap(ax2, u_cmap, u_l1meta, u_cmode, u_cconstant); + float H1 = xyMap(ay2, u_cmap, u_h1meta, u_cmode, u_cconstant); + float t = floor(float(gl_VertexID) * 0.5); + float side = float(gl_VertexID & 1); + float pos = mix(P0, P1, t); + // Straight edges in clip space — the affine image of transformed space, + // which is exactly the line the exporters draw after mapping the corners. + float cross = mix(mix(L0, L1, t), mix(H0, H1, t), side); + gl_Position = u_horizontal == 1 + ? vec4(pos, cross, 0.0, 1.0) + : vec4(cross, pos, 0.0, 1.0); + v_rgba = a_rgba; + v_rgba0 = a_rgba; + v_side = side; + v_t = t; +}`; + export const RIBBON_FS = `#version 300 es precision highp float; uniform float u_opacity; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 7e8f2297..89bedf98 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,7 +2,7 @@ import { PROTOCOL, TRACE_GPU_BUFFERS, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { angularTicks, categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtLog, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; -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, RIBBON_FS, RIBBON_STEPS, RIBBON_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, FUNNEL_VS, 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, RIBBON_FS, RIBBON_STEPS, RIBBON_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { acquireGLHost } from "./42_glhost"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -3277,11 +3277,54 @@ export class ChartView { g._filterDirty = true; for (const s of this._densityOverlays(g)) this._filterScatterRows(s, hidden); this._scheduleViewRequest(this.view, { delay: 0 }); + } else if (g._cpuFunnel) { + this._filterFunnelStages(g, hidden); } else { this._filterScatterRows(g, hidden); } } + // Hiding a funnel stage removes that segment and nothing else: the stage + // axis keeps its label and the surviving stages keep their own geometry and + // conversion arithmetic, because a funnel's stage values are the data, not + // a running total to be recomputed. Small-N, so the six instance columns and + // the paint rows are simply rebuilt from the retained CPU views rather than + // read back off the GPU the way the scatter filter must. + _filterFunnelStages(g, hidden) { + const f = g._cpuFunnel; + if (!f) return; + const codes = g._funnelCodes; + const visible = []; + for (let i = 0; i < f.n; i++) { + const code = codes ? Math.round(codes[i]) : i; + if (!hidden || !hidden.has(code)) visible.push(i); + } + // `_visMap` translates drawn instance → shipped stage row, so hover, + // tooltips and events keep naming the right stage while filtered. + g._visMap = visible.length === f.n ? null : Int32Array.from(visible); + g.n = visible.length; + const slots = { pos0: "x0", pos1: "x1", lo0: "y0", hi0: "y1", lo1: "x2", hi1: "y2" }; + for (const [name, slot] of Object.entries(slots)) { + const source = f[name]; + const values = g._visMap + ? Float32Array.from(visible, (i) => source[i]) + : source; + this._deleteBuffers(g, [slot + "Buf"]); + g[slot + "Buf"] = this._upload(values); + } + if (g._funnelRgbaFull || g.rgbaBuf) { + const full = g._funnelRgbaFull; + if (full) { + const rows = g._visMap + ? Uint8Array.from({ length: visible.length * 4 }, (_v, k) => + full[visible[(k / 4) | 0] * 4 + (k % 4)]) + : full; + this._deleteBuffers(g, ["rgbaBuf"]); + g.rgbaBuf = this._upload(rows); + } + } + } + // Filter a scatter-shaped gpu entry's vertex buffers down to the rows whose // categorical code is not hidden; an empty/absent set restores the full // buffers. Gathers from the CPU views retained at build; per-point @@ -3821,6 +3864,9 @@ export class ChartView { get segmentProg() { return this._prog("segment", SEGMENT_VS, SEGMENT_FS); } get meshProg() { return this._prog("mesh", MESH_VS, MESH_FS); } get ribbonProg() { return this._prog("ribbon", RIBBON_VS, RIBBON_FS); } + // The funnel program shares the ribbon fragment stage: same edge + // coverage, stroke inset, and match-fill outline contract. + get funnelProg() { return this._prog("funnel", FUNNEL_VS, RIBBON_FS); } get areaProg() { return this._prog("area", AREA_VS, AREA_FS); } get rectProg() { return this._prog("rect", RECT_VS, RECT_FS); } get barProg() { return this._prog("bar", BAR_VS, RECT_FS); } @@ -4663,6 +4709,178 @@ export class ChartView { return null; } + // Funnel ships one symmetric quad per stage (pos0/pos1 along the stage + // axis, lo/hi cross edges at each end) plus a per-stage color channel. Each + // column uploads as a per-instance attribute with ITS OWN meta uniform — + // nothing is re-encoded client-side — and the funnel program sweeps a + // 4-vertex strip per stage, sharing RIBBON_FS for the fwidth edge coverage + // that keeps the slanted edges smooth on the antialias:false context. + _buildFunnelMark(g, t, buffer) { + const cols: any = {}; + const metas: any = {}; + const slots = { pos0: "x0", pos1: "x1", lo0: "y0", hi0: "y1", lo1: "x2", hi1: "y2" }; + let n = Infinity; + for (const [name, slot] of Object.entries(slots)) { + const values = this._columnView(buffer, this.spec.columns[t[name]]); + cols[name] = values; + metas[name] = { ...this.spec.columns[t[name]] }; + g[slot + "Meta"] = metas[name]; + g[slot + "Buf"] = this._upload(values); + n = Math.min(n, values.length); + } + n = Number.isFinite(n) ? n : 0; + g.n = n; + g.orientation = t.orientation === "horizontal" ? 1 : 0; + g._cpuFunnel = { ...cols, metas, n }; + // Stage centers for keyboard traversal (declared order): the a11y walk + // reads g._cpu.x/y like any point group, so a funnel announces stage by + // stage from the first. Centers are decoded to data space and re-encoded + // against the pos0/lo0 metas the _cpu record carries. + const posMeta = metas.pos0; + const crossMeta = metas.lo0; + const dec = (name, i) => cols[name][i] / (metas[name].scale || 1) + (metas[name].offset || 0); + const centerX = new Float32Array(n); + const centerY = new Float32Array(n); + for (let i = 0; i < n; i++) { + const pCenter = (dec("pos0", i) + dec("pos1", i)) / 2; + const cCenter = (dec("lo0", i) + dec("hi0", i) + dec("lo1", i) + dec("hi1", i)) / 4; + const encP = (pCenter - (posMeta.offset || 0)) * (posMeta.scale || 1); + const encC = (cCenter - (crossMeta.offset || 0)) * (crossMeta.scale || 1); + centerX[i] = g.orientation === 1 ? encP : encC; + centerY[i] = g.orientation === 1 ? encC : encP; + } + g._cpu = { + x: centerX, + y: centerY, + xMeta: { ...(g.orientation === 1 ? posMeta : crossMeta) }, + yMeta: { ...(g.orientation === 1 ? crossMeta : posMeta) }, + }; + this._funnelPaint(g, t, buffer); + const style = t.style || {}; + g.strokeWidth = Number(style.stroke_width) || 0; + g.stroke = style.stroke ? parseColor(this.root, style.stroke, [0, 0, 0, 1]) : null; + g.tooltipRows = Array.isArray(t.tooltip_rows) ? t.tooltip_rows : null; + } + + // Per-stage fill resolved to one RGBA8 row per instance. Categorical codes + // look their palette entry up here — theme-resolving each CSS color, so a + // var(--…) palette entry follows light/dark — and the funnel program needs + // no LUT texture. Build stashes codes+palette on the record; refreshColor + // re-runs this with buffer=null to re-resolve against the new theme. + _funnelPaint(g, t, buffer) { + const n = g.n; + g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); + const channel = t.color || {}; + if (buffer !== null && Number.isInteger(channel.buf)) { + if (channel.mode === "categorical") { + g._funnelCodes = this._columnView(buffer, this.spec.columns[channel.buf]); + } else if (channel.mode === "direct_rgba") { + g._funnelRgba = this._columnView(buffer, this.spec.columns[channel.buf]); + } + } + let rgba = null; + if (channel.mode === "categorical" && g._funnelCodes) { + const palette = Array.isArray(channel.palette) && channel.palette.length + ? channel.palette : ["#4c78a8"]; + const table = palette.map((css) => parseColor(this.root, css, [0.3, 0.47, 0.66, 1])); + rgba = new Uint8Array(n * 4); + for (let i = 0; i < n; i++) { + const c = table[Math.round(g._funnelCodes[i]) % table.length]; + rgba.set([c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255], i * 4); + } + } else if (channel.mode === "direct_rgba" && g._funnelRgba) { + rgba = g._funnelRgba; + } + if (g.rgbaBuf) this._deleteBuffers(g, ["rgbaBuf"]); + // Full-length rows kept for the legend filter, which rebuilds the visible + // subset from them rather than reading the GPU buffer back. + g._funnelRgbaFull = rgba || null; + if (rgba) g.rgbaBuf = this._upload(rgba); + } + + _drawFunnels(g, xm, ym) { + if (g.n < 1) return; + const gl = this.gl; + const prog = this.funnelProg; + gl.useProgram(prog); + const u = (name) => uniformOf(gl, prog, name); + const horizontal = g.orientation === 1; + const posAxis = horizontal ? g.xAxis : g.yAxis; + const crossAxis = horizontal ? g.yAxis : g.xAxis; + gl.uniform2f(u("u_pmap"), ...(horizontal ? xm : ym)); + gl.uniform2f(u("u_cmap"), ...(horizontal ? ym : xm)); + this._setAxisUniforms(prog, "u_p0", g.x0Meta, posAxis); + this._setAxisUniforms(prog, "u_p1", g.x1Meta, posAxis); + this._setAxisUniforms(prog, "u_l0", g.y0Meta, crossAxis); + this._setAxisUniforms(prog, "u_h0", g.y1Meta, crossAxis); + this._setAxisUniforms(prog, "u_l1", g.x2Meta, crossAxis); + this._setAxisUniforms(prog, "u_h1", g.y2Meta, crossAxis); + gl.uniform1i(u("u_pmode"), this._axisMode(posAxis)); + gl.uniform1f(u("u_pconstant"), this._axisConstant(posAxis)); + gl.uniform1i(u("u_cmode"), this._axisMode(crossAxis)); + gl.uniform1f(u("u_cconstant"), this._axisConstant(crossAxis)); + gl.uniform1i(u("u_horizontal"), horizontal ? 1 : 0); + gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * (g._legendDim ?? 1)); + const stroke = g.stroke || [0, 0, 0, 0]; + gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); + gl.uniform1i(u("u_strokeMode"), g.stroke ? 0 : 1); + gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); + gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {}) * (g._legendDim ?? 1)); + const parts = ["x0", "x1", "y0", "y1", "x2", "y2"].map((name) => g[name + "Buf"]._fcId); + parts.push(g.rgbaBuf ? g.rgbaBuf._fcId : 0); + this._bindVao(g, "funnel", parts, () => { + this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ax2, g.x2Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ay2, g.y2Buf, 0, 1); + if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + }); + if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, ...g.color); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); + } + + // Containment against the same linear edges the mesh triangles draw, in + // axis-transformed space (the ribbon rule: the pointer and the decoded + // endpoints go through the transform the shader applies, so log/symlog + // axes hit-test the drawn shape, and on linear axes it is the identity). + // Index is the QUAD (= stage) index, which is what tooltip_rows and the + // kernel exact-pick expect. + _funnelHover(g, dataX, dataY) { + const f = g._cpuFunnel; + if (!f) return null; + const horizontal = g.orientation === 1; + const posAxis = horizontal ? g.xAxis : g.yAxis; + const crossAxis = horizontal ? g.yAxis : g.xAxis; + const posAxisRec = { ...this._axis(posAxis), constant: this._axisConstant(posAxis) }; + const crossAxisRec = { ...this._axis(crossAxis), constant: this._axisConstant(crossAxis) }; + const pointerPos = this._axisCoord(posAxisRec, horizontal ? dataX : dataY); + const pointerCross = this._axisCoord(crossAxisRec, horizontal ? dataY : dataX); + const val = (name, i) => f[name][i] / (f.metas[name].scale || 1) + (f.metas[name].offset || 0); + const posVal = (name, i) => this._axisCoord(posAxisRec, val(name, i)); + const crossVal = (name, i) => this._axisCoord(crossAxisRec, val(name, i)); + // A legend-hidden stage draws nothing, so it must not hover either; the + // returned index stays the SHIPPED stage row, which is what tooltip_rows + // and the kernel exact-pick speak. + const rows = g._visMap ? Array.from(g._visMap) : null; + for (let k = 0; k < (rows ? rows.length : f.n); k++) { + const i = rows ? rows[k] : k; + const p0 = posVal("pos0", i); + const p1 = posVal("pos1", i); + const lo = Math.min(p0, p1); + const hi = Math.max(p0, p1); + if (!(pointerPos >= lo && pointerPos <= hi) || hi === lo) continue; + const t = (pointerPos - p0) / (p1 - p0); + const eLo = crossVal("lo0", i) + (crossVal("lo1", i) - crossVal("lo0", i)) * t; + const eHi = crossVal("hi0", i) + (crossVal("hi1", i) - crossVal("hi0", i)) * t; + if (pointerCross >= Math.min(eLo, eHi) && pointerCross <= Math.max(eLo, eHi)) { + return { trace: g.trace.id, index: i, g, dist: 0, synthetic: true }; + } + } + return null; + } + _buildMeshMark(g, t, buffer) { for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { const values = this._columnView(buffer, this.spec.columns[t[name]]); @@ -7844,6 +8062,13 @@ export class ChartView { if (hit) return hit; continue; } + if (g._cpuFunnel) { + // Before the generic point path: the funnel's _cpu holds stage + // centers for keyboard traversal, not hoverable point geometry. + const hit = this._funnelHover(g, dataX, dataY); + if (hit) return hit; + continue; + } if (g._cpuRect) { const hit = this._rectHover(g, dataX, dataY); if (hit) return hit; @@ -8057,9 +8282,9 @@ export class ChartView { this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY }; if (id === this._hoverId) { // Point tooltips stay attached to their data point. Sankey ribbons and - // nodes cover an area instead, so keep their tooltip at the pointer as - // it travels through the same picked shape. - if (hit.g && hit.g._cpuRibbon) { + // nodes — and funnel segments — cover an area instead, so keep their + // tooltip at the pointer as it travels through the same picked shape. + if (hit.g && (hit.g._cpuRibbon || hit.g._cpuFunnel)) { this._setTooltipAnchor(hit, this._lastRow, e.clientX, e.clientY); this._repositionTooltip(); } else if (!this._tooltipAnchor) { diff --git a/js/src/51_annotations.ts b/js/src/51_annotations.ts index a3f43ebe..5a1d9da0 100644 --- a/js/src/51_annotations.ts +++ b/js/src/51_annotations.ts @@ -593,6 +593,20 @@ Object.assign(ChartView.prototype, { ctx.fill(); }, + // A mark-owned annotation (a funnel's value/drop-off label) retires with + // the geometry it describes: legend-hidden category, or hidden trace. An + // author-written annotation carries no `owner` and is never suppressed. + _annotationSuppressed(ann) { + const owner = ann && ann.owner; + if (!owner) return false; + const traces = Array.isArray(this.spec.traces) ? this.spec.traces : []; + const index = traces.findIndex((t) => t && t.id === owner.trace); + if (index < 0) return false; + if (this._legendOffTraces && this._legendOffTraces.has(index)) return true; + const hidden = this._legendOffCats && this._legendOffCats.get(index); + return !!(hidden && hidden.has(owner.category)); + }, + _drawAnnotationShapes(ctx) { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; if (!annotations.length) return; @@ -606,6 +620,7 @@ Object.assign(ChartView.prototype, { polarGeom, ); for (const [annotationIndex, ann] of annotations.entries()) { + if (this._annotationSuppressed(ann)) continue; ctx.save(); let targetX = NaN; let targetY = NaN; @@ -718,7 +733,7 @@ Object.assign(ChartView.prototype, { this._resolvedAnnotationAnchors = new Map(); for (const [annotationIndex, ann] of annotations.entries()) { const text: string = typeof ann.text === "string" ? ann.text : ""; - if (!text) continue; + if (!text || this._annotationSuppressed(ann)) continue; const style = ann && typeof ann.style === "object" ? ann.style : {}; let px = null; let py = null; diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index b50174bd..b501f307 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -61,7 +61,10 @@ Object.assign(ChartView.prototype, { if (yKind !== undefined) row.y_kind = yKind; const norm = g._cpuHeatmap.grid[hit.index]; row.color_value = this._denormalizeUnit(norm, g.trace.color && g.trace.color.domain); - } else if (g._cpuRibbon && Array.isArray(g.tooltipRows)) { + } else if ((g._cpuRibbon || g._cpuFunnel) && Array.isArray(g.tooltipRows)) { + // Semantic rows replace the coordinate readout: ribbon and funnel + // geometry slots hold internal placement coordinates, and the pick + // describes the flow/stage, never its placement. const semantic = g.tooltipRows[hit.index]; if (semantic && typeof semantic === "object") Object.assign(row, semantic); } else if (g._cpuRect) { @@ -318,6 +321,34 @@ Object.assign(ChartView.prototype, { } return items; } + const rowTrace = (Array.isArray(this.spec.traces) ? this.spec.traces : []) + .find((t) => t && t.id === row.trace); + if (rowTrace && rowTrace.kind === "funnel" && row.stage !== undefined) { + // A funnel stage's identity is its name and its conversion arithmetic; + // the geometry slots are layout. The kernel ships a preformatted `*_text` + // beside each number — it owns `value_format`/`percent_format`, and an + // em dash where a ratio's denominator was zero — so the tooltip prints + // those and never invents a format of its own. + const field = (label, text, numeric) => { + if (typeof text === "string") { + if (text !== "—") items.push({ kind: "field", label, value: text }); + return; + } + if (numeric !== undefined && numeric !== null && Number.isFinite(Number(numeric))) { + items.push({ kind: "field", label, value: fmtValue(numeric) }); + } + }; + items.push({ kind: "title", value: String(row.stage) }); + field( + typeof (labels as any).value === "string" ? (labels as any).value : "Value", + row.value_text, + row.value, + ); + field("Overall", row.share_text, row.share); + field("From previous", row.conversion_text, row.conversion); + field("Drop-off", row.dropoff_text, row.dropoff); + return items; + } const seriesName = this._tooltipSeriesName(row); if (seriesName) items.push({ kind: "title", value: seriesName }); const wedge = this._namedWedge(row); @@ -469,10 +500,11 @@ Object.assign(ChartView.prototype, { const yAxis = g.yAxis || "y"; let x = row.x; let y = row.y; - if (g._cpuRibbon || !Number.isFinite(x) || !Number.isFinite(y)) { - // Sankey rows describe a whole ribbon/node rather than a single data - // point, so their anchor always follows the pick. Category rows also - // carry labels instead of numeric coordinates and use the same fallback. + if (g._cpuRibbon || g._cpuFunnel || !Number.isFinite(x) || !Number.isFinite(y)) { + // Sankey rows describe a whole ribbon/node — and a funnel row a whole + // stage segment — rather than a single data point, so their anchor + // always follows the pick. Category rows also carry labels instead of + // numeric coordinates and use the same fallback. const rect = this.canvas.getBoundingClientRect(); [x, y] = this._dataFromCanvas(clientX - rect.left, clientY - rect.top, xAxis, yAxis); } @@ -541,8 +573,12 @@ Object.assign(ChartView.prototype, { if (this.a11yLive && options.announce !== false) { const prefix = this._a11yKeyboardReadout; const detail = lines.join(", "); + // A funnel is an ordered process, and its keyboard walk should say so: + // "Stage 2 of 5", not "Point 2 of 5". + const g = this._hoverTarget && this._hoverTarget.g; + const noun = g && g.trace && g.trace.kind === "funnel" ? "Stage" : "Point"; const announcement = prefix - ? `Point ${prefix.flat + 1} of ${prefix.total}. ${detail}` + ? `${noun} ${prefix.flat + 1} of ${prefix.total}. ${detail}` : detail; if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement; } diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index 987b950d..c590f837 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -469,8 +469,12 @@ Object.assign(ChartView.prototype, { }, _a11yPointGroups() { + // stageNav marks (funnel) traverse their per-stage centers in data order, + // which for a funnel is the declared stage order — the ordered process a + // screen reader should hear. return (this.gpuTraces || []).filter((g) => - markOf(g.trace.kind).pointPick && g.tier !== "density" && g._cpu && + (markOf(g.trace.kind).pointPick || markOf(g.trace.kind).stageNav) && + g.tier !== "density" && g._cpu && g._cpu.x && g._cpu.y && Math.min(g._cpu.x.length, g._cpu.y.length) > 0); }, diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index b3c3e154..af564b84 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -194,6 +194,30 @@ export const MARK_KINDS = { }, }, error_band: AREA_MARK, + funnel: { + build: (view, g, t, buffer) => view._buildFunnelMark(g, t, buffer), + draw: (view, g) => { + const [x0, x1] = view._axisRange(g.xAxis); + const [y0, y1] = view._axisRange(g.yAxis); + view._drawFunnels(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); + }, + // No pointPick: the GPU id pass draws gl.POINTS from the xy slots, which + // for a funnel hold trailing cross edges — garbage ids. Hover works + // through the CPU containment path (_funnelHover); box/lasso selection is + // absent rather than wrong, as for ribbon. stageNav puts the per-stage + // centers into the keyboard traversal so the funnel reads as an ordered + // process, stage 0 first. + stageNav: true, + retainCpu: true, + refreshColor: (view, g) => { + // The palette rows (possibly var(--…)) and the outline are CSS; both + // re-resolve against the new theme. buffer=null reuses the stashed + // codes and re-uploads the recolored per-stage RGBA rows. + view._funnelPaint(g, g.trace, null); + const style = g.trace.style || {}; + g.stroke = style.stroke ? parseColor(view.root, style.stroke, [0, 0, 0, 1]) : null; + }, + }, hexbin: { build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { diff --git a/python/xy/__init__.py b/python/xy/__init__.py index 6ac39be4..44944db0 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -87,6 +87,8 @@ "histogram": ".components", "histogram_chart": ".components", "interaction_config": ".components", + "funnel": ".components", + "funnel_chart": ".components", "label": ".components", "legend": ".components", "register_mark": ".plugins", @@ -182,6 +184,8 @@ "errorbar_chart", "export_config", "facet_chart", + "funnel", + "funnel_chart", "heatmap", "heatmap_chart", "hexbin", @@ -326,6 +330,8 @@ def __dir__() -> list[str]: errorbar_chart, export_config, facet_chart, + funnel, + funnel_chart, heatmap, heatmap_chart, hexbin, diff --git a/python/xy/_annotations.py b/python/xy/_annotations.py index a8cd9d4c..8832c8c1 100644 --- a/python/xy/_annotations.py +++ b/python/xy/_annotations.py @@ -201,8 +201,14 @@ def text( anchor: str = "start", class_name: Optional[str] = None, style: Optional[dict[str, Any]] = None, + owner: Optional[dict[str, int]] = None, ) -> "Figure": - """Add a text annotation anchored at a data coordinate.""" + """Add a text annotation anchored at a data coordinate. + + `owner` is an internal tag for labels a mark draws about its own + geometry (`{"trace": id, "category": code}`) so a legend toggle can + hide them together; author-written annotations leave it None. + """ text = self._required_text(text, "text annotation text") dx = self._finite_scalar(dx, "text annotation dx") dy = self._finite_scalar(dy, "text annotation dy") @@ -222,6 +228,7 @@ def text( **self._style_mapping(style or {}, "text annotation style"), }, "class_name": self._optional_text(class_name, "text annotation class_name"), + "owner": owner, } ) return self @@ -552,6 +559,14 @@ def _annotation_common(self, annotation: dict[str, Any]) -> dict[str, Any]: ) if style: out["style"] = style + # Optional ownership tag: a mark that draws its own labels as + # annotations (funnel value/drop-off labels) records which trace and + # categorical code they belong to, so a legend toggle hides a label + # with the geometry it describes instead of leaving it ghosting over + # an empty slot. Absent for author-written annotations. + owner = annotation.get("owner") + if owner is not None: + out["owner"] = {"trace": int(owner["trace"]), "category": int(owner["category"])} return out @staticmethod diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4c0f92cc..d304aa32 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -600,6 +600,7 @@ def _rollback(self, checkpoint: _FigureCheckpoint) -> None: segments = _marks.segments ribbon = _marks.ribbon sankey = _marks.sankey + funnel = _marks.funnel triangle_mesh = _marks.triangle_mesh bar = _marks.bar column = _marks.column @@ -1741,6 +1742,15 @@ def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: if t.x0 is None or t.x1 is None or t.y0 is None or t.y1 is None: raise ValueError("ribbon trace missing geometry columns") return [t.x0, t.x1] if axis == "x" else [t.y0, t.y1, t.x, t.y] + if t.kind == "funnel": + # The generic x/y slots carry the trailing CROSS edges (funnel + # geometry contract), so they range on the cross axis — x for a + # vertical funnel, y for a horizontal one. + if t.x0 is None or t.x1 is None or t.y0 is None or t.y1 is None: + raise ValueError("funnel trace missing geometry columns") + if str(t.style.get("orientation", "vertical")) == "vertical": + return [t.x0, t.x1, t.x, t.y] if axis == "x" else [t.y0, t.y1] + return [t.x0, t.x1] if axis == "x" else [t.y0, t.y1, t.x, t.y] if t.x0 is not None and t.x1 is not None and t.y0 is not None and t.y1 is not None: return [t.x0, t.x1] if axis == "x" else [t.y0, t.y1] return [t.x if axis == "x" else t.y] diff --git a/python/xy/_funnel.py b/python/xy/_funnel.py new file mode 100644 index 00000000..ea665d7f --- /dev/null +++ b/python/xy/_funnel.py @@ -0,0 +1,375 @@ +"""Funnel stage arithmetic and segment geometry. + +Pure build-time layout, the way `_sankey.compute_layout` owns the Sankey +placement: validation, conversion/drop-off arithmetic, quad construction and +the label-placement ladder all happen here, and the renderers only ever see +`funnel` quads plus semantic tooltip rows. Everything is deterministic in the +declared stage order — a funnel is a categorical business process, and this +module never reorders it. + +Coordinates are orientation-neutral: `pos` runs along the stage axis (one unit +per stage, stage 0 first) and `cross` runs across it, centered on zero. The +mark maps pos→y/cross→x for vertical funnels and pos→x/cross→y for horizontal +ones; `funnel_chart` reverses the vertical stage axis so stage 0 reads from +the top. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Optional + +from . import _fontmetrics + +ORIENTATIONS = ("vertical", "horizontal") +GEOMETRIES = ("area", "bar") +NECKS = ("rect", "taper") + +# Mode-resolved segment gaps (fraction of the unit stage pitch). An area +# funnel reads as one tapering silhouette, so its segments touch; bars need +# the same separation a bar chart's 0.8 width leaves. +DEFAULT_GAP = {"area": 0.0, "bar": 0.2} + + +@dataclass(frozen=True) +class FunnelStage: + """One stage with its conversion arithmetic resolved. + + `share` is the overall conversion (value / first value), `conversion` the + previous-stage conversion (value / prior). Both are None where the + denominator is zero or absent — the first stage has no prior, and a zero + denominator has no meaningful ratio — so formatting shows an em dash + instead of an invented number. + """ + + index: int + name: str + value: float + share: Optional[float] + prior: Optional[float] + conversion: Optional[float] + dropoff: Optional[float] + + +@dataclass(frozen=True) +class FunnelQuad: + """One drawn segment: a symmetric trapezoid in pos/cross space. + + `pos0` is the leading edge (toward stage 0), `pos1` the trailing edge; + `lo0/hi0` are the cross-axis edges at pos0 and `lo1/hi1` at pos1. A bar + segment is the degenerate trapezoid with equal ends. + """ + + stage: int + pos0: float + pos1: float + lo0: float + hi0: float + lo1: float + hi1: float + + +@dataclass(frozen=True) +class FunnelLayout: + stages: list[FunnelStage] + quads: list[FunnelQuad] + orientation: str + geometry: str + gap: float + max_value: float + # The drawn half-width floor in value units (min_width × max_value / 2); + # tooltip/event values are never clamped, only drawn geometry. + floor_half: float + + +@dataclass(frozen=True) +class FunnelLabelSpec: + """One resolved label: text, position, and the placement decision.""" + + stage: int + kind: str # "value" | "dropoff" + text: str + pos: float + cross: float + anchor: str # "start" | "middle" | "end" + placement: str # "inside" | "outside" | "hidden" + + +def _validated_values(names: Sequence[str], values: Sequence[float]) -> list[float]: + if len(names) != len(values): + raise ValueError( + f"funnel needs one value per stage; got {len(names)} stages and {len(values)} values" + ) + if not names: + raise ValueError("funnel needs at least one stage") + seen: dict[str, int] = {} + for index, name in enumerate(names): + if name in seen: + raise ValueError( + f"funnel stage names must be unique; {name!r} appears at " + f"positions {seen[name]} and {index}" + ) + seen[name] = index + out: list[float] = [] + for name, value in zip(names, values, strict=True): + try: + v = float(value) + except (TypeError, ValueError): + raise ValueError(f"funnel stage {name!r} has a non-numeric value {value!r}") from None + if math.isnan(v): + raise ValueError( + f"funnel stage {name!r} has a missing value; drop the stage " + "or supply a number (zero is allowed)" + ) + if math.isinf(v): + raise ValueError(f"funnel stage {name!r} has a non-finite value") + if v < 0.0: + raise ValueError( + f"funnel stage {name!r} has a negative value ({v:g}); stage " + "values are counts or amounts and must be >= 0" + ) + out.append(v) + return out + + +def compute_stages(names: Sequence[str], values: Sequence[float]) -> list[FunnelStage]: + """Validate stage values and resolve conversion arithmetic. + + Increasing values are allowed (re-entry and net-growth funnels are real) + and produce conversion > 1 with a negative drop-off; negative and missing + values are refused by stage name. Ratios against a zero denominator are + None, never inf. + """ + vals = _validated_values(names, values) + first = vals[0] + stages: list[FunnelStage] = [] + prior: Optional[float] = None + for index, (name, value) in enumerate(zip(names, vals, strict=True)): + share = (value / first) if first > 0.0 else None + if index == 0: + conversion: Optional[float] = None + dropoff: Optional[float] = None + elif prior is not None and prior > 0.0: + conversion = value / prior + dropoff = 1.0 - conversion + else: + conversion = None + dropoff = None + stages.append( + FunnelStage( + index=index, + name=name, + value=value, + share=share, + prior=prior, + conversion=conversion, + dropoff=dropoff, + ) + ) + prior = value + return stages + + +def compute_layout( + names: Sequence[str], + values: Sequence[float], + *, + orientation: str = "vertical", + geometry: str = "area", + gap: Optional[float] = None, + neck: str = "rect", + min_width: float = 0.0, +) -> FunnelLayout: + """Build the drawn quads for a funnel in declared stage order. + + - ``geometry="area"``: equal-length segments whose cross width tapers from + this stage's value to the next stage's, so drop-off is visible as slope. + The painted area is therefore NOT proportional to the stage value — the + docs say so — and ``geometry="bar"`` is the faithful-width alternative. + - ``geometry="bar"``: centered constant-width segments (widths carry the + values exactly). + - ``neck`` decides the last area segment's far edge: ``"rect"`` holds the + stage's own width, ``"taper"`` runs it to a point. + - ``min_width`` clamps drawn cross widths to a fraction of the widest + stage so zero/tiny stages stay visible and hoverable; values in events, + tooltips and labels are never clamped. + """ + if orientation not in ORIENTATIONS: + raise ValueError(f"funnel orientation must be one of {ORIENTATIONS}, got {orientation!r}") + if geometry not in GEOMETRIES: + raise ValueError(f"funnel geometry must be one of {GEOMETRIES}, got {geometry!r}") + if neck not in NECKS: + raise ValueError(f"funnel neck must be one of {NECKS}, got {neck!r}") + if neck != "rect" and geometry != "area": + raise ValueError('funnel neck applies to geometry="area" only; bar segments have no taper') + if gap is None: + gap_value = DEFAULT_GAP[geometry] + else: + gap_value = float(gap) + if not 0.0 <= gap_value < 1.0: + raise ValueError(f"funnel gap must be in [0, 1), got {gap!r}") + min_width_value = float(min_width) + if not 0.0 <= min_width_value <= 1.0: + raise ValueError(f"funnel min_width must be in [0, 1], got {min_width!r}") + + stages = compute_stages(names, values) + max_value = max(stage.value for stage in stages) + floor_half = min_width_value * max_value / 2.0 + + def half(value: float) -> float: + return max(value / 2.0, floor_half) + + quads: list[FunnelQuad] = [] + n = len(stages) + for stage in stages: + pos0 = stage.index - 0.5 + gap_value / 2.0 + pos1 = stage.index + 0.5 - gap_value / 2.0 + lead = half(stage.value) + if geometry == "bar": + trail = lead + elif stage.index + 1 < n: + trail = half(stages[stage.index + 1].value) + elif neck == "taper": + # The spout: the funnel's last segment runs to a point. The floor + # deliberately does not apply — a point is the documented shape, + # not an accidentally invisible stage. + trail = 0.0 + else: + trail = lead + quads.append( + FunnelQuad( + stage=stage.index, + pos0=pos0, + pos1=pos1, + lo0=-lead, + hi0=lead, + lo1=-trail, + hi1=trail, + ) + ) + return FunnelLayout( + stages=stages, + quads=quads, + orientation=orientation, + geometry=geometry, + gap=gap_value, + max_value=max_value, + floor_half=floor_half, + ) + + +def format_ratio(ratio: Optional[float], percent_format: str) -> str: + """A conversion/share ratio for display; an em dash where undefined.""" + if ratio is None: + return "—" + return percent_format.format(ratio) + + +def format_value(value: float, value_format: str) -> str: + return value_format.format(value) + + +def _fits(px: float, budget: float) -> bool: + return px <= budget + + +def decide_labels( + layout: FunnelLayout, + *, + show_values: bool, + show_conversion: bool, + show_dropoff: bool, + value_format: str, + percent_format: str, + font_size: float, + plot_px: tuple[float, float], +) -> list[FunnelLabelSpec]: + """Resolve label texts and placements with the documented collision ladder. + + For each stage the value label (value, plus overall conversion when + ``show_conversion``) tries, in order: + + 1. **inside** — centered in the segment, when the text fits the segment's + cross width and the stage pitch fits the line height; + 2. **outside** — beside the segment on the positive cross side, when only + the cross width fails; + 3. **hidden** — when the stage pitch cannot hold a text line (the tooltip + and events still carry every number). + + Drop-off labels (``show_dropoff``) sit outside at the boundary between a + stage and its predecessor, formatted as the signed change + (``-38%`` for a drop, ``+12%`` for growth), and inherit the same + pitch-or-hidden rule. Placement is estimated against the figure's + configured pixel size at build time; a responsive chart keeps the + build-time decision. + """ + plot_w, plot_h = plot_px + n = len(layout.stages) + # Cross span the mark occupies: the widest drawn edge on each side, plus + # the small margin funnel_chart adds. Conservative: labels care about + # scale, not exact margins. + cross_full = 2.0 * max((max(quad.hi0, quad.hi1) for quad in layout.quads), default=0.5) + if cross_full <= 0.0: + # Every stage is zero-valued with no floor: nothing has drawable + # width, so nothing fits "inside" and every label falls outside. + cross_full = 1.0 + if layout.orientation == "vertical": + cross_px_per_unit = plot_w / (cross_full * 1.1) + pitch_px = plot_h / max(n, 1) + else: + cross_px_per_unit = plot_h / (cross_full * 1.1) + pitch_px = plot_w / max(n, 1) + line_px = font_size * 1.4 + + labels: list[FunnelLabelSpec] = [] + for stage, quad in zip(layout.stages, layout.quads, strict=True): + mid_pos = (quad.pos0 + quad.pos1) / 2.0 + mid_half = (quad.hi0 + quad.hi1) / 2.0 + if show_values: + text = format_value(stage.value, value_format) + if show_conversion: + text += f" {format_ratio(stage.share, percent_format)}" + width_px = _fontmetrics.advance(text, font_size) + if not _fits(line_px, pitch_px): + placement, cross, anchor = "hidden", 0.0, "middle" + elif _fits(width_px, mid_half * 2.0 * cross_px_per_unit * 0.92): + placement, cross, anchor = "inside", 0.0, "middle" + else: + placement, cross, anchor = "outside", mid_half, "start" + labels.append( + FunnelLabelSpec( + stage=stage.index, + kind="value", + text=text, + pos=mid_pos, + cross=cross, + anchor=anchor, + placement=placement, + ) + ) + if show_dropoff and stage.index > 0: + change = None if stage.conversion is None else stage.conversion - 1.0 + if change is None: + text = "—" + else: + text = percent_format.format(change) + if change > 0.0 and not text.startswith(("+", "-")): + text = "+" + text + boundary = stage.index - 0.5 + prev_quad = layout.quads[stage.index - 1] + edge = max(prev_quad.hi1, quad.hi0) + placement = "outside" if _fits(line_px, pitch_px) else "hidden" + labels.append( + FunnelLabelSpec( + stage=stage.index, + kind="dropoff", + text=text, + pos=boundary, + cross=edge, + anchor="start", + placement=placement, + ) + ) + return labels diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 9448c189..b2c1ae0f 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -871,6 +871,65 @@ def _emit_ribbon( self._ship_trace_styles(entry, t, sel_arg, pw) return self._transition_entry(entry, t, pw, sel_arg) + def _emit_funnel( + self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int + ) -> dict[str, Any]: + """Ship funnel segments: per-stage symmetric quads plus semantics. + + Slot mapping (funnel geometry contract): the stage-axis edges ride the + stage dimension's edge pair, the leading cross edges ride the other + pair, and the generic x/y slots carry the TRAILING cross edges — both + on the CROSS axis scale. Orientation decides which axis is which, so + the wire re-labels the six columns semantically (`pos0`/`pos1`, + `lo0`/`hi0`, `lo1`/`hi1`) and renderers never see the slot trick. + """ + del xr, yr, px_width + if t.x0 is None or t.x1 is None or t.y0 is None or t.y1 is None: + raise ValueError("funnel trace missing geometry columns") + orientation = str(t.style.get("orientation", "vertical")) + xs, ys = self._axis_scale(t.x_axis), self._axis_scale(t.y_axis) + if orientation == "vertical": + pos0, pos1, pos_scale = t.y0, t.y1, ys + lo0, hi0, cross_scale = t.x0, t.x1, xs + elif orientation == "horizontal": + pos0, pos1, pos_scale = t.x0, t.x1, xs + lo0, hi0, cross_scale = t.y0, t.y1, ys + else: + raise ValueError(f"unknown funnel orientation {orientation!r}") + lo1, hi1 = t.x, t.y + entry = { + "id": t.id, + "kind": t.kind, + "name": t.name, + "style": self._default_styled(t), + # Always direct: a funnel is small-N by nature — one quad per + # stage — and neither decimation nor a density tier means + # anything for it (§28). + "tier": "direct", + "n_points": t.n_points, + "n_marks": int(len(pos0.values)), + "x_axis": t.x_axis, + "y_axis": t.y_axis, + "orientation": orientation, + "pos0": pw.ship(pos0.values, pos0, scale=pos_scale), + "pos1": pw.ship(pos1.values, pos1, scale=pos_scale), + "lo0": pw.ship(lo0.values, lo0, scale=cross_scale), + "hi0": pw.ship(hi0.values, hi0, scale=cross_scale), + "lo1": pw.ship(lo1.values, lo1, scale=cross_scale), + "hi1": pw.ship(hi1.values, hi1, scale=cross_scale), + } + if t.color_ch is not None: + entry["color"], _size = self._ship_channels(t, None, pw.ship_scalar, pw.ship_u8) + if t.tooltip_rows is not None: + if len(t.tooltip_rows) != t.n_points: + raise ValueError( + "funnel tooltip rows must match funnel geometry " + f"({len(t.tooltip_rows)} != {t.n_points})" + ) + entry["tooltip_rows"] = [dict(row) for row in t.tooltip_rows] + self._ship_trace_styles(entry, t, None, pw) + return self._transition_entry(entry, t, pw, None) + def _emit_triangle_mesh( self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int ) -> dict[str, Any]: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index bb341b27..17f55b1d 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1095,6 +1095,9 @@ def render_raster( ) elif kind == "triangle_mesh": _emit_triangle_mesh(cmd, t, blob, cols, trace_sx, trace_sy, style, color) + elif kind == "funnel": + _emit_funnel(cmd, t, blob, cols, trace_sx, trace_sy, style, color) + elif kind == "ribbon": # MUST precede the rect fall-through: a ribbon ships x0/x1/y0/y1 # too, so a later branch would draw every band as a rectangle. @@ -1102,7 +1105,17 @@ def render_raster( elif all(k in t for k in ("x0", "x1", "y0", "y1")): _emit_rects(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot, polar) - _emit_annotations(cmd, spec.get("annotations") or [], sx, sy, plot, width, height, polar=polar) + _emit_annotations( + cmd, + spec.get("annotations") or [], + sx, + sy, + plot, + width, + height, + polar=polar, + default_text=_css(dom_style.get("--chart-annotation-text"), "") or default_text, + ) # Chrome (unclipped): baselines, labels, title, legend. cmd.clip(0, 0, width, height) @@ -1117,6 +1130,7 @@ def render_raster( width, height, phase="text", + default_text=_css(dom_style.get("--chart-annotation-text"), "") or default_text, polar=polar, ) # "none" silences the whole axis chrome (sparklines); "off" hides only the @@ -1676,6 +1690,7 @@ def _emit_annotations( *, phase: str = "marks", polar: "Optional[_PolarProjection]" = None, + default_text: str = _TEXT, ) -> None: px0, py0 = plot["x"], plot["y"] text_phase = phase == "text" @@ -1800,7 +1815,7 @@ def point(x: float, y: float) -> tuple[float, float]: "label_opacity", style.get("opacity", 1.0) if ann.get("kind") == "text" else 1.0, ) - color = _rgba(label_color, _TEXT, float(label_opacity)) + color = _rgba(label_color, default_text, float(label_opacity)) rotation = float(style.get("rotation", 0.0)) % 360.0 italic, bold = _native_font_emphasis(style) math_ranges = _math_italic_ranges(style) @@ -2433,6 +2448,79 @@ def _emit_hexbin( cmd.triangles(x0, y0, x1, y1, x2, y2, fills, 0.0, (0, 0, 0, 0)) +def _emit_funnel( + cmd: _Cmd, + t: dict[str, Any], + blob: bytes, + cols: list[dict[str, Any]], + sx: _Scale, + sy: _Scale, + style: dict[str, Any], + color: str, +) -> None: + """Funnel segments: one flat-filled 4-corner polygon per stage. + + Geometry comes from `_scene.funnel_quad` — the same reference the SVG + exporter and the golden test consume — built from the **axis-mapped** + edges, so the straight edges live in transformed space exactly like the + client's mesh triangles (the ribbon rule, minus the cubic). + """ + pos0 = _column(blob, cols[t["pos0"]]) + pos1 = _column(blob, cols[t["pos1"]]) + lo0 = _column(blob, cols[t["lo0"]]) + hi0 = _column(blob, cols[t["hi0"]]) + lo1 = _column(blob, cols[t["lo1"]]) + hi1 = _column(blob, cols[t["hi1"]]) + horizontal = t.get("orientation") == "horizontal" + spos, scross = (sx, sy) if horizontal else (sy, sx) + n = min(len(pos0), len(pos1), len(lo0), len(hi0), len(lo1), len(hi1)) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + intrinsic = _trace_paint_rgba(t, "color", n, color, read) + fills = np.rint( + _paint.effective_rgba(intrinsic, t, read, component="fill", default_opacity=1.0) * 255.0 + ).astype(np.uint8) + stroke_width = float(style.get("stroke_width", 0.0) or 0.0) + stroke_op = _stroke_opacity(style) + stroke_c = ( + _rgba(style.get("stroke"), color, stroke_op) + if stroke_width > 0 and style.get("stroke") is not None + else None + ) + # An omitted stroke colour matches each segment's own fill + # (edgecolors="face"), resolved per stage like the ribbon's per-band rule. + edges = ( + np.rint( + np.column_stack([intrinsic[:, :3] * 255.0, intrinsic[:, 3] * stroke_op * 255.0]) + ).astype(np.uint8) + if stroke_width > 0 and style.get("stroke") is None + else None + ) + for i in range(n): + mapped = ( + float(spos(pos0[i])), + float(spos(pos1[i])), + float(scross(lo0[i])), + float(scross(hi0[i])), + float(scross(lo1[i])), + float(scross(hi1[i])), + ) + if not all(math.isfinite(v) for v in mapped): + continue + quad = _scene.funnel_quad(*mapped, horizontal) + poly = list(zip(quad[:, 0].tolist(), quad[:, 1].tolist(), strict=True)) + cmd.fill(poly, tuple(int(v) for v in fills[i])) + edge_c = ( + stroke_c + if stroke_c is not None + else (tuple(int(v) for v in edges[i]) if edges is not None else None) + ) + if edge_c is not None: + cmd.stroke([*poly, poly[0]], stroke_width, edge_c) + + def _emit_ribbon( cmd: _Cmd, t: dict[str, Any], diff --git a/python/xy/_scene.py b/python/xy/_scene.py index 2f30d242..0320d798 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -74,6 +74,31 @@ def ribbon_polygon( return np.vstack([upper, lower[::-1]]) +def funnel_quad( + pos0: float, + pos1: float, + lo0: float, + hi0: float, + lo1: float, + hi1: float, + horizontal: bool, +) -> np.ndarray: + """One funnel segment as a closed 4-corner polygon, in the caller's space. + + `pos` runs along the stage axis, `lo/hi` are the cross-axis edges at each + end; `horizontal` maps pos→x/cross→y and vertical the transpose. Corners + run A=(lo0@pos0) B=(hi0@pos0) C=(hi1@pos1) D=(lo1@pos1) — the same two + triangles (ABC, ACD) the client expands in `_buildFunnelMark`. This is the + single reference both static exporters and the golden geometry test + consume, so SVG and PNG cannot drift from each other or from the client. + """ + if horizontal: + corners = [(pos0, lo0), (pos0, hi0), (pos1, hi1), (pos1, lo1)] + else: + corners = [(lo0, pos0), (hi0, pos0), (hi1, pos1), (lo1, pos1)] + return np.array(corners, dtype=np.float64) + + def curve_points(xv: np.ndarray, yv: np.ndarray, sx: Any, sy: Any, smooth: bool) -> np.ndarray: """Pixel-space polyline for a series. Smooth flattens the monotone-cubic Hermite (the same tangents `_svg._curve_path` emits as Béziers) into short diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 0e5c5e1f..3b92c31a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4183,6 +4183,9 @@ def line_attrs(style: dict[str, Any], color: str) -> str: # flow band as a rectangle. marks.append(_ribbon_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg)) + elif kind == "funnel": + marks.append(_funnel_marks(t, blob, cols, trace_sx, trace_sy, style, color)) + elif all(k in t for k in ("x0", "x1", "y0", "y1")): # histogram / rect family marks.append( _rect_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg, plot, polar) @@ -4333,7 +4336,20 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( - spec.get("annotations") or [], sx, sy, plot, width, height, polar + spec.get("annotations") or [], + sx, + sy, + plot, + width, + height, + polar, + # The live client resolves an annotation label through + # var(--chart-annotation-text, var(--chart-text, inherit)); the + # exporters must reach the same colour or a themed chart's labels + # print in the light-mode default (parity is identity). + _css(dom_style.get("--chart-annotation-text"), "") + or _css(dom_style.get("--chart-text"), "") + or "#667085", ) marks.extend(annotation_marks) labels.extend(annotation_labels) @@ -4726,6 +4742,7 @@ def _annotation_svg( width: float, height: float, polar: "Optional[_PolarProjection]" = None, + default_text: str = "#667085", ) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] unclipped_marks: list[str] = [] @@ -4751,7 +4768,7 @@ def point(x: float, y: float) -> tuple[float, float]: for ann in annotations: style = ann.get("style") or {} - color = escape(_css(style.get("color"), "#667085")) + color = escape(_css(style.get("color"), default_text)) opacity = float(style.get("opacity", 1.0)) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) @@ -5586,6 +5603,82 @@ def rgb(paint: Any) -> str: return "".join(out) +def _funnel_marks( + t: dict, + blob: bytes, + cols: list, + sx: _Scale, + sy: _Scale, + style: dict, + fallback: str, +) -> str: + """Funnel segments as one closed 4-corner `` each, flat per-stage + fill. Geometry comes from `_scene.funnel_quad` — the same reference the + raster consumes and the golden test pins — built from the axis-mapped + edges, so log/symlog cross axes keep the straight-in-transformed-space + edges the client's strip draws.""" + # Deferred import: _scene itself imports the column readers from this + # module, so a module-level import here is a load-order cycle. + from . import _scene + + pos0 = _column(blob, cols[t["pos0"]]) + pos1 = _column(blob, cols[t["pos1"]]) + lo0 = _column(blob, cols[t["lo0"]]) + hi0 = _column(blob, cols[t["hi0"]]) + lo1 = _column(blob, cols[t["lo1"]]) + hi1 = _column(blob, cols[t["hi1"]]) + horizontal = t.get("orientation") == "horizontal" + spos, scross = (sx, sy) if horizontal else (sy, sx) + n = min(len(pos0), len(pos1), len(lo0), len(hi0), len(lo1), len(hi1)) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + intrinsic = _trace_paint_rgba(t, "color", n, fallback, read) + fills = _paint.effective_rgba(intrinsic, t, read, component="fill", default_opacity=1.0) + stroke_css = style.get("stroke") + stroke_width = float(style.get("stroke_width", 0.0) or 0.0) + stroke_op = _stroke_opacity(style) + # An omitted stroke colour matches each segment's own fill + # (edgecolors="face"), the ribbon rule: a per-stage funnel has no single + # trace colour to outline with. + stroke_paint = None if stroke_css is None else escape(_css(stroke_css, fallback)) + + def rgb(paint: Any) -> str: + return f"rgb({round(paint[0] * 255)},{round(paint[1] * 255)},{round(paint[2] * 255)})" + + out: list[str] = [] + for i in range(n): + mapped = ( + float(spos(pos0[i])), + float(spos(pos1[i])), + float(scross(lo0[i])), + float(scross(hi0[i])), + float(scross(lo1[i])), + float(scross(hi1[i])), + ) + if not all(math.isfinite(v) for v in mapped): + continue + quad = _scene.funnel_quad(*mapped, horizontal) + d = ( + f"M {_num(quad[0, 0])} {_num(quad[0, 1])} " + f"L {_num(quad[1, 0])} {_num(quad[1, 1])} " + f"L {_num(quad[2, 0])} {_num(quad[2, 1])} " + f"L {_num(quad[3, 0])} {_num(quad[3, 1])} Z" + ) + paint = fills[i] + alpha = float(paint[3]) + attrs = f'fill="{rgb(paint)}"' + (f' fill-opacity="{_num(alpha)}"' if alpha < 1 else "") + if stroke_width > 0: + paint_css = stroke_paint if stroke_paint is not None else rgb(intrinsic[i]) + edge_op = stroke_op * (1.0 if stroke_paint is not None else float(intrinsic[i][3])) + attrs += f' stroke="{paint_css}" stroke-width="{_num(stroke_width)}" ' + if edge_op < 1: + attrs += f'stroke-opacity="{_num(edge_op)}" ' + out.append(f'') + return "".join(out) + + def _triangle_mesh_marks( t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str ) -> str: diff --git a/python/xy/components.py b/python/xy/components.py index a5ec5b9c..dae20778 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -101,6 +101,8 @@ "errorbar_chart", "export_config", "facet_chart", + "funnel", + "funnel_chart", "heatmap", "heatmap_chart", "hexbin", @@ -1145,6 +1147,114 @@ def sankey( ) +def funnel( + stage: Union[str, ArrayLike, None] = None, + value: Union[str, ArrayLike, None] = None, + *, + data: TableLike = None, + key: Any = None, + orientation: str = "vertical", + geometry: str = "area", + gap: Optional[float] = None, + neck: str = "rect", + min_width: float = 0.0, + color: Optional[str] = None, + colors: Optional[Sequence[str]] = None, + name: Optional[str] = None, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, + show_values: bool = True, + show_conversion: bool = True, + show_dropoff: bool = False, + labels: bool = True, + label_size: float = 12.0, + value_format: str = "{:,.10g}", + percent_format: str = "{:.0%}", + animation: Animation | bool | None = None, + style: Optional[dict[str, StyleValue]] = None, + class_name: Optional[str] = None, +) -> Mark: + """A funnel mark: one centered segment per stage, in declared order. + + Stage order is the declared order — a funnel is a categorical business + process and is never sorted. Prefer `xy.funnel_chart(...)`, which also + hides the cross axis and puts stage 0 at the top. + + Args: + stage: Stage names in order, or a column name resolved from ``data``. + value: One non-negative value per stage, or a column name. + data: Table used to resolve column-name inputs. + key: Stable per-stage identities for animation matching, or a column + name. Defaults to positional matching. + orientation: ``"vertical"`` stacks stages along y (stage 0 on top in + `funnel_chart`); ``"horizontal"`` runs them along x. + geometry: ``"area"`` draws the classic tapering silhouette (each + segment's far edge previews the next stage, so painted area is NOT + proportional to the value); ``"bar"`` draws centered + constant-width segments whose widths carry the values exactly. + gap: Gap between segments as a fraction of the stage pitch, in + ``[0, 1)``. ``None`` resolves per geometry: 0 for ``"area"`` + (a continuous silhouette), 0.2 for ``"bar"`` (bar-chart spacing). + neck: Last area segment's far edge: ``"rect"`` holds the stage's own + width, ``"taper"`` runs it to a point. Area geometry only. + min_width: Drawn-width floor as a fraction of the widest stage, in + ``[0, 1]``. Keeps zero/tiny stages visible and hoverable; values + in labels, tooltips and events are never clamped. + color: One constant CSS color for every segment (no per-stage legend). + colors: One CSS color per stage. Defaults to the palette cycle in + declared stage order. + name: Series label. Legend rows normally come from the per-stage + categorical encoding instead. + opacity: Segment opacity from zero to one (per-trace). + stroke: Optional segment outline color (per-trace). + stroke_width: Segment outline width in pixels (per-trace). + show_values: Draw the value label on each segment. + show_conversion: Append the overall conversion (share of the first + stage) to each value label. + show_dropoff: Draw the signed stage-over-stage change at each + boundary (``-38%`` for a drop, ``+12%`` for growth). + labels: Master switch for all funnel labels. + label_size: Label font size in px. + value_format: ``str.format`` template for values. + percent_format: ``str.format`` template for ratios. + animation: Per-mark animation override; ``False`` disables animation. + style: Mark style overrides (opacity, stroke, stroke-width). + class_name: Adapter-only trace metadata; it does not style canvas + geometry. + """ + return Mark( + kind="funnel", + x=stage, + y=value, + data=data, + name=name, + class_name=class_name, + key=key, + animation=animation, + style=_mark_style_dict(style, "funnel style"), + props={ + "orientation": orientation, + "geometry": geometry, + "gap": gap, + "neck": neck, + "min_width": min_width, + "color": color, + "colors": None if colors is None else list(colors), + "opacity": opacity, + "stroke": stroke, + "stroke_width": stroke_width, + "show_values": show_values, + "show_conversion": show_conversion, + "show_dropoff": show_dropoff, + "labels": labels, + "label_size": label_size, + "value_format": value_format, + "percent_format": percent_format, + }, + ) + + def triangle_mesh( x0: Union[str, ArrayLike, None] = None, y0: Union[str, ArrayLike, None] = None, @@ -5829,6 +5939,32 @@ def _apply_sankey(fig: Figure, m: Mark, data: Any) -> None: ) +def _apply_funnel(fig: Figure, m: Mark, data: Any) -> None: + fig.funnel( + _resolve(data, m.x, context=f"{m.kind}.stage"), + _resolve(data, m.y, context=f"{m.kind}.value"), + orientation=m.props["orientation"], + geometry=m.props["geometry"], + gap=m.props["gap"], + neck=m.props["neck"], + min_width=m.props["min_width"], + color=m.props["color"], + colors=m.props["colors"], + name=m.name, + opacity=m.props["opacity"], + stroke=m.props["stroke"], + stroke_width=m.props["stroke_width"], + show_values=m.props["show_values"], + show_conversion=m.props["show_conversion"], + show_dropoff=m.props["show_dropoff"], + labels=m.props["labels"], + label_size=m.props["label_size"], + value_format=m.props["value_format"], + percent_format=m.props["percent_format"], + style=m.style, + ) + + def _apply_triangle_mesh(fig: Figure, m: Mark, data: Any) -> None: color = m.props["color"] fig.triangle_mesh( @@ -6202,6 +6338,7 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: "stem": _apply_stem, "ribbon": _apply_ribbon, "sankey": _apply_sankey, + "funnel": _apply_funnel, "triangle_mesh": _apply_triangle_mesh, "violin": _apply_violin, } @@ -6990,6 +7127,96 @@ def sankey_chart( return Chart("sankey_chart", children, **props) +def funnel_chart( + *children: Any, + **props: Any, +) -> Chart: + """A funnel chart: ordered stages, centered segments, hidden cross axis. + + xy.funnel_chart( + xy.funnel( + data=df, + stage="stage", + value="users", + show_conversion=True, + ), + ) + + Positional ``(stage, value)`` sequences also work without composing the + mark explicitly: ``xy.funnel_chart(["Visit", "Signup"], [9800, 6200])``. + + The stage axis keeps the declared order — stage 0 at the top for vertical + funnels (the axis is reversed exactly like a Sankey's), at the left for + horizontal ones — and shows the stage names as its tick labels. The cross + axis is layout, not data (segments are centered on zero), so it is hidden; + keyword arguments that belong to `xy.funnel` (``orientation``, + ``geometry``, ``gap``, ``neck``, ``min_width``, ``colors``, + ``show_dropoff``, …) are forwarded there, and everything else (``width``, + ``height``, ``title``, …) styles the chart. + """ + mark_keys = ( + "data", + "stage", + "value", + "key", + "orientation", + "geometry", + "gap", + "neck", + "min_width", + "color", + "colors", + "opacity", + "stroke", + "stroke_width", + "show_values", + "show_conversion", + "show_dropoff", + "labels", + "label_size", + "value_format", + "percent_format", + "animation", + ) + mark_kwargs = {key: props.pop(key) for key in mark_keys if key in props} + rest = list(children) + marks: list[Component] = [] + if rest and not isinstance(rest[0], Component): + stage_values = rest.pop(0) + if "stage" in mark_kwargs: + raise ValueError("funnel_chart got positional stages and stage=") + mark_kwargs["stage"] = stage_values + if rest and not isinstance(rest[0], Component): + if "value" in mark_kwargs: + raise ValueError("funnel_chart got positional values and value=") + mark_kwargs["value"] = rest.pop(0) + if mark_kwargs or not any(isinstance(child, Mark) and child.kind == "funnel" for child in rest): + marks.append(funnel(**mark_kwargs)) + orientation = mark_kwargs.get("orientation", "vertical") + for child in rest: + if isinstance(child, Mark) and child.kind == "funnel": + orientation = child.props.get("orientation", orientation) + # The cross axis is layout, not data (segments center on zero), so it is + # hidden with a symmetric margin that keeps the widest stage — and the + # outside/drop-off labels beside it — clear of the plot edges. The legend + # is off by default: the stage axis already names every stage, and the + # widest stage sits exactly where an inside legend would land. An explicit + # `xy.legend(...)` child (or any later child) overrides these defaults. + if orientation == "vertical": + axes: tuple[Component, ...] = ( + x_axis(show=False, margin=0.14), + y_axis(reverse=True), + legend(show=False), + ) + else: + axes = ( + y_axis(show=False, margin=0.14), + x_axis(), + legend(show=False), + ) + return Chart("funnel_chart", (*marks, *axes, *rest), **props) + + def triangle_mesh_chart(*children: Component, **props: Any) -> Chart: """A filled triangular mesh chart.""" return Chart("triangle_mesh_chart", children, **props) diff --git a/python/xy/config.py b/python/xy/config.py index 8579700c..55cbfabc 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -40,7 +40,10 @@ # v12 adds polar sector/grid-shape metadata on the angular axis and hole/origin # metadata on the radial axis. A v11 client would silently draw a full circular # grid with a centre-origin radius, so the new geometry must fail the handshake. -PROTOCOL_VERSION = 12 +# v13 adds the `funnel` trace kind. `markOf()` falls back to scatter for +# unknown kinds, so a v12 client would silently render funnel quads as a point +# cloud. +PROTOCOL_VERSION = 13 # Mark kinds the polar transform renders correctly today. Everything else is # refused by Figure._validate_coords rather than approximated: the rect, area, diff --git a/python/xy/marks.py b/python/xy/marks.py index 72b4252e..278775de 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -676,6 +676,298 @@ def sankey( raise +def funnel( + self: "Figure", + stage: ArrayLike, + value: ArrayLike, + *, + orientation: str = "vertical", + geometry: str = "area", + gap: Optional[float] = None, + neck: str = "rect", + min_width: float = 0.0, + color: Union[str, None] = None, + colors: Optional[Sequence[str]] = None, + name: Optional[str] = None, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, + show_values: bool = True, + show_conversion: bool = True, + show_dropoff: bool = False, + labels: bool = True, + label_size: float = 12.0, + value_format: str = "{:,.10g}", + percent_format: str = "{:.0%}", + style: styles.StyleMapping | None = None, +) -> "Figure": + """Add a funnel: one centered segment per stage, in declared order. + + Stage math (`_funnel.compute_stages`), quad geometry + (`_funnel.compute_layout`) and the label ladder (`_funnel.decide_labels`) + are pure Python at build time, exactly as `hist` owns its binning and + `sankey` its layout; the renderers only ever see `funnel` quads. The stage + axis is categorical (stage names in declared order — never sorted: a + funnel is a business process), the cross axis is numeric and centered on + zero. + + ``geometry="area"`` draws the classic tapering silhouette — each segment's + far edge previews the next stage's width, so painted area is NOT + proportional to the stage value. ``geometry="bar"`` draws centered + constant-width segments whose widths carry the values exactly. ``neck`` + ("rect" | "taper") decides the last area segment's far edge. ``min_width`` + keeps zero/tiny stages visible (a drawn floor as a fraction of the widest + stage); event and label values are never clamped. + + Per-stage colors are a categorical channel over the stage names — palette + slots follow declared stage order, and the legend gets one row per stage. + An explicit `color=` paints every segment one constant color instead (no + per-stage legend rows); `colors=` pins one CSS color per stage. + """ + from . import _funnel + + css = styles.compile_mark_style("funnel", style) + opacity = css.get("opacity", opacity) + stroke = css.get("stroke", stroke) + stroke_width = css.get("stroke_width", stroke_width) + name = self._optional_text(name, "funnel name") + + if stage is None or value is None: + raise ValueError( + "funnel needs stage names and values: xy.funnel(stage=[...], value=[...]) " + "or xy.funnel(data=df, stage='col', value='col')" + ) + if self._is_category_like(self._materialize_sequence(stage)): + stage_names = self._category_axis_labels(self._materialize_sequence(stage), "funnel stage") + else: + # Numeric stages are legal input (quarter numbers, ordinal codes) but + # a funnel's stage axis is categorical by contract, so they become + # labels in the declared order. + stage_names = [ + channels.category_label(raw) + for raw in np.asarray(self._materialize_sequence(stage)).reshape(-1) + ] + values_arr = self._as_1d_float(value, "funnel value") + layout = _funnel.compute_layout( + stage_names, + [float(v) for v in values_arr], + orientation=orientation, + geometry=geometry, + gap=gap, + neck=neck, + min_width=min_width, + ) + n = len(layout.stages) + + if colors is not None and color is not None: + raise ValueError("funnel takes color= or colors=, not both") + if colors is not None: + if len(colors) != n: + raise ValueError( + f"funnel colors must have one entry per stage ({n}); got {len(colors)}" + ) + stage_css = [_validate.css_color(str(c), "funnel colors") for c in colors] + elif isinstance(self.palette, Mapping): + # A `{category: color}` theme palette pins colors by stage NAME. The + # canonical resolver owns that contract (spare-color fallback and the + # unmapped-category warning), so run it and reorder its per-category + # answer back into declared stage order — the resolver factorizes + # alphabetically for its own determinism, which a funnel must undo. + resolved = channels.resolve_color( + np.array(stage_names, dtype=object), + n, + default_constant=self.next_series_color, + palette=self.palette, + ) + lookup = dict(zip(resolved.categories or [], resolved.palette or [], strict=True)) + stage_css = [lookup[name] for name in stage_names] + else: + stage_css = [self.palette_color(i) for i in range(n)] + + if color is not None: + color_ch = channels.ColorChannel( + mode="constant", constant=_validate.css_color(color, "funnel color") + ) + else: + # Hand-built rather than `resolve_color`: factorization sorts category + # labels alphabetically for palette determinism, but a funnel's palette + # slots must follow the DECLARED stage order (stage 0 wears palette + # color 0), and `colors=` must pin by position. + code_dtype = np.uint8 if n <= channels.MAX_CATEGORIES else np.uint32 + color_ch = channels.ColorChannel( + mode="categorical", + codes=np.arange(n, dtype=code_dtype), + categories=list(layout.stages[i].name for i in range(n)), + palette=stage_css, + ) + + opacity_constant, opacity_channel = channels.resolve_style_channel( + opacity, n, "funnel opacity", minimum=0.0, maximum=1.0 + ) + if opacity_channel is not None: + raise ValueError("funnel opacity is per-trace; use colors= with RGBA for per-stage alpha") + opacity_value = 1.0 if opacity_constant is None else float(opacity_constant) + stroke_value, stroke_ch = _stroke_channel(stroke, n, "funnel stroke") + if stroke_ch is not None: + raise ValueError("funnel stroke is per-trace") + width_constant, width_channel = channels.resolve_style_channel( + stroke_width, n, "funnel stroke_width", minimum=0.0 + ) + if width_channel is not None: + raise ValueError("funnel stroke_width is per-trace") + stroke_width_value = 0.0 if width_constant is None else float(width_constant) + + stage_dim = "y" if orientation == "vertical" else "x" + # Category positions come from the axis registry so a funnel layered onto + # an axis that already holds categories lands after them instead of on top + # of them; on a fresh axis they are exactly 0..n-1. + centers = self._axis_positions(stage_names, stage_dim) + pos0 = np.array([centers[q.stage] + (q.pos0 - q.stage) for q in layout.quads], dtype=np.float64) + pos1 = np.array([centers[q.stage] + (q.pos1 - q.stage) for q in layout.quads], dtype=np.float64) + lo0 = np.array([q.lo0 for q in layout.quads], dtype=np.float64) + hi0 = np.array([q.hi0 for q in layout.quads], dtype=np.float64) + lo1 = np.array([q.lo1 for q in layout.quads], dtype=np.float64) + hi1 = np.array([q.hi1 for q in layout.quads], dtype=np.float64) + + checkpoint = self._checkpoint() + try: + posc0, posc1 = self.store.ingest(pos0), self.store.ingest(pos1) + loc0, hic0 = self.store.ingest(lo0), self.store.ingest(hi0) + loc1, hic1 = self.store.ingest(lo1), self.store.ingest(hi1) + style_dict: dict[str, Any] = { + "opacity": opacity_value, + "orientation": orientation, + "role": "funnel", + } + style_dict.update(styles._opacity_channels(css)) + if stroke_value is not None: + style_dict["stroke"] = stroke_value + if stroke_width_value: + style_dict["stroke_width"] = stroke_width_value + # Slot mapping (funnel geometry contract): the stage-axis edges ride + # the stage axis's x0/x1-or-y0/y1 pair, the leading cross edges ride + # the other pair, and the generic x/y slots carry the TRAILING cross + # edges — both on the CROSS axis scale, which is why `_range_columns` + # has a funnel branch. + if orientation == "vertical": + trace = Trace( + id=len(self.traces), + kind="funnel", + x=loc1, + y=hic1, + x0=loc0, + x1=hic0, + y0=posc0, + y1=posc1, + name=name, + style=style_dict, + color_ch=color_ch, + count=n, + ) + else: + trace = Trace( + id=len(self.traces), + kind="funnel", + x=loc1, + y=hic1, + x0=posc0, + x1=posc1, + y0=loc0, + y1=hic0, + name=name, + style=style_dict, + color_ch=color_ch, + count=n, + ) + self.traces.append(trace) + # Numeric fields are the event payload; the `*_text` twins are the + # readout. Python owns every format so a tooltip, a label and a static + # export can never disagree about how a number reads — the client has + # no `str.format`, and re-implementing these specs in JS is exactly the + # divergence the single-reference rule exists to prevent. + trace.tooltip_rows = [ + { + "stage": s.name, + "value": s.value, + "share": s.share, + "prior": s.prior, + "conversion": s.conversion, + "dropoff": s.dropoff, + "value_text": _funnel.format_value(s.value, value_format), + "share_text": _funnel.format_ratio(s.share, percent_format), + "conversion_text": _funnel.format_ratio(s.conversion, percent_format), + "dropoff_text": _funnel.format_ratio(s.dropoff, percent_format), + } + for s in layout.stages + ] + if labels: + plot_w = self.width if isinstance(self.width, int) else 640 + plot_h = self.height if isinstance(self.height, int) else 400 + specs = _funnel.decide_labels( + layout, + show_values=show_values, + show_conversion=show_conversion, + show_dropoff=show_dropoff, + value_format=value_format, + percent_format=percent_format, + font_size=label_size, + plot_px=(plot_w * 0.85, plot_h * 0.85), + ) + for spec in specs: + if spec.placement == "hidden": + continue + inside = spec.placement == "inside" + pos = centers[spec.stage] + (spec.pos - spec.stage) + if orientation == "vertical": + x_anchor, y_anchor = spec.cross, pos + dx, dy = (0.0, 0.0) if inside else (8.0, 0.0) + else: + x_anchor, y_anchor = pos, spec.cross + dx, dy = (0.0, 0.0) if inside else (0.0, -8.0) + self.text( + x_anchor, + y_anchor, + spec.text, + dx=dx, + dy=dy, + anchor="middle" if inside else spec.anchor, + color=_funnel_label_color(stage_css[spec.stage]) if inside else None, + style={"font_size": label_size}, + # A funnel label describes one stage's geometry, so a + # legend toggle must hide the two together — an orphaned + # value floating over an empty slot is worse than no + # label. A drop-off label belongs to the boundary it + # names, so it retires with its own stage. + owner={"trace": trace.id, "category": spec.stage}, + ) + return self + except Exception: + self._rollback(checkpoint) + raise + + +def _funnel_label_color(css: str) -> Optional[str]: + """Contrast color for a label inside a segment of fill `css`. + + WCAG relative-luminance threshold; None (theme default) when the fill is + not statically parseable (a CSS variable), which keeps the label legible + in whichever theme resolves the variable. + """ + try: + from ._raster import _parse_color + + r, g, b, _a = _parse_color(css) + except Exception: + return None + + def channel(v: int) -> float: + c = v / 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + return "#1f2430" if luminance > 0.45 else "#f7f8fa" + + def triangle_mesh( self: "Figure", x0: ArrayLike, diff --git a/python/xy/styles.py b/python/xy/styles.py index adf7be13..6edeb17e 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -40,6 +40,9 @@ # deliberately absent from its property set and the guard below rejects a # `linear-gradient(...)` on it with the standard message. _RIBBON_KINDS = frozenset({"ribbon"}) +# Funnel per-stage colors are a channel (categorical by stage, or explicit +# per-stage paints), so like ribbon its style carries no "fill". +_FUNNEL_KINDS = frozenset({"funnel"}) _DENSITY_KINDS = frozenset({"heatmap", "hexbin"}) _AXIS_COLOR_PROPERTIES = frozenset( @@ -88,6 +91,7 @@ | _FILL_KINDS | _MESH_KINDS | _RIBBON_KINDS + | _FUNNEL_KINDS | _DENSITY_KINDS ) ) @@ -292,10 +296,11 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: # distribution outline, so keep their smaller fill-only contract. if kind == "box": props |= {"stroke", "stroke-width", "stroke-opacity"} - elif kind in _RIBBON_KINDS: - # No "fill": a ribbon's two end colors are channels. Leaving the - # property out is what makes `style={"fill": "linear-gradient(...)"}` - # raise instead of silently painting one end's colour across the band. + elif kind in _RIBBON_KINDS | _FUNNEL_KINDS: + # No "fill": a ribbon's two end colors — and a funnel's per-stage + # colors — are channels. Leaving the property out is what makes + # `style={"fill": "linear-gradient(...)"}` raise instead of silently + # painting one paint across per-mark geometry. props |= {"fill-opacity", "stroke", "stroke-width", "stroke-opacity"} elif kind in _MESH_KINDS: props |= { @@ -361,12 +366,16 @@ def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> di _set(out, target, paint, prop, seen) elif prop == "stroke": target = "line_color" if kind == "area" else "color" - if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | {"box"}: + if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | _FUNNEL_KINDS | { + "box" + }: target = "stroke" _set(out, target, _paint(raw, f"{label}['stroke']"), prop, seen) elif prop == "stroke-width": target = "line_width" if kind in _AREA_KINDS else "width" - if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | {"box"}: + if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | _FUNNEL_KINDS | { + "box" + }: target = "stroke_width" _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen) elif prop == "stroke-dasharray": diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 1cf54701..3f97a21b 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -1219,7 +1219,55 @@ def main() -> None: gMc.readPixels(Math.round(WM*0.8),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,rpx); const meancolor=(lpx[0]>60 && lpx[0]>lpx[2]*3 && rpx[2]>60 && rpx[2]>rpx[0]*3)?1:0; vMc.destroy();holderMc.remove(); - const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} dretire=${{dretire}}`; + // Funnel: per-stage quads expand locally into mesh triangles wearing the + // categorical palette; hover is CPU trapezoid containment returning the + // STAGE index with semantic tooltip rows; the per-stage centers join the + // keyboard traversal groups. Three stages, vertical, no axis reverse so + // stage 0 sits at the BOTTOM of the canvas (GL y small). + const fnBuf=new ArrayBuffer(512); const fnCols=[]; let fnOff=0; + const fncol=(vals)=>{{new Float32Array(fnBuf,fnOff*4,vals.length).set(vals); + fnCols.push({{byte_offset:fnOff*4,len:vals.length,offset:0,scale:1,kind:"float"}}); + fnOff+=vals.length; return fnCols.length-1;}}; + const fnu8=(vals)=>{{const bo=fnOff*4;new Uint8Array(fnBuf,bo,vals.length).set(vals); + fnCols.push({{byte_offset:bo,len:vals.length,dtype:"u8"}}); + fnOff+=Math.ceil(vals.length/4); return fnCols.length-1;}}; + const fnSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", + show_legend:false,show_modebar:false, + x_axis:{{kind:"linear",label:"",range:[-6,6]}}, + y_axis:{{kind:"linear",label:"",range:[-0.5,2.5]}}, + traces:[{{id:0,kind:"funnel",name:null,tier:"direct",n_points:3,n_marks:3, + style:{{opacity:1.0,orientation:"vertical",role:"funnel"}}, + orientation:"vertical", + pos0:fncol([-0.4,0.6,1.6]),pos1:fncol([0.4,1.4,2.4]), + lo0:fncol([-5,-3,-1]),hi0:fncol([5,3,1]), + lo1:fncol([-3,-1,-1]),hi1:fncol([3,1,1]), + color:{{mode:"categorical",categories:["A","B","C"],dtype:"u8", + buf:fnu8([0,1,2]),palette:["#e01010","#10c010","#1030e0"]}}, + tooltip_rows:[ + {{stage:"A",value:10,share:1.0,prior:null,conversion:null,dropoff:null}}, + {{stage:"B",value:6,share:0.6,prior:10,conversion:0.6,dropoff:0.4}}, + {{stage:"C",value:2,share:0.2,prior:6,conversion:0.3333,dropoff:0.6667}}]}}], + columns:fnCols}}; + const holderFn=document.createElement("div");document.body.appendChild(holderFn); + const vFn=xy.renderStandalone(holderFn,fnSpec,fnBuf); + vFn._drawNow(); + const gFn=vFn.gpuTraces[0]; + const glFn=vFn.gl,WF=vFn.canvas.width,HF=vFn.canvas.height; + const apx=new Uint8Array(4), cpx=new Uint8Array(4); + // Stage A (data y=0, bottom sixth of the y range) and stage C (top). + glFn.readPixels(Math.round(WF/2),Math.round(HF*0.17),1,1,glFn.RGBA,glFn.UNSIGNED_BYTE,apx); + glFn.readPixels(Math.round(WF/2),Math.round(HF*0.83),1,1,glFn.RGBA,glFn.UNSIGNED_BYTE,cpx); + const fnInk=(apx[0]>90 && apx[0]>apx[2]*2 && cpx[2]>90 && cpx[2]>cpx[0]*2)?1:0; + // Containment: stage B center hits index 1; a point beside B's taper at + // the same height (cross 4.5 > its widest half-width 3) misses. + const fnHit=vFn._funnelHover(gFn,0,1.0); + const fnMiss=vFn._funnelHover(gFn,4.5,1.0); + const fnRow=fnHit?vFn._localRow(fnHit):null; + const fnRowOk=(fnRow && fnRow.stage==="B" && fnRow.value===6 && fnRow.dropoff===0.4)?1:0; + const fnNav=vFn._a11yPointGroups().some((g)=>g===gFn)?1:0; + const funnel=(fnInk && fnHit && fnHit.index===1 && !fnMiss && fnRowOk && fnNav)?1:0; + vFn.destroy();holderFn.remove(); + const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} funnel=${{funnel}} dretire=${{dretire}}`; const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; // Responsive: 100%-by-100% chart in a 400x300 container tracks its parent; // growing the container must fire the ResizeObserver and re-render bigger. @@ -1424,6 +1472,7 @@ def main() -> None: dying = int(re.search(r"dying=(\d+)", title).group(1)) density_lit = int(re.search(r"densityLit=(\d+)", title).group(1)) meancolor = int(re.search(r"meancolor=(\d+)", title).group(1)) + funnel = int(re.search(r"funnel=(\d+)", title).group(1)) dretire = int(re.search(r"dretire=(\d+)", title).group(1)) dpick = int(re.search(r"dpick=(\d+)", title).group(1)) hold = int(re.search(r"hold=(\d+)", title).group(1)) @@ -1606,6 +1655,11 @@ def main() -> None: "mean-color density failed (surface must wear the per-cell mean " "point colors, count as alpha — LOD doc §2)" ) + if funnel != 1: + raise SystemExit( + "funnel failed (per-stage palette ink, trapezoid containment " + "hover with stage index + semantic rows, or a11y stage nav)" + ) if dretire != 1: raise SystemExit( "settled drill kept its aggregate backdrop painted (T10: the " diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 1c4b18f1..069fc218 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -13,7 +13,7 @@ which is sometimes deliberate, and the notes say which. ## In one line -- **11** mark style properties across **21** mark kinds, drawn by all three renderers. +- **11** mark style properties across **22** mark kinds, drawn by all three renderers. - **48** stable chrome slots, CSS- and Tailwind-addressable in the browser; **10** of them reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. - **1** shipped extension point. - **1** known default divergence between renderers, listed below rather than left to be discovered. @@ -26,12 +26,12 @@ 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`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `opacity` | css | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `heatmap`, `hexbin`, `hist`, `histogram`, `line`, `ribbon`, `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`, `ribbon`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `funnel`, `heatmap`, `hexbin`, `hist`, `histogram`, `ribbon`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | +| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `funnel`, `hist`, `histogram`, `line`, `ribbon`, `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 | diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 31ee6978..85ce064b 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -24,9 +24,10 @@ reduces to a few GPU primitives on top of the shared infrastructure. Establish the primitive once; the charts sharing it are mostly wiring. The registry is the authority on what exists. `MARK_KINDS` (`js/src/55_marks.ts`) -holds nineteen kinds today — `area`, `bar`, `box`, `box_median`, `box_whisker`, -`column`, `contour`, `error_band`, `errorbar`, `heatmap`, `hexbin`, `histogram`, -`line`, `ribbon`, `scatter`, `segments`, `stem`, `triangle_mesh`, `violin` — each with a +holds twenty kinds today — `area`, `bar`, `box`, `box_median`, `box_whisker`, +`column`, `contour`, `error_band`, `errorbar`, `funnel`, `heatmap`, `hexbin`, +`histogram`, `line`, `ribbon`, `scatter`, `segments`, `stem`, `triangle_mesh`, +`violin` — each with a matching `_emit_` in `_payload.py`. `density` is a *tier* of `scatter`, not a kind. Public builders that reuse an existing kind add no registry entry: `hist` → `histogram`, and `step`/`stairs`/`ecdf` → `line`. @@ -146,6 +147,66 @@ lasso selection is correctly absent rather than present and wrong. When semantic fields so a Sankey tooltip describes the flow or node rather than its internal placement coordinates. +#### The funnel geometry contract + +A `funnel` is a run of symmetric quads: one segment per stage of an ordered +process, centered on zero along a cross axis, with independent cross widths at +each end so a segment can taper. It is the primitive behind funnel charts and +a straight, transposable sibling of the ribbon; a future pyramid or population +chart reuses it. + +Three renderers draw it and must agree exactly: the client's funnel program +(`FUNNEL_VS` + the shared `RIBBON_FS`, `js/src/40_gl.ts`), and both static +exporters through `_scene.funnel_quad` — the single corner reference the +golden tests pin. This section is normative — a fourth renderer implements it +without reading the other three. + +**On the wire.** All layout happens in Python at build time +(`python/xy/_funnel.py` — validation, conversion arithmetic, quad +construction, the label ladder), the way `_sankey` owns the Sankey placement; +renderers only ever see quads plus semantics: + +| Field | Meaning | +| --- | --- | +| `kind` | `"funnel"` | +| `tier` | always `"direct"` — one quad per stage is small-N by nature, and neither decimation nor a density tier means anything for it (§28) | +| `orientation` | `"vertical"` (stage axis = y) or `"horizontal"` (stage axis = x) | +| `pos0`, `pos1` | segment edges along the stage axis, shipped on that axis's scale. Stage order is the DECLARED order; positions come from the categorical stage axis | +| `lo0`, `hi0` | cross-axis edges at `pos0`, on the cross axis's scale | +| `lo1`, `hi1` | cross-axis edges at `pos1` — internally these ride the trace's generic `x`/`y` slots, which is why `_range_columns` has a funnel branch | +| `n_marks` | quad count (= stage count) | +| `color` | per-stage paint channel: `constant`, `categorical` (codes + palette, categories in DECLARED stage order — the funnel factory builds this by hand because generic factorization sorts labels alphabetically), or `direct_rgba` | +| `tooltip_rows` | one semantic row per stage: `stage`, `value`, `prior`, `share`, `conversion`, `dropoff`. Ratios over a zero denominator are `null`, never `inf`. Small-N readouts, not geometry, per §29's raw-buffer rule | + +**Geometry.** Corners run A=(`lo0`@`pos0`) B=(`hi0`@`pos0`) C=(`hi1`@`pos1`) +D=(`lo1`@`pos1`), transposed per orientation. Edges are STRAIGHT in +axis-transformed space: the exporters map the six edge values first and join +them with lines (`_scene.funnel_quad` consumes mapped values), and the client +lerps between per-vertex `xyMap` results in clip space — the affine image of +transformed space, so all three draw literally the same line on every axis +type. CPU hover lerps the same edges in transformed space and returns the +QUAD (= stage) index. + +**Paint.** One flat color per stage; there is no along-segment gradient (that +is the ribbon's contract, and its `a_rgba2` slot is exactly what a funnel does +not need). `opacity`, `stroke`, `stroke_width` are per-trace scalars, refused +as arrays; `style.fill` is absent from the property set (per-stage paint is +the channel). An omitted stroke color outlines each segment with its own fill +(`edgecolors="face"`), resolved per stage in every renderer. The client's +segment edges get `fwidth` coverage antialiasing from the shared ribbon +fragment stage — the GL context is `antialias: false`, and without coverage +the funnel's long diagonals staircase (found in review). + +**Interaction.** `pointPick` is false (the GPU id pass draws points from the +generic slots — trailing cross edges, garbage ids); hover is the CPU +containment path, so box/lasso selection is absent rather than wrong, as for +ribbon. The registry entry sets `stageNav`: the per-stage centers join the +keyboard traversal groups, so arrow keys walk the declared order and the +announcement reads "Stage i of n" plus the stage's semantic row. + +Deferred, recorded in the roadmap: GPU picking, box/lasso selection, and +multi-series comparison grouping (facets are the current answer). + #### Shared-geometry marks: the hexbin centers-only contract A mark whose cells all share one geometry ships **centers plus channels**, not diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 042dd7de..38361b34 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -129,7 +129,7 @@ not fall out of sight. | 12 | Violin and distribution shapes | violin, split violin, KDE plot, density ridge | Implemented core | Bounded-resolution smoothed distribution bands through the rectangle renderer. | | 13 | Contour | contour, filled contour, isolines | Implemented core | Marching-squares isolines over regular grids, optionally layered on heatmap fill. | | 14 | Waterfall | waterfall, bridge chart | Planned | Business reporting and finance expectation; mostly categorical bars plus running baseline. | -| 15 | Funnel | funnel, funnel area, conversion funnel | Planned | Product analytics and sales/ops dashboard expectation. | +| 15 | Funnel | funnel, funnel area, conversion funnel | Implemented core | `xy.funnel_chart`/`xy.funnel` render per-stage quads through the `funnel` kind (protocol v13): declared-order categorical stages, explicit area/bar geometry, rect/taper neck, gaps and a drawn-width floor, conversion/drop-off arithmetic in labels, tooltips, events, and keyboard traversal. Follow-ups are multi-series comparison grouping and box/lasso selection. | | 16 | Treemap | treemap, squarified treemap | Planned | Common BI hierarchy chart; requires layout and label polish. | | 17 | Sunburst / icicle | sunburst, icicle, radial hierarchy | Planned | Plotly/Highcharts/ECharts compatibility for hierarchical data. | | 18 | Radar / polar | radar, spider, polar area, radial bar, polar heatmap/contour | Implemented core | `xy.polar_chart` renders the allowlisted line/scatter/area/bar/column/heatmap/contour/errorbar schemas through the polar coordinate system (spec/design/polar-axes.md); `xy.radar_chart`, `xy.polar_bar_chart` and `xy.wind_rose` are the compositions. Sector layout, hole/r-origin, categorical θ, log/symlog radius, and polygonal grids ship. Follow-ups are rule/band geometry, polar LOD, facets/animation, and angular navigation/selection. | diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4f9ccd41..64a699ad 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -469,7 +469,13 @@ Two independent version constants: v11 client would silently draw a full circular, centre-origin view and route those grid/segment traces through their Cartesian paths. The v12 handshake rejects that stale bundle before any of those compatible-looking wrong - pictures can appear. + pictures can appear. v13 adds the `funnel` mark kind (per-stage symmetric + quads: six semantic geometry columns re-labelled `pos0`/`pos1`/`lo0`/`hi0`/ + `lo1`/`hi1`, an `orientation` field, a per-stage paint channel, and semantic + `tooltip_rows` — the funnel geometry contract in + `spec/api/chart-kind-contract.md`). `markOf()` falls back to scatter for + unknown kinds, so a cached v12 client would silently draw every funnel as a + point cloud of trailing cross edges. - **Transport frame.** `FRAME_MAGIC` `"XYBF"` with `FRAME_VERSION = 1` versions the binary envelope separately, so the transport and the renderer can evolve without coupling. diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 8df54519..bb9165a7 100644 --- a/tests/pyplot/test_tick_side_rendering.py +++ b/tests/pyplot/test_tick_side_rendering.py @@ -120,7 +120,7 @@ def test_tick_sides_bump_wire_protocol_and_client_in_lockstep() -> None: client = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") assert spec["x_axis"]["tick_sides"] == ["bottom", "top"] - assert spec["protocol"] == PROTOCOL_VERSION == 12 + assert spec["protocol"] == PROTOCOL_VERSION == 13 assert f"PROTOCOL = {PROTOCOL_VERSION};" in header # The point is that the client reads PROTOCOL from the header, not the exact # spelling of the import list — which grows whenever the header gains another diff --git a/tests/test_accessibility_contract.py b/tests/test_accessibility_contract.py index 443d739f..47dfb8e4 100644 --- a/tests/test_accessibility_contract.py +++ b/tests/test_accessibility_contract.py @@ -66,7 +66,7 @@ def test_keyboard_navigation_reuses_hover_and_tooltip_pipeline() -> None: "const hit = { trace: g.trace.id, index: offset, g }", "this._showTooltip(hit, clientX, clientY)", "this._drawKeepPick()", - "Point ${prefix.flat + 1} of ${prefix.total}.", + "${noun} ${prefix.flat + 1} of ${prefix.total}.", "if (this._interactionTransitionActive()) return;", 'this.a11yLive.textContent = "Readout closed."', 'this._dispatchChartEvent("leave"', diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py index 8195a4fe..a652bc72 100644 --- a/tests/test_api_parity.py +++ b/tests/test_api_parity.py @@ -38,6 +38,7 @@ ("scatter", "scatter"), ("ribbon", "ribbon"), ("sankey", "sankey"), + ("funnel", "funnel"), ("line", "line"), ("area", "area"), ("histogram", "histogram"), @@ -64,6 +65,7 @@ "scatter": lambda: xy.scatter(x=[1.0, 2.0], y=[3.0, 4.0]), "ribbon": lambda: xy.ribbon([0.0], [1.0], [0.0], [0.4], [0.2], [0.6]), "sankey": lambda: xy.sankey([("a", "b", 1.0)]), + "funnel": lambda: xy.funnel(stage=["a", "b"], value=[2.0, 1.0]), "line": lambda: xy.line(x=[1.0, 2.0], y=[3.0, 4.0]), "area": lambda: xy.area(x=[1.0, 2.0], y=[3.0, 4.0]), "histogram": lambda: xy.histogram(values=[1.0, 2.0, 3.0]), diff --git a/tests/test_check_typing.py b/tests/test_check_typing.py index 55ff617c..60e41eaf 100644 --- a/tests/test_check_typing.py +++ b/tests/test_check_typing.py @@ -92,7 +92,7 @@ def test_canonical_public_names_come_from_source_exports(tmp_path: Path) -> None def test_canonical_public_names_match_the_current_root_contract() -> None: names = check_typing._canonical_public_names() - assert len(names) == 102 + assert len(names) == 104 assert names == sorted(xy.__all__) diff --git a/tests/test_funnel.py b/tests/test_funnel.py new file mode 100644 index 00000000..a0b62185 --- /dev/null +++ b/tests/test_funnel.py @@ -0,0 +1,723 @@ +"""Funnel charts: stage math, quad geometry, wire shape, labels, and +cross-renderer parity. + +The arithmetic and layout are pinned by direct assertions on +`_funnel.compute_stages` / `_funnel.compute_layout`; the geometry is pinned by +comparing both static exporters against `_scene.funnel_quad`, the single +reference the contract names — the failure mode being guarded is a renderer +quietly drawing its own quad (or a funnel entry silently skipped because its +wire columns don't match the rect family's names). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import numpy as np +import pytest + +import xy +from xy import _funnel +from xy._figure import Figure +from xy._funnel import compute_layout, compute_stages, decide_labels +from xy._scene import funnel_quad +from xy.config import PROTOCOL_VERSION +from xy.interaction import row_dict + +STAGES = ["Visit", "Signup", "Activate", "Trial", "Pay"] +VALUES = [9800.0, 6200.0, 3100.0, 2200.0, 1450.0] + + +def _funnel_figure(**kwargs) -> Figure: + fig = Figure(width=640, height=430) + fig.funnel(STAGES, VALUES, **kwargs) + return fig + + +# -- stage arithmetic -------------------------------------------------------- + + +def test_conversion_and_dropoff_are_numerically_correct() -> None: + stages = compute_stages(STAGES, VALUES) + assert [s.value for s in stages] == VALUES + assert stages[0].share == 1.0 + assert stages[0].prior is None + assert stages[0].conversion is None + assert stages[0].dropoff is None + assert stages[1].share == pytest.approx(6200 / 9800) + assert stages[1].prior == 9800.0 + assert stages[1].conversion == pytest.approx(6200 / 9800) + assert stages[1].dropoff == pytest.approx(1 - 6200 / 9800) + assert stages[4].share == pytest.approx(1450 / 9800) + assert stages[4].conversion == pytest.approx(1450 / 2200) + + +def test_increasing_stage_is_allowed_with_conversion_above_one() -> None: + stages = compute_stages(["a", "b"], [100.0, 120.0]) + assert stages[1].conversion == pytest.approx(1.2) + assert stages[1].dropoff == pytest.approx(-0.2) + + +def test_zero_prior_yields_none_ratios_not_inf() -> None: + stages = compute_stages(["a", "b", "c"], [10.0, 0.0, 5.0]) + assert stages[1].conversion == 0.0 + assert stages[1].dropoff == 1.0 + # c follows a zero stage: value / 0 has no meaning, and must never be inf. + assert stages[2].conversion is None + assert stages[2].dropoff is None + assert all(s.share is not None for s in stages) + + +def test_zero_first_stage_makes_every_share_undefined() -> None: + stages = compute_stages(["a", "b"], [0.0, 0.0]) + assert all(s.share is None for s in stages) + + +def test_repeated_values_are_full_conversion() -> None: + stages = compute_stages(["a", "b"], [7.0, 7.0]) + assert stages[1].conversion == 1.0 + assert stages[1].dropoff == 0.0 + + +@pytest.mark.parametrize( + ("names", "values", "message"), + [ + (["a", "b"], [1.0], "one value per stage"), + ([], [], "at least one stage"), + (["a", "a"], [1.0, 2.0], "unique"), + (["a", "b"], [1.0, -2.0], r"negative value \(-2\)"), + (["a", "b"], [1.0, float("nan")], "missing value"), + (["a", "b"], [1.0, float("inf")], "non-finite"), + (["a", "b"], [1.0, "wat"], "non-numeric"), + ], +) +def test_bad_stage_values_are_refused_by_name(names, values, message) -> None: + with pytest.raises(ValueError, match=message): + compute_stages(names, values) + + +def test_validation_names_the_offending_stage() -> None: + with pytest.raises(ValueError, match="'Signup'"): + compute_stages(["Visit", "Signup"], [10.0, -1.0]) + + +# -- layout ------------------------------------------------------------------ + + +def test_stage_order_is_declared_order_never_sorted() -> None: + names = ["Zeta", "Alpha", "Mid"] + layout = compute_layout(names, [3.0, 2.0, 1.0]) + assert [s.name for s in layout.stages] == names + assert [q.stage for q in layout.quads] == [0, 1, 2] + # Position along the stage axis follows the declared order exactly. + assert [q.pos0 for q in layout.quads] == sorted(q.pos0 for q in layout.quads) + + +def test_area_geometry_tapers_to_the_next_stage() -> None: + layout = compute_layout(["a", "b", "c"], [10.0, 6.0, 2.0]) + assert layout.quads[0].hi0 == 5.0 + assert layout.quads[0].hi1 == 3.0 # previews b + assert layout.quads[1].hi0 == 3.0 + assert layout.quads[1].hi1 == 1.0 # previews c + # Last stage under the default rect neck holds its own width. + assert layout.quads[2].hi0 == layout.quads[2].hi1 == 1.0 + for q in layout.quads: + assert q.lo0 == -q.hi0 and q.lo1 == -q.hi1, "segments are centered" + + +def test_bar_geometry_holds_each_stages_own_width() -> None: + layout = compute_layout(["a", "b"], [10.0, 6.0], geometry="bar") + assert layout.quads[0].hi0 == layout.quads[0].hi1 == 5.0 + assert layout.quads[1].hi0 == layout.quads[1].hi1 == 3.0 + + +def test_neck_taper_runs_the_last_stage_to_a_point() -> None: + layout = compute_layout(["a", "b"], [10.0, 6.0], neck="taper") + assert layout.quads[1].hi0 == 3.0 + assert layout.quads[1].hi1 == 0.0 + assert layout.quads[1].lo1 == 0.0 + + +def test_default_gaps_resolve_per_geometry() -> None: + area = compute_layout(["a", "b"], [4.0, 2.0]) + bar = compute_layout(["a", "b"], [4.0, 2.0], geometry="bar") + assert area.gap == 0.0 + assert bar.gap == 0.2 + # Area segments touch; bar segments leave the bar-chart gap. + assert area.quads[0].pos1 == area.quads[1].pos0 + assert bar.quads[0].pos1 == pytest.approx(0.4) + assert bar.quads[1].pos0 == pytest.approx(0.6) + + +def test_explicit_gap_carves_the_stage_pitch() -> None: + layout = compute_layout(["a", "b"], [4.0, 2.0], gap=0.5) + assert layout.quads[0].pos0 == pytest.approx(-0.25) + assert layout.quads[0].pos1 == pytest.approx(0.25) + + +def test_min_width_floors_drawn_geometry_only() -> None: + layout = compute_layout(["a", "b", "c"], [100.0, 0.0, 50.0], min_width=0.1) + # b draws at the floor (10% of the widest stage), stays hoverable... + assert layout.quads[1].hi0 == pytest.approx(5.0) + # ...but its VALUE is untouched everywhere semantic. + assert layout.stages[1].value == 0.0 + # The taper into b also floors (drawn edges agree between neighbours). + assert layout.quads[0].hi1 == pytest.approx(5.0) + + +def test_zero_stage_without_floor_draws_nothing_but_keeps_semantics() -> None: + layout = compute_layout(["a", "b"], [10.0, 0.0]) + assert layout.quads[1].hi0 == 0.0 + assert layout.stages[1].dropoff == 1.0 + + +def test_taper_neck_beats_the_floor_at_the_spout() -> None: + # The documented point at the end of a tapered funnel is a point, not an + # accidentally invisible stage — the floor deliberately does not apply. + layout = compute_layout(["a", "b"], [10.0, 6.0], neck="taper", min_width=0.1) + assert layout.quads[1].hi1 == 0.0 + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"orientation": "diagonal"}, "orientation"), + ({"geometry": "cone"}, "geometry"), + ({"neck": "bulb"}, "neck"), + ({"neck": "taper", "geometry": "bar"}, "neck applies"), + ({"gap": 1.0}, "gap"), + ({"gap": -0.1}, "gap"), + ({"min_width": 1.5}, "min_width"), + ], +) +def test_bad_layout_options_are_refused(kwargs, message) -> None: + with pytest.raises(ValueError, match=message): + compute_layout(["a", "b"], [2.0, 1.0], **kwargs) + + +def test_single_stage_funnel_is_legal() -> None: + layout = compute_layout(["only"], [5.0]) + assert len(layout.quads) == 1 + assert layout.stages[0].conversion is None + + +# -- labels ------------------------------------------------------------------ + + +def _labels(layout, **kwargs): + defaults = dict( + show_values=True, + show_conversion=True, + show_dropoff=False, + value_format="{:,.10g}", + percent_format="{:.0%}", + font_size=12.0, + plot_px=(544.0, 366.0), + ) + defaults.update(kwargs) + return decide_labels(layout, **defaults) + + +def test_wide_stages_label_inside_and_narrow_ones_outside() -> None: + layout = compute_layout(STAGES, VALUES) + labels = _labels(layout) + placement = {label.stage: label.placement for label in labels} + assert placement[0] == "inside" + assert placement[1] == "inside" + assert placement[4] == "outside", "the narrow Pay stage falls outside" + outside = next(label for label in labels if label.stage == 4) + assert outside.anchor == "start" + assert outside.cross > 0.0 + + +def test_labels_hide_when_the_stage_pitch_cannot_hold_a_line() -> None: + layout = compute_layout(STAGES, VALUES) + labels = _labels(layout, plot_px=(544.0, 40.0)) + assert {label.placement for label in labels} == {"hidden"} + + +def test_dropoff_labels_show_the_signed_change() -> None: + layout = compute_layout(["a", "b", "c"], [100.0, 62.0, 74.4]) + labels = _labels(layout, show_dropoff=True) + drop = [label for label in labels if label.kind == "dropoff"] + assert [label.text for label in drop] == ["-38%", "+20%"] + assert all(label.pos == label.stage - 0.5 for label in drop) + + +def test_dropoff_after_a_zero_stage_shows_a_dash() -> None: + layout = compute_layout(["a", "b", "c"], [10.0, 0.0, 5.0]) + labels = _labels(layout, show_dropoff=True) + drop = [label for label in labels if label.kind == "dropoff"] + assert drop[1].text == "—" + + +def test_value_formats_are_honoured() -> None: + layout = compute_layout(["a"], [1234.5]) + labels = _labels(layout, value_format="{:.1f}", percent_format="{:.1%}") + assert labels[0].text == "1234.5 100.0%" + + +def test_funnel_figure_emits_label_annotations_with_contrast_color() -> None: + fig = _funnel_figure() + texts = [a for a in fig.annotations if a["kind"] == "text"] + assert any("9,800" in a["text"] for a in texts) + inside = next(a for a in texts if "9,800" in a["text"]) + # Inside labels carry an explicit contrast color picked from the fill. + assert inside["style"]["color"] in {"#1f2430", "#f7f8fa"} + fig_off = Figure(width=640, height=430) + fig_off.funnel(STAGES, VALUES, labels=False) + assert fig_off.annotations == [] + + +# -- the mark and its wire shape --------------------------------------------- + + +def test_funnel_trace_ships_semantic_quads_direct_tier() -> None: + fig = _funnel_figure() + spec, _blob = fig.build_payload() + entry = spec["traces"][0] + assert entry["kind"] == "funnel" + assert entry["tier"] == "direct" + assert entry["orientation"] == "vertical" + assert entry["n_marks"] == 5 + for key in ("pos0", "pos1", "lo0", "hi0", "lo1", "hi1"): + assert isinstance(entry[key], int), f"{key} must be a shipped column index" + rows = entry["tooltip_rows"] + assert [row["stage"] for row in rows] == STAGES + assert ( + rows[1] + | { + "stage": "Signup", + "value": 6200.0, + "share": pytest.approx(6200 / 9800), + "prior": 9800.0, + "conversion": pytest.approx(6200 / 9800), + "dropoff": pytest.approx(1 - 6200 / 9800), + } + == rows[1] + ) + + +def test_categorical_palette_follows_declared_stage_order() -> None: + """Factorization sorts labels alphabetically; the funnel must not — stage 0 + wears palette color 0 even when its name sorts last.""" + fig = Figure(width=400, height=300) + fig.funnel(["Zeta", "Alpha"], [10.0, 5.0]) + channel = fig.traces[0].color_ch + assert channel.mode == "categorical" + assert channel.categories == ["Zeta", "Alpha"] + assert list(channel.codes) == [0, 1] + assert channel.palette[0] == fig.palette_color(0) + + +def test_explicit_colors_pin_by_position_and_validate_length() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b"], [2.0, 1.0], colors=["#ff0000", "#00ff00"]) + assert fig.traces[0].color_ch.palette == ["#ff0000", "#00ff00"] + with pytest.raises(ValueError, match="one entry per stage"): + Figure(width=400, height=300).funnel(["a", "b"], [2.0, 1.0], colors=["#ff0000"]) + with pytest.raises(ValueError, match="color= or colors=, not both"): + Figure(width=400, height=300).funnel( + ["a", "b"], [2.0, 1.0], color="#fff", colors=["#ff0000", "#00ff00"] + ) + + +def test_constant_color_ships_a_constant_channel() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b"], [2.0, 1.0], color="#123456") + assert fig.traces[0].color_ch.mode == "constant" + assert fig.traces[0].color_ch.constant == "#123456" + + +def test_stage_axis_is_categorical_with_declared_labels() -> None: + fig = _funnel_figure() + spec, _ = fig.build_payload() + assert spec["y_axis"]["kind"] == "category" + assert spec["y_axis"]["categories"] == STAGES + fig_h = Figure(width=640, height=430) + fig_h.funnel(STAGES, VALUES, orientation="horizontal") + spec_h, _ = fig_h.build_payload() + assert spec_h["x_axis"]["kind"] == "category" + assert spec_h["x_axis"]["categories"] == STAGES + + +def test_horizontal_orientation_transposes_the_slots() -> None: + fig_v = Figure(width=400, height=300) + fig_v.funnel(["a", "b"], [4.0, 2.0]) + fig_h = Figure(width=400, height=300) + fig_h.funnel(["a", "b"], [4.0, 2.0], orientation="horizontal") + tv, th = fig_v.traces[0], fig_h.traces[0] + # Vertical: stage edges ride y0/y1; horizontal: they ride x0/x1. + assert list(tv.y0.values) == list(th.x0.values) + assert list(tv.x0.values) == list(th.y0.values) + + +def test_autorange_covers_the_widest_stage_on_the_cross_axis() -> None: + fig = _funnel_figure() + t = fig.traces[0] + columns = fig._range_columns(t, "x") + lo = min(float(c.values.min()) for c in columns) + hi = max(float(c.values.max()) for c in columns) + assert lo == pytest.approx(-4900.0) + assert hi == pytest.approx(4900.0) + # The stage axis ranges over the quad edges, not the trailing cross edges. + stage_cols = fig._range_columns(t, "y") + assert len(stage_cols) == 2 + + +def test_missing_stage_or_value_is_refused_with_usage() -> None: + with pytest.raises(ValueError, match="funnel needs stage names and values"): + Figure(width=400, height=300).funnel(None, None) + + +def test_per_stage_style_scalars_are_refused_as_arrays() -> None: + with pytest.raises(ValueError, match="per-trace"): + Figure(width=400, height=300).funnel(["a", "b"], [2.0, 1.0], opacity=[0.5, 1.0]) + with pytest.raises(ValueError, match="per-trace"): + Figure(width=400, height=300).funnel(["a", "b"], [2.0, 1.0], stroke_width=[1.0, 2.0]) + + +def test_failed_funnel_rolls_back_the_figure() -> None: + fig = Figure(width=400, height=300) + with pytest.raises(ValueError): + fig.funnel(["a", "b"], [1.0, -1.0]) + assert fig.traces == [] + assert fig.annotations == [] + assert "y" not in fig._axis_categories + + +# -- events ------------------------------------------------------------------ + + +def test_exact_pick_returns_stage_semantics_not_placement() -> None: + fig = _funnel_figure() + row = row_dict(fig, fig.traces[0], 2) + assert row["stage"] == "Activate" + assert row["value"] == 3100.0 + assert row["prior"] == 6200.0 + assert row["conversion"] == pytest.approx(3100 / 6200) + assert row["dropoff"] == pytest.approx(0.5) + assert "x" not in row, "geometry slots are placement, not readout" + + +def test_pick_ratios_after_zero_stage_are_json_null_not_inf() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b", "c"], [10.0, 0.0, 5.0]) + row = row_dict(fig, fig.traces[0], 2) + assert row["conversion"] is None + assert row["dropoff"] is None + + +# -- keys and animation ------------------------------------------------------ + + +def test_stable_keys_attach_for_key_matched_animation() -> None: + chart = xy.funnel_chart( + xy.funnel( + stage=STAGES, + value=VALUES, + key=STAGES, + animation=xy.animation(match="key"), + ), + ) + fig = chart.figure() + funnel_traces = [t for t in fig.traces if t.kind == "funnel"] + assert funnel_traces[0].transition_keys is not None + assert len(funnel_traces[0].transition_keys) == 5 + + +# -- composition surface ----------------------------------------------------- + + +def test_funnel_chart_positional_form_builds_the_mark() -> None: + fig = xy.funnel_chart(STAGES, VALUES).figure() + kinds = [t.kind for t in fig.traces] + assert kinds == ["funnel"] + + +def test_funnel_chart_hides_the_cross_axis_and_reverses_the_stage_axis() -> None: + spec, _ = xy.funnel_chart(STAGES, VALUES).figure().build_payload() + # A hidden axis compiles to fully transparent chrome, not a boolean. + assert spec["x_axis"]["style"]["axis_color"] == "#00000000" + assert spec["x_axis"]["style"]["tick_label_color"] == "#00000000" + assert spec["y_axis"]["reverse"] is True + assert spec["show_legend"] is False + spec_h, _ = xy.funnel_chart(STAGES, VALUES, orientation="horizontal").figure().build_payload() + assert spec_h["y_axis"]["style"]["axis_color"] == "#00000000" + assert spec_h["x_axis"].get("reverse", False) is False + + +def test_funnel_chart_forwards_mark_kwargs_and_rejects_conflicts() -> None: + fig = xy.funnel_chart(STAGES, VALUES, geometry="bar", gap=0.4).figure() + t = fig.traces[0] + quad_span = float(t.y1.values[0] - t.y0.values[0]) + assert quad_span == pytest.approx(0.6) + with pytest.raises(ValueError, match="positional stages and stage="): + xy.funnel_chart(STAGES, VALUES, stage=STAGES) + + +def test_funnel_chart_accepts_an_explicit_mark_child() -> None: + fig = xy.funnel_chart( + xy.funnel(stage=["a", "b"], value=[2.0, 1.0], orientation="horizontal"), + ).figure() + assert [t.kind for t in fig.traces] == ["funnel"] + spec, _ = fig.build_payload() + assert spec["y_axis"]["style"]["axis_color"] == "#00000000", ( + "orientation read from the child mark" + ) + + +def test_funnel_resolves_columns_from_data() -> None: + data = {"step": ["a", "b"], "users": [4.0, 3.0], "id": ["a", "b"]} + fig = xy.funnel_chart(data=data, stage="step", value="users", key="id").figure() + t = fig.traces[0] + assert t.kind == "funnel" + assert t.tooltip_rows[0]["value"] == 4.0 + + +# -- exports ----------------------------------------------------------------- + + +def test_svg_funnel_is_four_corner_paths_with_stage_fills() -> None: + doc = _funnel_figure().to_image(format="svg").decode() + paths = re.findall(r'= 5 + quad_paths = [p for p in paths if p[0].count(" L ") == 3] + assert len(quad_paths) == 5, "each stage is one closed 4-corner path" + fills = [p[1] for p in quad_paths] + assert len(set(fills)) == 5, "each stage wears its own palette color" + for d, _fill in quad_paths: + assert " C " not in d, "funnel edges are straight, never cubics" + + +def test_svg_corners_match_the_scene_reference() -> None: + fig = Figure(width=400, height=300) + fig.set_axis("x", domain=(-6.0, 6.0), tick_label_strategy="none") + fig.set_axis("y", domain=(-0.5, 1.5), tick_label_strategy="none") + fig.funnel(["a", "b"], [10.0, 6.0], labels=False) + doc = fig.to_image(format="svg").decode() + + from xy._svg import _Scale, layout + + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + sx = _Scale(spec["x_axis"], plot["x"], plot["x"] + plot["w"]) + sy = _Scale(spec["y_axis"], plot["y"] + plot["h"], plot["y"]) + expected = funnel_quad( + float(sy(-0.5)), + float(sy(0.5)), + float(sx(-5.0)), + float(sx(5.0)), + float(sx(-3.0)), + float(sx(3.0)), + False, + ) + d = re.findall(r' None: + """The PNG must have ink inside the taper and none outside the slanted + edge — a renderer that fell through to the rect family would fill the + corner the taper cuts away.""" + from test_png_export import _decode_rgba + + fig = Figure(width=400, height=300) + fig.set_axis("x", domain=(-6.0, 6.0), tick_label_strategy="none") + fig.set_axis("y", domain=(-0.5, 1.5), tick_label_strategy="none") + fig.funnel(["a", "b"], [10.0, 2.0], color="#000000", labels=False, gap=0.0) + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + + from xy._svg import _Scale, layout + + spec, _ = fig.build_payload_split() + _w, _h, _c, plot = layout(spec) + sx = _Scale(spec["x_axis"], plot["x"], plot["x"] + plot["w"]) + sy = _Scale(spec["y_axis"], plot["y"] + plot["h"], plot["y"]) + # Mid-height of stage 0 (data y = 0): the taper narrows 5 -> 1, so the + # half-width at the segment's own middle is 3. + mid_y = int(float(sy(0.0))) + inside_x = int(float(sx(2.5))) + outside_x = int(float(sx(4.4))) + assert pixels[mid_y, inside_x, 0] < 128, "ink inside the taper" + assert pixels[mid_y, outside_x, 0] >= 128, "no ink outside the slanted edge" + assert pixels[mid_y, int(float(sx(-4.4))), 0] >= 128, "symmetric on the left" + + +def test_exporters_share_the_reference_quad() -> None: + quad = funnel_quad(0.0, 1.0, -5.0, 5.0, -3.0, 3.0, False) + assert quad.shape == (4, 2) + # A=(lo0@pos0) B=(hi0@pos0) C=(hi1@pos1) D=(lo1@pos1). + assert quad.tolist() == [[-5.0, 0.0], [5.0, 0.0], [3.0, 1.0], [-3.0, 1.0]] + horizontal = funnel_quad(0.0, 1.0, -5.0, 5.0, -3.0, 3.0, True) + assert horizontal.tolist() == [[0.0, -5.0], [0.0, 5.0], [1.0, 3.0], [1.0, -3.0]] + + +def test_funnel_svg_stroke_defaults_to_each_stages_own_fill() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b"], [4.0, 2.0], stroke_width=2.0, labels=False) + doc = fig.to_image(format="svg").decode() + quads = re.findall(r' None: + # markOf() falls back to scatter for unknown kinds, so an old client would + # silently render funnel quads as a point cloud; the handshake must fail + # instead (the same tripwire the ribbon and polar kinds pinned). + assert PROTOCOL_VERSION >= 13 + + +# -- styling surface --------------------------------------------------------- + + +def test_funnel_style_surface_is_the_ribbon_contract() -> None: + """Per-stage paint is a channel, so `fill` is absent from the property set + and refuses with the supported list rather than silently painting one + color across per-stage geometry.""" + with pytest.raises(ValueError, match="funnel supports: fill-opacity, opacity"): + xy.funnel_chart(xy.funnel(stage=["a"], value=[1.0], style={"fill": "#ff0000"})).figure() + with pytest.raises(ValueError, match="border-radius"): + xy.funnel_chart(xy.funnel(stage=["a"], value=[1.0], style={"border-radius": 4})).figure() + + +def test_mark_style_compiles_into_both_static_exports() -> None: + fig = xy.funnel_chart( + xy.funnel( + stage=["A", "B"], + value=[4.0, 2.0], + style={"stroke": "#123456", "stroke-width": 3, "fill-opacity": 0.8}, + ), + ).figure() + doc = fig.to_svg() + assert 'stroke="#123456"' in doc + assert 'fill-opacity="0.8"' in doc + + +def test_theme_palette_mapping_pins_stage_colors_by_name() -> None: + fig = xy.funnel_chart( + ["Visit", "Signup", "Pay"], + [10.0, 6.0, 2.0], + xy.theme(palette={"Pay": "#f4a300", "Visit": "#3b82f6", "Signup": "#10b981"}), + ).figure() + channel = fig.traces[0].color_ch + assert channel.categories == ["Visit", "Signup", "Pay"] + assert channel.palette == ["#3b82f6", "#10b981", "#f4a300"] + + +def test_chart_class_names_reach_the_slot_spec() -> None: + spec, _ = ( + xy.funnel_chart( + ["a", "b"], + [2.0, 1.0], + class_names={ + "title": "text-xl font-semibold", + "annotation_label": "tabular-nums", + "tooltip": "rounded-xl", + }, + title="t", + ) + .figure() + .build_payload() + ) + assert spec["dom"]["class_names"]["title"] == "text-xl font-semibold" + assert spec["dom"]["class_names"]["annotation_label"] == "tabular-nums" + assert spec["dom"]["class_names"]["tooltip"] == "rounded-xl" + + +def test_tooltip_rows_carry_preformatted_text_for_renderer_parity() -> None: + """The client has no `str.format`; Python ships the formatted twin so a + tooltip, a label and a static export cannot disagree about a number.""" + fig = Figure(width=640, height=430) + fig.funnel(STAGES, VALUES, value_format="{:,.0f}", percent_format="{:.1%}") + row = fig.traces[0].tooltip_rows[1] + assert row["value_text"] == "6,200" + assert row["share_text"] == "63.3%" + assert row["conversion_text"] == "63.3%" + assert row["dropoff_text"] == "36.7%" + # Numeric fields stay numeric for events. + assert isinstance(row["value"], float) + + +def test_undefined_ratios_format_as_an_em_dash() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b", "c"], [10.0, 0.0, 5.0]) + row = fig.traces[0].tooltip_rows[2] + assert row["conversion"] is None + assert row["conversion_text"] == "—" + + +# -- legend toggle ----------------------------------------------------------- + + +def test_labels_are_tagged_with_the_stage_that_owns_them() -> None: + """A legend toggle must retire a label with the geometry it describes; the + client can only do that if the wire says which stage owns it.""" + spec, _ = _funnel_figure(show_dropoff=True).build_payload() + annotations = spec["annotations"] + assert annotations, "funnel labels ship as annotations" + for annotation in annotations: + assert annotation["owner"] == {"trace": 0, "category": annotation["owner"]["category"]} + assert 0 <= annotation["owner"]["category"] < len(STAGES) + # The value label of stage 0 belongs to stage 0. + first = next(a for a in annotations if "9,800" in a["text"]) + assert first["owner"]["category"] == 0 + # A drop-off label belongs to the stage it names, not its predecessor. + drop = next(a for a in annotations if a["text"].startswith("-37")) + assert drop["owner"]["category"] == 1 + + +def test_author_annotations_carry_no_owner_tag() -> None: + fig = Figure(width=400, height=300) + fig.text(0.0, 0.0, "hand written") + spec, _ = fig.build_payload() + assert "owner" not in spec["annotations"][0] + + +def test_tooltip_follows_the_pointer_inside_a_segment() -> None: + """A funnel segment covers an area, so its tooltip tracks the cursor the + way a Sankey band's does. Both the re-hover fast path (same hit id) and + the anchor fallback must know that — the fast path is what froze the + tooltip at its entry point.""" + view = (Path(__file__).parents[1] / "js" / "src" / "50_chartview.ts").read_text( + encoding="utf-8" + ) + assert "hit.g._cpuRibbon || hit.g._cpuFunnel" in view + tooltip = (Path(__file__).parents[1] / "js" / "src" / "52_tooltip.ts").read_text( + encoding="utf-8" + ) + assert "g._cpuRibbon || g._cpuFunnel" in tooltip + + +def test_client_filters_funnel_stages_and_suppresses_owned_labels() -> None: + """The category-visibility path must route funnels to their own filter — + `_filterScatterRows` is gated on CPU color codes a funnel does not have, + so without the branch a legend click silently did nothing.""" + client = (Path(__file__).parents[1] / "js" / "src" / "50_chartview.ts").read_text( + encoding="utf-8" + ) + assert "_filterFunnelStages" in client + # The dispatch itself: _applyCategoryVisibility must route a funnel to it. + dispatch = client.split("_applyCategoryVisibility(ti) {")[1][:1600] + assert "g._cpuFunnel" in dispatch and "_filterFunnelStages" in dispatch + annotations = (Path(__file__).parents[1] / "js" / "src" / "51_annotations.ts").read_text( + encoding="utf-8" + ) + assert "_annotationSuppressed" in annotations + # Both draw loops consult it: shapes and labels. + assert annotations.count("this._annotationSuppressed(ann)") == 2 + + +# -- documentation contracts ------------------------------------------------- + + +def test_default_formats_produce_the_documented_labels() -> None: + assert _funnel.format_value(6200.0, "{:,.10g}") == "6,200" + assert _funnel.format_value(0.5, "{:,.10g}") == "0.5" + assert _funnel.format_ratio(0.6326, "{:.0%}") == "63%" + assert _funnel.format_ratio(None, "{:.0%}") == "—" diff --git a/tests/test_polar_phase7_api.py b/tests/test_polar_phase7_api.py index 89995abc..92b6d126 100644 --- a/tests/test_polar_phase7_api.py +++ b/tests/test_polar_phase7_api.py @@ -28,9 +28,9 @@ def _spec(*children: xy.Component) -> dict: return spec -def test_protocol_v12_is_locked_to_the_client() -> None: +def test_protocol_v13_is_locked_to_the_client() -> None: header = Path(__file__).parents[1] / "js" / "src" / "00_header.ts" - assert PROTOCOL_VERSION == 12 + assert PROTOCOL_VERSION == 13 assert f"PROTOCOL = {PROTOCOL_VERSION};" in header.read_text() diff --git a/tests/test_sankey.py b/tests/test_sankey.py index 9a17cd04..47c4b3df 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -235,7 +235,7 @@ def test_sankey_chart_builds_ribbon_traces_only() -> None: # ribbon's internal placement (its target span), never a data readout. for row in (exact_link, exact_node): assert "x" not in row and "y" not in row - assert spec["protocol"] == PROTOCOL_VERSION == 12 + assert spec["protocol"] == PROTOCOL_VERSION == 13 # -- resolved paints and per-trace styles ------------------------------------ diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index b65beb5e..7af2d74f 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -19,6 +19,7 @@ "scatter", "ribbon", "sankey", + "funnel", "line", "area", "histogram", From 011cfa2d178afff20d93fb79ea9df075965f310f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 15:49:43 -0700 Subject: [PATCH 2/9] Docs: show the funnel legend with a runnable example The legend was described in prose only, so a reader had no way to see what `xy.legend(...)` on a funnel actually does. Adds an "Add a Legend" section with a live demo whose rows are clickable, and says what the click does (hides the stage's segment AND its labels, leaves the other stages' arithmetic untouched), which `loc`/`title`/`ncols` control, which chrome slots style it, and why a theme palette mapping keeps a swatch and its segment the same colour across charts. --- docs/charts/funnel-chart.md | 51 +++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/charts/funnel-chart.md b/docs/charts/funnel-chart.md index 940f0b17..c26fb12a 100644 --- a/docs/charts/funnel-chart.md +++ b/docs/charts/funnel-chart.md @@ -21,8 +21,9 @@ prior value, overall share, previous-stage conversion, and drop-off. Jump to [the basic chart](#create-a-funnel-chart), [geometry modes](#choose-honest-geometry), [horizontal funnels](#run-the-funnel-horizontally), -[necks, gaps, and floors](#shape-the-silhouette), or -[styling](#style-a-funnel). +[necks, gaps, and floors](#shape-the-silhouette), +[styling](#style-a-funnel), or +[the legend](#add-a-legend). ## Create a Funnel Chart @@ -212,15 +213,45 @@ kernel applies them once for every surface — segment labels, hover tooltips, and static exports all print the same string, because the client is handed the formatted text rather than re-implementing the format spec. -## Interact With a Funnel +## Add a Legend + +The legend is **off by default** — the stage axis already names every stage, +so a second list of the same names is usually noise. Pass an explicit +`xy.legend(...)` child to bring back one row per stage, drawn from the +categorical stage encoding: -The legend is off by default — the stage axis already names every stage — and -an explicit `xy.legend(...)` child brings back one row per stage from the -categorical stage encoding. Those rows are live: clicking one hides that -stage's segment **and its labels**, leaving the remaining stages and their -arithmetic untouched (a funnel's stage values are the data, not a running -total to recompute), and clicking again restores it. Hovering a row -emphasizes its stage and dims the rest. +~~~python demo exec +import reflex_xy +import xy + +legend_funnel = xy.funnel_chart( + ["Sourced", "Screen", "Onsite", "Offer", "Hired"], + [1_840, 920, 388, 152, 121], + xy.legend(loc="center right", title="Stage"), + show_dropoff=True, + percent_format="{:.1%}", + title="Recruiting — click a legend row to hide a stage", +) + + +def funnel_legend_demo(): + return reflex_xy.chart(legend_funnel, height="460px") +~~~ + +Those rows are live. Clicking one hides that stage's segment **and its +labels**, leaving every other stage's geometry and arithmetic untouched — a +funnel's stage values are the data, not a running total to recompute — and +clicking again restores it. Hovering a row emphasizes its stage and dims the +rest. `xy.legend(show=False)` is the default; `loc`, `title`, and `ncols` +place and shape it like any other chart's legend, and the `legend`, +`legend_item`, `legend_swatch`, and `legend_label` +[chrome slots](/docs/xy/styling/chrome-slots/) style it. + +Because stage colours come from a categorical channel keyed on the stage +names, a `xy.theme(palette={...})` mapping keeps each legend swatch and its +segment in the same colour across every chart that names that stage. + +## Interact With a Funnel Hover reads the full arithmetic for a stage, and because a segment covers an area rather than a point, the tooltip follows the cursor within it. Clicking From b38a333f40aaf95fb29d64de35f2d38316feade2 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 17:03:59 -0700 Subject: [PATCH 3/9] Funnel review round 2: correctness, parity, and interaction fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent adversarial reviews of #474 (an external one and a seven-dimension workflow run over the diff) agreed on a set of real defects. Both flagged the horizontal label transposition first, and each found things the other did not. Correctness - Conversion ratios could overflow to +/-inf on a wide dynamic range (1e-300 -> 1e10), contradicting the documented "never inf" contract and putting a non-JSON value on the wire. Undefined is undefined whether the denominator was zero or the quotient overflowed: both are None, both print the em dash (_funnel._ratio). - Horizontal funnels tested label fit on the WRONG axis: text width was measured against the segment's cross height and the line height against the stage pitch. Text never rotates, so both budgets were transposed and the inside/outside/hidden ladder silently did not hold — a ten-stage 400px funnel marked every ~101px label as fitting inside a 34px stage. The fit now measures the box the segment actually offers, and horizontal boundary labels take their own row so they cannot overprint the values beside them. - Inside-label contrast read the palette even when a constant `color=` was painting every segment, so a white funnel got near-white labels. It now reads the fill actually drawn — and defers to the theme's text colour for browser-only fills, where `_parse_color` silently substitutes its fallback blue rather than failing (the previous None branch was dead code). - A failed build (a bad `value_format`, say) left its stage names in the axis category registry, shifting the next valid funnel's positions. The checkpoint now precedes the commit. - The theme `palette={...}` reorder raised KeyError when stage names are themselves CSS colours: the shared resolver reads such a column as per-point paint, so there were no categories to reorder. The map is keyed by stage name either way. - Multidimensional stage arrays were silently flattened into an invented stage order; refused now. Axis validation - A funnel's segments are centered on zero, so half its corners are negative: a log cross axis mapped them hundreds of thousands of pixels offscreen and a forced time stage axis stripped the stage labels. Both refuse at payload build, beside the polar check and for the same §28 reason — a plausible wrong picture is worse than an error. Client - A theme refresh while a stage was legend-hidden rebuilt the paint cache at the VISIBLE length, recolouring survivors by their drawn index and losing the hidden rows for good. Paint is now always resolved over the full stage count, and one shared upload path applies the filter — so refresh, filter and hover-dim cannot disagree about row order. - A GPU rebuild (context restore, streaming append) un-hid filtered stages: _reapplyLegendVisibility routed everything to the scatter filter, which no-ops on a funnel. It now routes funnels to their own filter. - Legend hover dimmed the rows but not the funnel: the generic path swaps a palette LUT and a funnel carries resolved RGBA. It recolours the rows with the same blend rule, and restores them on leave. - Keyboard traversal walked legend-hidden stages that pointer hover already refused, and the "Stage i of n" total counted them. Traversal now walks the visible rows and reports the SHIPPED row index. - A legend-hidden series answered CPU hover for every kind that uses it (bar, rect, ribbon, funnel), reporting invisible geometry. Animation (the API was advertised and inert) - `key=`/`animation=` reached the payload but funnel geometry was excluded from position interpolation and the draw ignored transition progress, so key-matched updates snapped and `enter="grow"` did nothing. Funnel now has its own interpolation prep (start values are the old trace's currently displayed geometry, mid-flight retargets included) and a per-frame CPU mix that writes the live buffers in place — one quad per stage makes that cheaper than a second attribute set, and the shader stays untouched. The default entrance grows cross edges out of the segment spine, the bar family's baseline rule transposed. Export parity - Browser-only palette entries collapsed onto ONE fallback colour in the native rasterizer while SVG/PDF gave them distinct built-ins, so a PNG painted two stages the same. Both now resolve per index. - SVG funnel outlines joined as miter while the rasterizer's distance-field stroke is round by construction — visible where a taper meets its neck. - The annotation default-colour change from round 1 was too wide: it painted uncoloured annotation SHAPES (rule strokes, band fills, arrows) with the theme text colour in SVG only, diverging from the raster and the client. Only the LABEL follows --chart-annotation-text/--chart-text now. Composition - `name=` raised an unexpected-argument error instead of forwarding. - Chart-level `data=` with an explicit `xy.funnel(...)` child prepended a second empty funnel and failed; the data now reaches the child. Forwarded keywords that would build a ghost mark are refused by name. - Mixing vertical and horizontal funnel children silently mangled one of them through last-child-wins axis defaults; refused. Also: the tooltip dropped undefined-ratio rows instead of printing the em dash (the row set changed between stages, reading as missing data), and the wire-protocol spec still said v12 in the section describing v13. Tests: 17 more (88 total in test_funnel.py), each pinning one of the above, plus the render-smoke funnel probe extended from a static check into an executed one — legend filtering, the a11y row translation, the theme-refresh paint rebuild, hidden-trace hover refusal, a hand-stepped mid-flight interpolation frame, and the em-dash tooltip. Suite 3,921 passing, docs-site 111 passing, ruff/ty clean, render + ABI smokes pass. --- docs/charts/funnel-chart.md | 29 ++-- js/src/50_chartview.ts | 159 +++++++++++++++--- js/src/52_tooltip.ts | 6 +- js/src/53_interaction.ts | 31 +++- js/src/56_animation.ts | 55 ++++++- python/xy/_figure.py | 31 ++++ python/xy/_funnel.py | 87 +++++++--- python/xy/_hosts.py | 1 + python/xy/_payload.py | 1 + python/xy/_raster.py | 9 +- python/xy/_svg.py | 26 ++- python/xy/components.py | 46 +++++- python/xy/marks.py | 95 ++++++++--- scripts/render_smoke_nonumpy.py | 44 ++++- spec/api/chart-kind-contract.md | 12 +- spec/design/wire-protocol.md | 4 +- tests/test_accessibility_contract.py | 4 +- tests/test_funnel.py | 236 +++++++++++++++++++++++++++ 18 files changed, 769 insertions(+), 107 deletions(-) diff --git a/docs/charts/funnel-chart.md b/docs/charts/funnel-chart.md index c26fb12a..16f1ab2b 100644 --- a/docs/charts/funnel-chart.md +++ b/docs/charts/funnel-chart.md @@ -82,9 +82,10 @@ def funnel_geometry_demo(): Increasing stages are legal and drawn honestly: `Re-engage` is wider than `Invite`, its conversion is above one, and its boundary label reads `+9%`. -Negative and missing values are refused by stage name; a zero stage draws -nothing (or a floor sliver — see `min_width`) but keeps its labels, tooltip, -and keyboard stop. +Negative and missing values are refused by stage name. A zero stage draws +nothing and keeps its label and its keyboard stop, but with no drawn area +there is nothing for the pointer to land on — give it `min_width` to make it +hoverable as a floor sliver. ## Run the Funnel Horizontally @@ -256,13 +257,19 @@ segment in the same colour across every chart that names that stage. Hover reads the full arithmetic for a stage, and because a segment covers an area rather than a point, the tooltip follows the cursor within it. Clicking emits `xy:click` carrying the stage name, value, prior value, overall share, -conversion, and drop-off — the same semantic row the tooltip shows, with -ratios over a zero denominator arriving as `null` rather than infinity. Box +conversion, and drop-off — the same semantic row the tooltip shows. Ratios +over a zero denominator arrive as `null` in events and print as an em dash +(—) in the tooltip, so a stage after a zero reads as "no meaningful number" +rather than as missing data. Box and lasso selection are deliberately absent rather than approximate. -Stable `key=` identities opt segments into key-matched animation. Keyboard -navigation walks the stages in declared order — arrow keys move stage to -stage, `Home`/`End` jump to the ends, `Enter` activates, `Escape` dismisses — -and the screen-reader announcement reads "Stage 2 of 5" followed by that -stage's conversion arithmetic, so the funnel is heard as the ordered process -it is. +With `animation=` configured, a funnel enters by growing out of its spine +(the way bars grow from their baseline), and data updates morph each +segment's geometry to its new shape. Stable `key=` identities plus +`xy.animation(match="key")` keep a stage's segment continuous across updates +even when stages are added or removed; without keys, stages match by +position. Keyboard navigation walks the *visible* stages in declared order — +arrow keys move stage to stage, `Home`/`End` jump to the ends, `Enter` +activates, `Escape` dismisses — and the screen-reader announcement reads +"Stage 2 of 5" followed by that stage's conversion arithmetic, so the funnel +is heard as the ordered process it is. diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 89bedf98..defb2c05 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3080,6 +3080,11 @@ export class ChartView { for (const s of this._densityOverlays(g)) { this._dimLut(s, t.color.palette, item.cat, bg); } + } else if (g._cpuFunnel) { + // A funnel carries resolved RGBA rows, not a palette LUT, so the + // sibling dim recolors the rows themselves with the same blend + // rule _paletteLutDimmed applies to LUT entries. + this._dimFunnelPaint(g, item.cat, bg); } else { this._dimLut(g, t.color.palette, item.cat, bg); } @@ -3124,6 +3129,27 @@ export class ChartView { s.lut = this._paletteLutDimmed(palette, keepIdx, bg); } + // Legend-hover sibling dim for a funnel: the hovered stage keeps its full + // color, every other stage blends toward the backdrop by the same + // LEGEND_DIM_OPACITY weight the LUT path uses. Rebuilt from the retained + // full rows, uploaded through the shared filter-aware path. + _dimFunnelPaint(g, keepCode, bg) { + const full = g._funnelRgbaFull; + const codes = g._funnelCodes; + if (!full || !codes) return; + const rows = new Uint8Array(full.length); + for (let i = 0; i * 4 < full.length; i++) { + const keep = Math.round(codes[i]) === keepCode; + const w = keep ? 1 : LEGEND_DIM_OPACITY; + rows[i * 4] = full[i * 4] * w + bg[0] * 255 * (1 - w); + rows[i * 4 + 1] = full[i * 4 + 1] * w + bg[1] * 255 * (1 - w); + rows[i * 4 + 2] = full[i * 4 + 2] * w + bg[2] * 255 * (1 - w); + rows[i * 4 + 3] = full[i * 4 + 3]; + } + g._funnelHoverDim = true; + this._uploadFunnelPaint(g, rows); + } + // Undo any hover LUT swap on a trace and its density sample overlays, // and put back the plane's original texture if hover dimmed it. _restoreLegendLuts(g) { @@ -3143,6 +3169,10 @@ export class ChartView { g._legendHoverTex = null; g._legendHoverPrevTex = null; } + if (g._funnelHoverDim) { + delete g._funnelHoverDim; + this._uploadFunnelPaint(g); + } } // Per-cell sibling dim for an aggregated categorical plane: each occupied @@ -3312,17 +3342,7 @@ export class ChartView { this._deleteBuffers(g, [slot + "Buf"]); g[slot + "Buf"] = this._upload(values); } - if (g._funnelRgbaFull || g.rgbaBuf) { - const full = g._funnelRgbaFull; - if (full) { - const rows = g._visMap - ? Uint8Array.from({ length: visible.length * 4 }, (_v, k) => - full[visible[(k / 4) | 0] * 4 + (k % 4)]) - : full; - this._deleteBuffers(g, ["rgbaBuf"]); - g.rgbaBuf = this._upload(rows); - } - } + this._uploadFunnelPaint(g); } // Filter a scatter-shaped gpu entry's vertex buffers down to the rows whose @@ -3405,6 +3425,10 @@ export class ChartView { // view request must re-bin under the mask, not stand on it. g._filterDirty = true; if (g.sampleOverlay) this._filterScatterRows(g.sampleOverlay, cats); + } else if (g._cpuFunnel) { + // _filterScatterRows is gated on CPU color codes a funnel does not + // have — routing a rebuilt funnel there silently un-hid its stages. + this._filterFunnelStages(g, cats); } else { this._filterScatterRows(g, cats); } @@ -4628,12 +4652,13 @@ export class ChartView { gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform1f(u("u_yconstant"), this._axisConstant(g.yAxis)); gl.uniform1i(u("u_segments"), RIBBON_STEPS); - gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * (g._legendDim ?? 1)); + const transitionAlpha = (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); + gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * transitionAlpha); const stroke = g.stroke || [0, 0, 0, 0]; gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); gl.uniform1i(u("u_strokeMode"), g.stroke ? 0 : 1); gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); - gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {}) * (g._legendDim ?? 1)); + gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {}) * transitionAlpha); // A flat band must mix toward ITS OWN colour, so a per-band source buffer // with no target buffer binds the source buffer to both attributes — // mixing toward the constant fallback painted every node's right edge @@ -4768,7 +4793,12 @@ export class ChartView { // no LUT texture. Build stashes codes+palette on the record; refreshColor // re-runs this with buffer=null to re-resolve against the new theme. _funnelPaint(g, t, buffer) { - const n = g.n; + // Always resolved over the FULL stage count, never g.n: after a legend + // filter g.n is the visible count, and a theme refresh that rebuilt the + // cache at that length recolored the survivors by their DRAWN index and + // lost the hidden stages' rows for good — restoring a stage then drew + // garbage. The filter is applied at upload time instead. + const full = g._cpuFunnel ? g._cpuFunnel.n : g.n; g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); const channel = t.color || {}; if (buffer !== null && Number.isInteger(channel.buf)) { @@ -4783,25 +4813,110 @@ export class ChartView { const palette = Array.isArray(channel.palette) && channel.palette.length ? channel.palette : ["#4c78a8"]; const table = palette.map((css) => parseColor(this.root, css, [0.3, 0.47, 0.66, 1])); - rgba = new Uint8Array(n * 4); - for (let i = 0; i < n; i++) { + rgba = new Uint8Array(full * 4); + for (let i = 0; i < full; i++) { const c = table[Math.round(g._funnelCodes[i]) % table.length]; rgba.set([c[0] * 255, c[1] * 255, c[2] * 255, c[3] * 255], i * 4); } } else if (channel.mode === "direct_rgba" && g._funnelRgba) { rgba = g._funnelRgba; } - if (g.rgbaBuf) this._deleteBuffers(g, ["rgbaBuf"]); - // Full-length rows kept for the legend filter, which rebuilds the visible - // subset from them rather than reading the GPU buffer back. + // Full-length rows kept for the legend filter and hover dim, which build + // their visible/dimmed subsets from them rather than reading the GPU back. g._funnelRgbaFull = rgba || null; - if (rgba) g.rgbaBuf = this._upload(rgba); + this._uploadFunnelPaint(g); + } + + // Upload the paint rows the current legend filter leaves visible. The one + // place rgbaBuf is (re)created, so a theme refresh, a filter toggle, and a + // legend-hover dim can never disagree about row order. + _uploadFunnelPaint(g, rows = null) { + if (g.rgbaBuf) this._deleteBuffers(g, ["rgbaBuf"]); + const full = rows || g._funnelRgbaFull; + if (!full) return; + let out = full; + if (g._visMap) { + out = new Uint8Array(g._visMap.length * 4); + for (let k = 0; k < g._visMap.length; k++) { + const i = g._visMap[k]; + out.set(full.subarray(i * 4, i * 4 + 4), k * 4); + } + } + g.rgbaBuf = this._upload(out); + } + + // Mix the six per-stage geometry columns for the current animation frame + // and write them into the LIVE buffers in place (same WebGLBuffer objects, + // so the VAO signature holds). Small-N by contract, so the per-frame CPU + // mix is cheaper than a second attribute set and the shader stays as-is. + // Covers the update interpolation (prev -> current by progress), the + // enter grow (cross edges expand from the segment spine), and the settled + // re-upload after either finishes. + _mixFunnelGeometry(g) { + const f = g._cpuFunnel; + if (!f) return; + const prev = g._transitionPrevFunnelValues; + const progress = g._transitionPositionProgress; + const grow = g._transitionGrow ?? 1; + const mixing = (prev && Number.isFinite(progress) && progress < 1) || grow < 1; + if (!mixing) { + if (!g._funnelGeomMixed) return; + delete g._funnelGeomMixed; + } + const gl = this.gl; + const slots = { pos0: "x0", pos1: "x1", lo0: "y0", hi0: "y1", lo1: "x2", hi1: "y2" }; + const rows = g._visMap; + const count = rows ? rows.length : f.n; + const scratch = (g._funnelMixScratch ||= {}); + const mixed = {}; + for (const name of Object.keys(slots)) { + const out = scratch[name] && scratch[name].length === count + ? scratch[name] + : (scratch[name] = new Float32Array(count)); + const cur = f[name]; + const start = prev && prev[name]; + for (let k = 0; k < count; k++) { + const i = rows ? rows[k] : k; + let value = cur[i]; + if (start && Number.isFinite(progress) && progress < 1) { + value = start[i] + (value - start[i]) * progress; + } + out[k] = value; + } + mixed[name] = out; + } + if (grow < 1) { + // Grow from the spine: both cross edges of each end expand from their + // midpoint, so a vertical funnel widens out of its centerline exactly + // as a bar grows out of its baseline. Encoded space is fine — the two + // edges share a meta per column pair only after decoding, so mix the + // DECODED midpoint per end via the metas. + const dec = (name, v) => v / (f.metas[name].scale || 1) + (f.metas[name].offset || 0); + const enc = (name, v) => (v - (f.metas[name].offset || 0)) * (f.metas[name].scale || 1); + for (let k = 0; k < count; k++) { + for (const [lo, hi] of [["lo0", "hi0"], ["lo1", "hi1"]]) { + const a = dec(lo, mixed[lo][k]); + const b = dec(hi, mixed[hi][k]); + const mid = (a + b) / 2; + mixed[lo][k] = enc(lo, mid + (a - mid) * grow); + mixed[hi][k] = enc(hi, mid + (b - mid) * grow); + } + } + } + for (const [name, slot] of Object.entries(slots)) { + const buf = g[slot + "Buf"]; + if (!buf) continue; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, mixed[name], gl.DYNAMIC_DRAW); + } + if (mixing) g._funnelGeomMixed = true; } _drawFunnels(g, xm, ym) { if (g.n < 1) return; const gl = this.gl; const prog = this.funnelProg; + this._mixFunnelGeometry(g); gl.useProgram(prog); const u = (name) => uniformOf(gl, prog, name); const horizontal = g.orientation === 1; @@ -8045,6 +8160,10 @@ export class ChartView { const polarGeom = this._polarGeometry(); for (const g of this.gpuTraces) { if (g.tier === "density") continue; + // A legend-hidden series draws nothing, so it must not answer hover + // either (interaction spec §10) — before this guard every CPU-hover + // kind (bar, rect, ribbon, funnel) kept reporting invisible geometry. + if (g._legendHidden) continue; const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); if (!Number.isFinite(dataX) || !Number.isFinite(dataY)) continue; if (g.heatmap && g._cpuHeatmap) { diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index b501f307..c66ae792 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -331,7 +331,11 @@ Object.assign(ChartView.prototype, { // those and never invents a format of its own. const field = (label, text, numeric) => { if (typeof text === "string") { - if (text !== "—") items.push({ kind: "field", label, value: text }); + // The em dash IS the readout for an undefined ratio (a stage after + // a zero): dropping the row made the tooltip shape change between + // stages, which reads as missing data rather than "no meaningful + // number here". + items.push({ kind: "field", label, value: text }); return; } if (numeric !== undefined && numeric !== null && Number.isFinite(Number(numeric))) { diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index c590f837..b9222f5c 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -471,11 +471,25 @@ Object.assign(ChartView.prototype, { _a11yPointGroups() { // stageNav marks (funnel) traverse their per-stage centers in data order, // which for a funnel is the declared stage order — the ordered process a - // screen reader should hear. + // screen reader should hear. Legend-hidden series draw nothing and are + // not announced either; a category filter narrows the walk through + // _a11yGroupCount/_a11yGroupRow below. return (this.gpuTraces || []).filter((g) => (markOf(g.trace.kind).pointPick || markOf(g.trace.kind).stageNav) && - g.tier !== "density" && g._cpu && - g._cpu.x && g._cpu.y && Math.min(g._cpu.x.length, g._cpu.y.length) > 0); + g.tier !== "density" && !g._legendHidden && g._cpu && + g._cpu.x && g._cpu.y && this._a11yGroupCount(g) > 0); + }, + + // Keyboard traversal walks what is DRAWN: a legend category filter narrows + // the group to its visible rows, and the row index it reports stays in + // SHIPPED space (what tooltip_rows and the kernel exact-pick speak). + _a11yGroupCount(g) { + if (g._visMap) return g._visMap.length; + return Math.min(g._cpu.x.length, g._cpu.y.length); + }, + + _a11yGroupRow(g, offset) { + return g._visMap ? g._visMap[offset] : offset; }, _onA11yKey(e) { @@ -542,7 +556,7 @@ Object.assign(ChartView.prototype, { // density handoff, or animated tier frame. if (this._interactionTransitionActive()) return; const groups = this._a11yPointGroups(); - const total = groups.reduce((sum, g) => sum + Math.min(g._cpu.x.length, g._cpu.y.length), 0); + const total = groups.reduce((sum, g) => sum + this._a11yGroupCount(g), 0); if (!total) return; // Traversal intentionally follows trace/series data order, not visual x // order: sorting would change source-row identity and make streamed appends @@ -556,15 +570,16 @@ Object.assign(ChartView.prototype, { let offset = flat; let g = groups[0]; for (const candidate of groups) { - const n = Math.min(candidate._cpu.x.length, candidate._cpu.y.length); + const n = this._a11yGroupCount(candidate); if (offset < n) { g = candidate; break; } offset -= n; } - const hit = { trace: g.trace.id, index: offset, g }; + const row = this._a11yGroupRow(g, offset); + const hit = { trace: g.trace.id, index: row, g }; // Use the encoded numeric coordinates for positioning; _localRow may have // already converted categorical coordinates into display strings. - const xValue = this._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, offset); - const yValue = this._decodeValue(g._cpu.y, g._cpu.yMeta || g.yMeta, offset); + const xValue = this._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, row); + const yValue = this._decodeValue(g._cpu.y, g._cpu.yMeta || g.yMeta, row); const [chartX, chartY] = this._projectDataPoint( g.xAxis || "x", g.yAxis || "y", diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index 05772054..2292c489 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -88,7 +88,7 @@ Object.assign(ChartView.prototype, { _defaultEntrance(kind) { if (kind === "line" || kind === "area" || kind === "error_band") return "reveal"; - if (kind === "bar" || kind === "column") return "grow"; + if (kind === "bar" || kind === "column" || kind === "funnel") return "grow"; if (kind === "scatter" || kind === "errorbar") return "scale"; return "none"; }, @@ -118,7 +118,8 @@ Object.assign(ChartView.prototype, { if (enter === "scale") { if (g.trace.kind === "scatter") g._transitionScale = p; else if (g.trace.kind === "errorbar") g._transitionScale = p; - else if (g.trace.kind === "bar" || g.trace.kind === "column") g._transitionGrow = p; + else if (g.trace.kind === "bar" || g.trace.kind === "column" || + g.trace.kind === "funnel") g._transitionGrow = p; else if (g.trace.kind === "line" || g.trace.kind === "area" || g.trace.kind === "error_band") g._transitionReveal = p; } @@ -139,6 +140,12 @@ Object.assign(ChartView.prototype, { delete g._transitionPrevValue1Values; delete g._transitionPrevValue0Values; delete g._transitionPrevWidth; + if (g._transitionPrevFunnelValues) { + delete g._transitionPrevFunnelValues; + // The live buffers hold the last mixed frame; the next _drawFunnels + // re-uploads the settled geometry once. + g._funnelGeomMixed = true; + } delete g._transitionPositionInterpolated; this._deleteBuffers(g, [ "_transitionPrevXBuf", "_transitionPrevYBuf", @@ -332,6 +339,9 @@ Object.assign(ChartView.prototype, { if (["bar", "column"].includes(next.trace.kind)) { return this._prepareBarPositionInterpolation(previous, next, match); } + if (next.trace.kind === "funnel") { + return this._prepareFunnelPositionInterpolation(previous, next, match); + } if (!["scatter", "line"].includes(next.trace.kind)) return false; if (!previous._cpu || !next._cpu || next.n !== next._cpu.x.length || previous.n !== previous._cpu.x.length) { @@ -452,6 +462,47 @@ Object.assign(ChartView.prototype, { return true; }, + // Funnel update interpolation runs on the CPU: one quad per stage, so + // mixing six little arrays and re-uploading them per frame costs less than + // a second attribute set would, and the shader stays untouched. Start + // values are the OLD trace's currently DISPLAYED geometry (mid-flight + // retargets included), re-encoded into the new columns' metas; unmatched + // stages start at their destination. + _prepareFunnelPositionInterpolation(previous, next, match) { + const oldF = previous._cpuFunnel; + const newF = next._cpuFunnel; + if (!oldF || !newF || previous.orientation !== next.orientation) { + match.fallback ||= "snap:layout-mismatch"; + return false; + } + const names = ["pos0", "pos1", "lo0", "hi0", "lo1", "hi1"]; + const decode = (value, meta) => value / (Number(meta.scale) || 1) + (Number(meta.offset) || 0); + const encode = (value, meta) => (value - (Number(meta.offset) || 0)) * (Number(meta.scale) || 1); + const starts = {}; + for (const name of names) starts[name] = new Float32Array(newF[name].subarray(0, newF.n)); + const prevStarts = previous._transitionPrevFunnelValues; + const prevProgress = previous._transitionPositionProgress; + for (const [oldIndex, newIndex] of match.pairs) { + if (oldIndex >= oldF.n || newIndex >= newF.n) continue; + for (const name of names) { + const meta = oldF.metas[name]; + let displayed = decode(oldF[name][oldIndex], meta); + if (prevStarts && Number.isFinite(prevProgress)) { + const from = decode(prevStarts[name][oldIndex], meta); + displayed = from + (displayed - from) * prevProgress; + } + if (Number.isFinite(displayed)) { + starts[name][newIndex] = encode(displayed, newF.metas[name]); + } + } + } + next._transitionPrevFunnelValues = starts; + next._transitionPositionProgress = 0; + next._transitionPositionInterpolated = true; + previous._transitionSkipExit = true; + return true; + }, + updatePayload(spec, buffer) { if (this._destroyed || !spec || spec.protocol !== PROTOCOL) return false; if (this._dataAnimRaf) cancelAnimationFrame(this._dataAnimRaf); diff --git a/python/xy/_figure.py b/python/xy/_figure.py index d304aa32..3bcebe0f 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -829,6 +829,37 @@ def _interaction_axes(self, name: str) -> list[str]: value = self.interaction.get(name) return list(self.axis_options) if value is None else self._axis_policy(value, name) + def _validate_funnel_axes(self) -> None: + """Refuse axis types the funnel geometry cannot draw truthfully. + + A funnel's segments are centered on zero, so half its corners are + negative — a log cross axis maps them to NaN and a symlog one bends + the silhouette; a time cross axis has no meaning for widths. The + stage axis is categorical by construction, and a forced type + (`_axis_kind` lets a forced "time" beat the category registry) would + silently strip the stage labels. Every one of these *would* draw + something, and a plausible wrong picture is worse than an error (§28). + """ + for t in self.traces: + if t.kind != "funnel": + continue + vertical = str(t.style.get("orientation", "vertical")) == "vertical" + cross_axis = t.x_axis if vertical else t.y_axis + stage_axis = t.y_axis if vertical else t.x_axis + cross_type = self.axis_options.get(cross_axis, {}).get("type") + if cross_type in {"log", "symlog", "time"}: + raise ValueError( + f"funnel cross axis {cross_axis!r} cannot be {cross_type!r}: " + "segments are centered on zero, so their widths only read " + "on a linear axis" + ) + stage_type = self.axis_options.get(stage_axis, {}).get("type") + if stage_type in {"log", "symlog", "time"}: + raise ValueError( + f"funnel stage axis {stage_axis!r} cannot be {stage_type!r}: " + "the stage axis is categorical (stage names in declared order)" + ) + def _validate_coords(self) -> None: """Refuse mark kinds the polar transform does not yet render correctly. diff --git a/python/xy/_funnel.py b/python/xy/_funnel.py index ea665d7f..adb5b9cf 100644 --- a/python/xy/_funnel.py +++ b/python/xy/_funnel.py @@ -134,26 +134,41 @@ def _validated_values(names: Sequence[str], values: Sequence[float]) -> list[flo return out +def _ratio(numerator: float, denominator: float) -> Optional[float]: + """A conversion ratio, or None where one is not defined. + + Zero denominators have no ratio, and neither does an overflow: a wide + enough dynamic range (1e-300 -> 1e10) makes a bare division inf, which is + not JSON, not a wire value, and not a number any reader wants printed. + Both are the same answer to the same question — "no meaningful ratio + here" — and both render as the em dash. + """ + if denominator <= 0.0: + return None + value = numerator / denominator + return value if math.isfinite(value) else None + + def compute_stages(names: Sequence[str], values: Sequence[float]) -> list[FunnelStage]: """Validate stage values and resolve conversion arithmetic. Increasing values are allowed (re-entry and net-growth funnels are real) and produce conversion > 1 with a negative drop-off; negative and missing - values are refused by stage name. Ratios against a zero denominator are - None, never inf. + values are refused by stage name. A ratio with a zero denominator — or one + that overflows to infinity — is None, never inf. """ vals = _validated_values(names, values) first = vals[0] stages: list[FunnelStage] = [] prior: Optional[float] = None for index, (name, value) in enumerate(zip(names, vals, strict=True)): - share = (value / first) if first > 0.0 else None + share = _ratio(value, first) if index == 0: conversion: Optional[float] = None dropoff: Optional[float] = None - elif prior is not None and prior > 0.0: - conversion = value / prior - dropoff = 1.0 - conversion + elif prior is not None: + conversion = _ratio(value, prior) + dropoff = None if conversion is None else 1.0 - conversion else: conversion = None dropoff = None @@ -291,17 +306,21 @@ def decide_labels( For each stage the value label (value, plus overall conversion when ``show_conversion``) tries, in order: - 1. **inside** — centered in the segment, when the text fits the segment's - cross width and the stage pitch fits the line height; - 2. **outside** — beside the segment on the positive cross side, when only - the cross width fails; - 3. **hidden** — when the stage pitch cannot hold a text line (the tooltip - and events still carry every number). + 1. **inside** — centered in the segment, when the text fits the box the + segment actually offers: cross width × stage pitch for a vertical + funnel, stage pitch × cross height for a horizontal one (text always + runs horizontally, so the axis it is measured against transposes with + the orientation); + 2. **outside** — beside a vertical segment on the positive cross side, or + above a horizontal one, when the segment's box fails but the stage + pitch can still hold the text without hitting the neighbours; + 3. **hidden** — when even the outside slot cannot hold the text (the + tooltip and events still carry every number). Drop-off labels (``show_dropoff``) sit outside at the boundary between a stage and its predecessor, formatted as the signed change (``-38%`` for a drop, ``+12%`` for growth), and inherit the same - pitch-or-hidden rule. Placement is estimated against the figure's + outside-or-hidden rule. Placement is estimated against the figure's configured pixel size at build time; a responsive chart keeps the build-time decision. """ @@ -315,29 +334,44 @@ def decide_labels( # Every stage is zero-valued with no floor: nothing has drawable # width, so nothing fits "inside" and every label falls outside. cross_full = 1.0 - if layout.orientation == "vertical": - cross_px_per_unit = plot_w / (cross_full * 1.1) - pitch_px = plot_h / max(n, 1) - else: + horizontal = layout.orientation == "horizontal" + if horizontal: cross_px_per_unit = plot_h / (cross_full * 1.1) pitch_px = plot_w / max(n, 1) + else: + cross_px_per_unit = plot_w / (cross_full * 1.1) + pitch_px = plot_h / max(n, 1) line_px = font_size * 1.4 labels: list[FunnelLabelSpec] = [] for stage, quad in zip(layout.stages, layout.quads, strict=True): mid_pos = (quad.pos0 + quad.pos1) / 2.0 mid_half = (quad.hi0 + quad.hi1) / 2.0 + seg_pitch_px = pitch_px * (quad.pos1 - quad.pos0) + seg_cross_px = mid_half * 2.0 * cross_px_per_unit if show_values: text = format_value(stage.value, value_format) if show_conversion: text += f" {format_ratio(stage.share, percent_format)}" width_px = _fontmetrics.advance(text, font_size) - if not _fits(line_px, pitch_px): - placement, cross, anchor = "hidden", 0.0, "middle" - elif _fits(width_px, mid_half * 2.0 * cross_px_per_unit * 0.92): + # The box a horizontally-running text line must fit: along the + # text, the segment's HORIZONTAL extent (cross width when the + # funnel is vertical, stage pitch when it is horizontal); across + # the text, the segment's vertical extent. + along_px = seg_pitch_px if horizontal else seg_cross_px + across_px = seg_cross_px if horizontal else seg_pitch_px + # An outside label still occupies the stage's slot along the + # pitch axis for a horizontal funnel (it sits above its own + # segment), so a text wider than the pitch collides with the + # neighbours' labels and hides instead. Vertical outside labels + # extend into the margin and only need their line height. + outside_ok = _fits(width_px, pitch_px) if horizontal else _fits(line_px, pitch_px) + if _fits(width_px, along_px * 0.92) and _fits(line_px, across_px): placement, cross, anchor = "inside", 0.0, "middle" - else: + elif outside_ok: placement, cross, anchor = "outside", mid_half, "start" + else: + placement, cross, anchor = "hidden", 0.0, "middle" labels.append( FunnelLabelSpec( stage=stage.index, @@ -360,7 +394,16 @@ def decide_labels( boundary = stage.index - 0.5 prev_quad = layout.quads[stage.index - 1] edge = max(prev_quad.hi1, quad.hi0) - placement = "outside" if _fits(line_px, pitch_px) else "hidden" + drop_px = _fontmetrics.advance(text, font_size) + # Same transposition as the value labels: a horizontal funnel's + # boundary labels sit side by side along the pitch axis, so their + # WIDTH is what collides; a vertical funnel stacks them and only + # the line height competes for the pitch. + placement = ( + "outside" + if (_fits(drop_px, pitch_px) if horizontal else _fits(line_px, pitch_px)) + else "hidden" + ) labels.append( FunnelLabelSpec( stage=stage.index, diff --git a/python/xy/_hosts.py b/python/xy/_hosts.py index 9aef77e5..ef36f749 100644 --- a/python/xy/_hosts.py +++ b/python/xy/_hosts.py @@ -47,6 +47,7 @@ def palette_cycle(self) -> Optional[list[str]]: ... def palette_color(self, index: int, *, stacklevel: int = 3) -> str: ... def _validate_coords(self) -> None: ... + def _validate_funnel_axes(self) -> None: ... # -- shared validators (static on `Figure`, aliases of `_validate`) -- @staticmethod diff --git a/python/xy/_payload.py b/python/xy/_payload.py index b2c1ae0f..3a54305e 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -261,6 +261,7 @@ def axis_range(axis_id: str) -> tuple[float, float]: return r self._validate_coords() + self._validate_funnel_axes() spec_traces = [] for t in self.traces: xr = axis_range(t.x_axis) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 17f55b1d..65898e3d 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2031,7 +2031,14 @@ def _trace_paint_rgba( elif mode == "categorical": codes = np.asarray(read(channel["buf"]), dtype=np.int64)[:n] palette = channel.get("palette") or DEFAULT_PALETTE - table = np.asarray([_parse_color(value) for value in palette], dtype=np.float64) / 255.0 + # Per-index resolution (channels.palette_rows_rgba8), not _parse_color + # per entry: browser-only entries (var(--…)) must degrade to DISTINCT + # built-in colors — parsing each alone collapsed every var() category + # onto the same fallback, so a PNG painted two stages one color while + # SVG/PDF kept them apart. + from . import channels as _pal_channels + + table = _pal_channels.palette_rows_rgba8(palette, len(palette)).astype(np.float64) / 255.0 rgba[:] = table[codes % len(table)] else: rgba[:] = ( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3b92c31a..d494109b 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -4768,7 +4768,12 @@ def point(x: float, y: float) -> tuple[float, float]: for ann in annotations: style = ann.get("style") or {} - color = escape(_css(style.get("color"), default_text)) + # SHAPE paint (rule strokes, band fills, arrows, markers) keeps its own + # neutral default — only the LABEL falls back to the theme text colour, + # which is what the client's + # var(--chart-annotation-text, var(--chart-text, inherit)) governs. + # Widening this to shapes diverged SVG from the raster and the client. + color = escape(_css(style.get("color"), "#667085")) opacity = float(style.get("opacity", 1.0)) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) @@ -4933,8 +4938,14 @@ def point(x: float, y: float) -> tuple[float, float]: style.get("opacity", 1.0) if kind == "text" else 1.0, ) ) - # A callout's `color` paints its arrow; the label prefers its own. - label_color = escape(_css(style.get("label_color"), "")) or color + # A callout's `color` paints its arrow; the label prefers its own, + # then the theme text colour (the client resolves the same chain + # through var(--chart-annotation-text, var(--chart-text, …))). + label_color = ( + escape(_css(style.get("label_color"), "")) + or (escape(_css(style.get("color"), "")) if style.get("color") else "") + or escape(default_text) + ) labels.extend( _svg_text_box(style, lines, x_text, y_text, line_height, font_size, anchor) ) @@ -5672,7 +5683,14 @@ def rgb(paint: Any) -> str: if stroke_width > 0: paint_css = stroke_paint if stroke_paint is not None else rgb(intrinsic[i]) edge_op = stroke_op * (1.0 if stroke_paint is not None else float(intrinsic[i][3])) - attrs += f' stroke="{paint_css}" stroke-width="{_num(stroke_width)}" ' + # round joins: the native rasterizer's stroke is a distance field + # with round caps/joins by construction (src/raster.rs), so an SVG + # miter would spike where a taper meets its neck while the PNG + # stayed round — parity is identity, so say it explicitly. + attrs += ( + f' stroke="{paint_css}" stroke-width="{_num(stroke_width)}"' + ' stroke-linejoin="round" ' + ) if edge_op < 1: attrs += f'stroke-opacity="{_num(edge_op)}" ' out.append(f'') diff --git a/python/xy/components.py b/python/xy/components.py index dae20778..ace900d1 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -7150,15 +7150,18 @@ def funnel_chart( horizontal ones — and shows the stage names as its tick labels. The cross axis is layout, not data (segments are centered on zero), so it is hidden; keyword arguments that belong to `xy.funnel` (``orientation``, - ``geometry``, ``gap``, ``neck``, ``min_width``, ``colors``, + ``geometry``, ``gap``, ``neck``, ``min_width``, ``colors``, ``name``, ``show_dropoff``, …) are forwarded there, and everything else (``width``, - ``height``, ``title``, …) styles the chart. + ``height``, ``title``, …) styles the chart. ``style=`` stays chart-level + (the DOM container, as on every ``*_chart``); segment styling goes through + ``xy.funnel(style=...)`` or the forwarded ``stroke``/``opacity`` keywords. """ mark_keys = ( "data", "stage", "value", "key", + "name", "orientation", "geometry", "gap", @@ -7190,12 +7193,41 @@ def funnel_chart( if "value" in mark_kwargs: raise ValueError("funnel_chart got positional values and value=") mark_kwargs["value"] = rest.pop(0) - if mark_kwargs or not any(isinstance(child, Mark) and child.kind == "funnel" for child in rest): + child_funnels = [c for c in rest if isinstance(c, Mark) and c.kind == "funnel"] + if child_funnels and not ( + "stage" in mark_kwargs or "value" in mark_kwargs or "data" in mark_kwargs + ): + # The funnel came as an explicit child; forwarded keywords with no + # data of their own would build a second, empty funnel. Refuse by + # name instead of failing later inside that ghost mark. + if mark_kwargs: + raise ValueError( + f"funnel_chart got {sorted(mark_kwargs)} alongside an explicit " + "xy.funnel(...) child; set these on the mark itself" + ) + elif child_funnels and "stage" not in mark_kwargs and "value" not in mark_kwargs: + # Chart-level data= with an explicit funnel child: the data belongs to + # the chart (the applier resolves the child's column names against + # it), not to a second implicit mark. + props["data"] = mark_kwargs.pop("data") + if mark_kwargs: + raise ValueError( + f"funnel_chart got {sorted(mark_kwargs)} alongside an explicit " + "xy.funnel(...) child; set these on the mark itself" + ) + else: marks.append(funnel(**mark_kwargs)) - orientation = mark_kwargs.get("orientation", "vertical") - for child in rest: - if isinstance(child, Mark) and child.kind == "funnel": - orientation = child.props.get("orientation", orientation) + orientations = {str(child.props.get("orientation", "vertical")) for child in child_funnels} + if marks: + orientations.add(str(mark_kwargs.get("orientation", "vertical"))) + if len(orientations) > 1: + # Axis defaults (which axis hides, which reverses) are per + # orientation; last-child-wins silently mangled the other funnel. + raise ValueError( + "funnel_chart cannot mix vertical and horizontal funnels in one " + "chart; use two charts or facet_chart" + ) + orientation = next(iter(orientations), "vertical") # The cross axis is layout, not data (segments center on zero), so it is # hidden with a symmetric margin that keeps the widest stage — and the # outside/drop-off labels beside it — clear of the plot edges. The legend diff --git a/python/xy/marks.py b/python/xy/marks.py index 278775de..f3d5cae5 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -742,11 +742,12 @@ def funnel( else: # Numeric stages are legal input (quarter numbers, ordinal codes) but # a funnel's stage axis is categorical by contract, so they become - # labels in the declared order. - stage_names = [ - channels.category_label(raw) - for raw in np.asarray(self._materialize_sequence(stage)).reshape(-1) - ] + # labels in the declared order. 1-D only: silently flattening a 2-D + # array would invent a stage order the caller never declared. + stage_arr = np.asarray(self._materialize_sequence(stage)) + if stage_arr.ndim != 1: + raise ValueError(f"funnel stage must be 1-D, got shape {stage_arr.shape}") + stage_names = [channels.category_label(raw) for raw in stage_arr] values_arr = self._as_1d_float(value, "funnel value") layout = _funnel.compute_layout( stage_names, @@ -780,7 +781,26 @@ def funnel( palette=self.palette, ) lookup = dict(zip(resolved.categories or [], resolved.palette or [], strict=True)) - stage_css = [lookup[name] for name in stage_names] + if all(name in lookup for name in stage_names): + stage_css = [lookup[name] for name in stage_names] + else: + # The resolver reads a column of CSS colors as per-point PAINT + # rather than as category labels, so stage names that are + # themselves colors ("#ff0000") come back as direct RGBA with no + # categories to reorder. The map is still keyed by stage name, so + # apply it here and take the spare-color rule for the rest. + pinned = {str(key): str(value) for key, value in self.palette.items()} + spare = [c for c in DEFAULT_PALETTE if c not in set(pinned.values())] or list( + DEFAULT_PALETTE + ) + unmapped = 0 + stage_css = [] + for name in stage_names: + if name in pinned: + stage_css.append(pinned[name]) + else: + stage_css.append(spare[unmapped % len(spare)]) + unmapped += 1 else: stage_css = [self.palette_color(i) for i in range(n)] @@ -818,19 +838,26 @@ def funnel( stroke_width_value = 0.0 if width_constant is None else float(width_constant) stage_dim = "y" if orientation == "vertical" else "x" - # Category positions come from the axis registry so a funnel layered onto - # an axis that already holds categories lands after them instead of on top - # of them; on a fresh axis they are exactly 0..n-1. - centers = self._axis_positions(stage_names, stage_dim) - pos0 = np.array([centers[q.stage] + (q.pos0 - q.stage) for q in layout.quads], dtype=np.float64) - pos1 = np.array([centers[q.stage] + (q.pos1 - q.stage) for q in layout.quads], dtype=np.float64) - lo0 = np.array([q.lo0 for q in layout.quads], dtype=np.float64) - hi0 = np.array([q.hi0 for q in layout.quads], dtype=np.float64) - lo1 = np.array([q.lo1 for q in layout.quads], dtype=np.float64) - hi1 = np.array([q.hi1 for q in layout.quads], dtype=np.float64) - + # The checkpoint is taken BEFORE the stage names commit to the axis + # registry: a later failure (a bad value_format in the label pass, for + # example) must roll the categories back too, or the next valid funnel on + # this axis starts its positions after the ghost of the failed one. checkpoint = self._checkpoint() try: + # Category positions come from the axis registry so a funnel layered + # onto an axis that already holds categories lands after them instead + # of on top of them; on a fresh axis they are exactly 0..n-1. + centers = self._axis_positions(stage_names, stage_dim) + pos0 = np.array( + [centers[q.stage] + (q.pos0 - q.stage) for q in layout.quads], dtype=np.float64 + ) + pos1 = np.array( + [centers[q.stage] + (q.pos1 - q.stage) for q in layout.quads], dtype=np.float64 + ) + lo0 = np.array([q.lo0 for q in layout.quads], dtype=np.float64) + hi0 = np.array([q.hi0 for q in layout.quads], dtype=np.float64) + lo1 = np.array([q.lo1 for q in layout.quads], dtype=np.float64) + hi1 = np.array([q.hi1 for q in layout.quads], dtype=np.float64) posc0, posc1 = self.store.ingest(pos0), self.store.ingest(pos1) loc0, hic0 = self.store.ingest(lo0), self.store.ingest(hi0) loc1, hic1 = self.store.ingest(lo1), self.store.ingest(hi1) @@ -901,6 +928,11 @@ def funnel( for s in layout.stages ] if labels: + # Inside-label contrast is judged against the fill each segment + # ACTUALLY wears: the constant `color=` when given, else the + # per-stage palette. Judging the palette while painting a constant + # put near-white labels on a white funnel. + drawn_css = [color] * n if color is not None else stage_css plot_w = self.width if isinstance(self.width, int) else 640 plot_h = self.height if isinstance(self.height, int) else 400 specs = _funnel.decide_labels( @@ -920,10 +952,18 @@ def funnel( pos = centers[spec.stage] + (spec.pos - spec.stage) if orientation == "vertical": x_anchor, y_anchor = spec.cross, pos + # Vertical outside labels run into the side margin, and a + # boundary label sits half a stage above its stage's own, + # so the two never share a line. dx, dy = (0.0, 0.0) if inside else (8.0, 0.0) else: x_anchor, y_anchor = pos, spec.cross - dx, dy = (0.0, 0.0) if inside else (0.0, -8.0) + # Horizontal outside labels all sit ABOVE their segment, so + # a stage's own value and the boundary label beside it would + # land on one line and overprint. Boundary labels take a + # second row. + row = 1.4 * label_size if spec.kind == "dropoff" else 0.0 + dx, dy = (0.0, 0.0) if inside else (0.0, -8.0 - row) self.text( x_anchor, y_anchor, @@ -931,7 +971,7 @@ def funnel( dx=dx, dy=dy, anchor="middle" if inside else spec.anchor, - color=_funnel_label_color(stage_css[spec.stage]) if inside else None, + color=_funnel_label_color(drawn_css[spec.stage]) if inside else None, style={"font_size": label_size}, # A funnel label describes one stage's geometry, so a # legend toggle must hide the two together — an orphaned @@ -949,16 +989,19 @@ def funnel( def _funnel_label_color(css: str) -> Optional[str]: """Contrast color for a label inside a segment of fill `css`. - WCAG relative-luminance threshold; None (theme default) when the fill is - not statically parseable (a CSS variable), which keeps the label legible - in whichever theme resolves the variable. + WCAG relative-luminance threshold, or None (the theme's own text color) + when the fill only resolves in a browser — a `var()`/`oklch()`/ + `color-mix()` entry has no luminance XY can read, and guessing from the + exporters' substitute blue put a light label on a fill that resolves + white on screen. `_parse_color` cannot report this: it silently returns + the substitute, so the check has to happen against the CSS grammar. """ - try: - from ._raster import _parse_color + from . import kernels - r, g, b, _a = _parse_color(css) - except Exception: + status, rgba = kernels.css_check(kernels.CSS_COLOR, str(css)) + if status != 1 or rgba is None: return None + r, g, b = (round(c * 255) for c in rgba[:3]) def channel(v: int) -> float: c = v / 255.0 diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 3f97a21b..3577846c 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -1265,7 +1265,49 @@ def main() -> None: const fnRow=fnHit?vFn._localRow(fnHit):null; const fnRowOk=(fnRow && fnRow.stage==="B" && fnRow.value===6 && fnRow.dropoff===0.4)?1:0; const fnNav=vFn._a11yPointGroups().some((g)=>g===gFn)?1:0; - const funnel=(fnInk && fnHit && fnHit.index===1 && !fnMiss && fnRowOk && fnNav)?1:0; + // Review-fix probes (PR #474 round 2), all EXECUTED, not grepped: + // (a) legend category filter narrows draw count, hover, and the keyboard + // walk, and the row index stays in shipped space; + // (b) a theme-refresh paint rebuild while filtered keeps the FULL rows so + // restoring the stage restores its own color; + // (c) a legend-hidden trace answers no hover; + // (d) the update interpolation mixes geometry mid-flight (p=0.5 midpoint); + // (e) funnel tooltip rows print the shipped *_text (em dash included). + vFn._legendOffCats=new Map([[vFn.gpuTraces.indexOf(gFn), new Set([1])]]); + vFn._applyCategoryVisibility(vFn.gpuTraces.indexOf(gFn)); + const fnFilterN=(gFn.n===2 && gFn._visMap && gFn._visMap[1]===2)?1:0; + const fnA11y=(vFn._a11yGroupCount(gFn)===2 && vFn._a11yGroupRow(gFn,1)===2)?1:0; + const fnHitHidden=vFn._funnelHover(gFn,0,1.0)===null?1:0; + vFn._funnelPaint(gFn,gFn.trace,null); // theme-refresh path while filtered + const fullRows=gFn._funnelRgbaFull; + const fnPaintFull=(fullRows && fullRows.length===3*4 && fullRows[4+1]>150)?1:0; // stage 1 green + vFn._legendOffCats=new Map(); + vFn._applyCategoryVisibility(vFn.gpuTraces.indexOf(gFn)); + const fnRestored=(gFn.n===3 && !gFn._visMap)?1:0; + gFn._legendHidden=true; + const fnHiddenHover=vFn._hoverAt(100,80)===null?1:0; + delete gFn._legendHidden; + // Hand-stepped interpolation: prev = every cross edge doubled, p=0.5 must + // draw the midpoint (1.5x). Exercises _mixFunnelGeometry's real path. + const fPrev={{}}; + for (const nm of ["pos0","pos1","lo0","hi0","lo1","hi1"]) {{ + fPrev[nm]=Float32Array.from(gFn._cpuFunnel[nm], (v)=>nm.startsWith("pos")?v:v*2); + }} + gFn._transitionPrevFunnelValues=fPrev; + gFn._transitionPositionProgress=0.5; + vFn._drawNow(); + const mixHi=gFn._funnelMixScratch && gFn._funnelMixScratch.hi0; + const fnMix=(mixHi && Math.abs(mixHi[0]-gFn._cpuFunnel.hi0[0]*1.5)<1e-3)?1:0; + delete gFn._transitionPrevFunnelValues; + delete gFn._transitionPositionProgress; + gFn._funnelGeomMixed=true; vFn._drawNow(); // settled re-upload path + const fnItems=vFn._defaultTooltipItems({{trace:gFn.trace.id,index:2,stage:"C",value:2, + value_text:"2",share:0.2,share_text:"20%",conversion:null,conversion_text:"—", + dropoff:null,dropoff_text:"—"}},{{}},{{}}); + const fnDash=(fnItems.length===5 && fnItems[3].value==="—" && fnItems[4].value==="—")?1:0; + const funnel=(fnInk && fnHit && fnHit.index===1 && !fnMiss && fnRowOk && fnNav + && fnFilterN && fnA11y && fnHitHidden && fnPaintFull && fnRestored + && fnHiddenHover && fnMix && fnDash)?1:0; vFn.destroy();holderFn.remove(); const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} funnel=${{funnel}} dretire=${{dretire}}`; const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 85ce064b..f2dc122d 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -176,7 +176,7 @@ renderers only ever see quads plus semantics: | `lo1`, `hi1` | cross-axis edges at `pos1` — internally these ride the trace's generic `x`/`y` slots, which is why `_range_columns` has a funnel branch | | `n_marks` | quad count (= stage count) | | `color` | per-stage paint channel: `constant`, `categorical` (codes + palette, categories in DECLARED stage order — the funnel factory builds this by hand because generic factorization sorts labels alphabetically), or `direct_rgba` | -| `tooltip_rows` | one semantic row per stage: `stage`, `value`, `prior`, `share`, `conversion`, `dropoff`. Ratios over a zero denominator are `null`, never `inf`. Small-N readouts, not geometry, per §29's raw-buffer rule | +| `tooltip_rows` | one semantic row per stage. Numeric fields — `stage`, `value`, `prior`, `share`, `conversion`, `dropoff` — are the EVENT payload; `value_text`, `share_text`, `conversion_text`, `dropoff_text` are their preformatted twins and are what a tooltip prints. The kernel owns `value_format`/`percent_format` because the client has no `str.format`, so a label, a tooltip and a static export cannot disagree. A ratio whose denominator is zero — or which would overflow to infinity — is `null` numerically and an em dash in its `*_text`. Small-N readouts, not geometry, per §29's raw-buffer rule | **Geometry.** Corners run A=(`lo0`@`pos0`) B=(`hi0`@`pos0`) C=(`hi1`@`pos1`) D=(`lo1`@`pos1`), transposed per orientation. Edges are STRAIGHT in @@ -204,6 +204,16 @@ ribbon. The registry entry sets `stageNav`: the per-stage centers join the keyboard traversal groups, so arrow keys walk the declared order and the announcement reads "Stage i of n" plus the stage's semantic row. +**Animation.** Update interpolation and the grow entrance run on the CPU: +one quad per stage means mixing six small arrays and re-writing the live +buffers per frame (`_mixFunnelGeometry`) costs less than a second attribute +set, and the shader stays untouched. `_preparePositionInterpolation` +dispatches `funnel` to its own prep, which re-encodes the OLD trace's +currently displayed geometry (mid-flight retargets included) into the new +columns' metas; unmatched stages start at their destination. The default +entrance is `grow` — cross edges expand out of the segment spine, the bar +family's baseline rule transposed. + Deferred, recorded in the roadmap: GPU picking, box/lasso selection, and multi-series comparison grouping (facets are the current answer). diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 64a699ad..70c1a385 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -420,9 +420,9 @@ The reassembled bytes are identical to the source blob, which is what keeps Two independent version constants: -- **Renderer/spec protocol.** `PROTOCOL_VERSION = 12` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 13` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 12` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 13` (`js/src/00_header.ts`) is checked in the `ChartView` constructor. A mismatch replaces the chart element with "update the xy package and restart the kernel" and throws. Requests and replies carry no version of their own — the handshake happens once, at first paint, before diff --git a/tests/test_accessibility_contract.py b/tests/test_accessibility_contract.py index 47dfb8e4..4db02d84 100644 --- a/tests/test_accessibility_contract.py +++ b/tests/test_accessibility_contract.py @@ -63,7 +63,9 @@ def test_categorical_axes_announce_categories_instead_of_numeric_padding() -> No def test_keyboard_navigation_reuses_hover_and_tooltip_pipeline() -> None: required = ( 'this._listen(c, "keydown", (e) => this._onA11yKey(e))', - "const hit = { trace: g.trace.id, index: offset, g }", + # `row` (not the flat traversal offset) so a legend-filtered mark + # still reports the SHIPPED row the kernel and tooltip_rows speak. + "const hit = { trace: g.trace.id, index: row, g }", "this._showTooltip(hit, clientX, clientY)", "this._drawKeepPick()", "${noun} ${prefix.flat + 1} of ${prefix.total}.", diff --git a/tests/test_funnel.py b/tests/test_funnel.py index a0b62185..e5c612a2 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -721,3 +721,239 @@ def test_default_formats_produce_the_documented_labels() -> None: assert _funnel.format_value(0.5, "{:,.10g}") == "0.5" assert _funnel.format_ratio(0.6326, "{:.0%}") == "63%" assert _funnel.format_ratio(None, "{:.0%}") == "—" + + +# -- review round 2 (external review of PR #474) ------------------------------ + + +def test_incompatible_axis_types_are_refused_at_build() -> None: + """A log cross axis maps the centered (negative) corners to NaN and a + forced stage-axis type strips the categorical labels — both would draw a + plausible wrong picture, so both refuse at payload build (§28).""" + with pytest.raises(ValueError, match="cross axis 'x' cannot be 'log'"): + xy.funnel_chart(STAGES, VALUES, xy.x_axis(type_="log")).figure().build_payload() + with pytest.raises(ValueError, match="stage axis 'y' cannot be 'time'"): + xy.funnel_chart(STAGES, VALUES, xy.y_axis(type_="time")).figure().build_payload() + with pytest.raises(ValueError, match="cross axis 'y' cannot be 'symlog'"): + ( + xy.funnel_chart(STAGES, VALUES, xy.y_axis(type_="symlog"), orientation="horizontal") + .figure() + .build_payload() + ) + + +def test_raster_gives_var_palette_entries_distinct_fallbacks() -> None: + """SVG/PDF degrade browser-only palette entries to DISTINCT built-ins; + the PNG rasterizer must match instead of collapsing every var() stage + onto one fallback color.""" + import warnings + + from test_png_export import _decode_rgba + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + fig = xy.funnel_chart( + ["a", "b"], + [4.0, 4.0], + geometry="bar", + labels=False, + colors=["var(--a)", "var(--b)"], + width=400, + height=300, + ).figure() + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) + h, w = pixels.shape[:2] + top = tuple(int(v) for v in pixels[int(h * 0.32), int(w * 0.5)][:3]) + bottom = tuple(int(v) for v in pixels[int(h * 0.72), int(w * 0.5)][:3]) + assert top != bottom + + +def test_inside_label_contrast_follows_the_drawn_constant_color() -> None: + fig = Figure(width=640, height=430) + fig.funnel(STAGES, VALUES, color="#ffffff") + inside = next(a for a in fig.annotations if "9,800" in a["text"]) + assert inside["style"]["color"] == "#1f2430", "white fill takes a dark label" + fig2 = Figure(width=640, height=430) + fig2.funnel(STAGES, VALUES, color="#111111") + inside2 = next(a for a in fig2.annotations if "9,800" in a["text"]) + assert inside2["style"]["color"] == "#f7f8fa", "near-black fill takes a light label" + + +def test_horizontal_label_fit_measures_the_pitch_not_the_height() -> None: + """Ten stages across 400px leave ~34px of pitch; a ~100px text cannot sit + inside OR beside its slot, so it hides — measuring the segment HEIGHT + instead marked every one of them as fitting and they overlapped.""" + layout = compute_layout( + [f"S{i}" for i in range(10)], + [10_000.0 - 900.0 * i for i in range(10)], + orientation="horizontal", + geometry="bar", + ) + cramped = decide_labels( + layout, + show_values=True, + show_conversion=True, + show_dropoff=False, + value_format="{:,.0f}", + percent_format="{:.0%}", + font_size=12.0, + plot_px=(400 * 0.85, 300 * 0.85), + ) + assert {label.placement for label in cramped if label.kind == "value"} == {"hidden"} + roomy = decide_labels( + layout, + show_values=True, + show_conversion=True, + show_dropoff=False, + value_format="{:,.0f}", + percent_format="{:.0%}", + font_size=12.0, + plot_px=(1400 * 0.85, 400 * 0.85), + ) + assert {label.placement for label in roomy if label.kind == "value"} == {"inside"} + + +def test_funnel_chart_forwards_name_to_the_mark() -> None: + fig = xy.funnel_chart(["a", "b"], [4.0, 2.0], name="pipeline").figure() + assert fig.traces[0].name == "pipeline" + + +def test_chart_level_data_reaches_an_explicit_funnel_child() -> None: + data = {"stage": ["a", "b"], "value": [4.0, 2.0]} + fig = xy.funnel_chart(xy.funnel(stage="stage", value="value"), data=data).figure() + assert [t.kind for t in fig.traces] == ["funnel"] + assert fig.traces[0].tooltip_rows[0]["value"] == 4.0 + + +def test_stray_kwargs_with_an_explicit_child_are_refused_by_name() -> None: + with pytest.raises(ValueError, match=r"got \['gap'\] alongside an explicit"): + xy.funnel_chart(xy.funnel(stage=["a"], value=[1.0]), gap=0.5) + + +def test_mixed_orientations_in_one_chart_are_refused() -> None: + with pytest.raises(ValueError, match="cannot mix vertical and horizontal"): + xy.funnel_chart( + xy.funnel(stage=["a"], value=[1.0]), + xy.funnel(stage=["b"], value=[1.0], orientation="horizontal"), + ) + + +def test_failed_build_rolls_back_stage_categories() -> None: + """A bad value_format used to leave the failed stages in the category + registry, shifting the next valid funnel's positions by their count.""" + fig = Figure(width=400, height=300) + with pytest.raises(ValueError): + fig.funnel(["a", "b"], [4.0, 2.0], value_format="{:bogus}") + assert fig._axis_categories == {} + fig.funnel(["a", "b"], [4.0, 2.0]) + assert [float(v) for v in fig.traces[0].y0.values] == [pytest.approx(-0.5), pytest.approx(0.5)] + + +def test_multidimensional_stage_arrays_are_refused() -> None: + with pytest.raises(ValueError, match="must be 1-D"): + Figure(width=400, height=300).funnel(np.array([[1, 2], [3, 4]]), [1.0, 2.0, 3.0, 4.0]) + + +def test_ratios_never_overflow_to_infinity() -> None: + """A wide enough dynamic range makes a bare division inf, which is not + JSON, not a wire value, and not a number to print. Undefined is undefined + whether the denominator was zero or the quotient overflowed.""" + stages = compute_stages(["a", "b"], [1e-300, 1e10]) + assert stages[1].share is None + assert stages[1].conversion is None + assert stages[1].dropoff is None + fig = Figure(width=400, height=300) + fig.funnel(["a", "b"], [1e-300, 1e10]) + row = fig.traces[0].tooltip_rows[1] + assert row["conversion"] is None + assert row["conversion_text"] == "—" + + +def test_label_contrast_defers_on_browser_only_fills() -> None: + """`_parse_color` silently substitutes its fallback blue for a var()/oklch() + entry, so a luminance read there is a guess: a fill that resolves white on + screen got a white label. Defer to the theme's own text color instead.""" + from xy.marks import _funnel_label_color + + assert _funnel_label_color("#ffffff") == "#1f2430" + assert _funnel_label_color("#111111") == "#f7f8fa" + assert _funnel_label_color("rgb(240,240,240)") == "#1f2430" + assert _funnel_label_color("var(--brand)") is None + assert _funnel_label_color("oklch(0.7 0.1 200)") is None + + +def test_theme_palette_map_survives_colour_shaped_stage_names() -> None: + """The shared resolver reads a column of CSS colours as per-point PAINT, + not as category labels, so stage names like "#ff0000" come back with no + categories to reorder — the map is still keyed by stage name.""" + fig = xy.funnel_chart( + ["#ff0000", "#00ff00"], [4.0, 2.0], xy.theme(palette={"#ff0000": "#123456"}) + ).figure() + channel = fig.traces[0].color_ch + assert channel.categories == ["#ff0000", "#00ff00"] + assert channel.palette[0] == "#123456" + assert channel.palette[1] != "#123456" + + +def test_funnel_outlines_declare_round_joins_for_raster_parity() -> None: + """The native rasterizer's stroke is a distance field with round joins by + construction, so an SVG miter would spike where a taper meets its neck.""" + doc = ( + xy.funnel_chart(["a", "b"], [4.0, 2.0], neck="taper", stroke="#000000", stroke_width=3.0) + .figure() + .to_svg() + ) + quads = re.findall(r'', doc) + stroked = [q for q in quads if "stroke=" in q] + assert stroked, "expected stroked funnel paths" + for path in stroked: + assert 'stroke-linejoin="round"' in path + + +def test_annotation_labels_follow_the_theme_text_colour_in_both_exporters() -> None: + """The live client resolves an annotation label through + var(--chart-annotation-text, var(--chart-text, inherit)); the exporters + must reach the same colour or a themed chart prints its labels in the + light-mode default. Shapes keep their own neutral paint.""" + from test_png_export import _decode_rgba + + chart = xy.funnel_chart( + ["a", "b"], [4.0, 2.0], xy.theme(text_color="#cc0000"), width=420, height=300 + ) + doc = chart.figure().to_svg() + assert 'fill="#cc0000"' in doc, "SVG label ignored --chart-text" + pixels = _decode_rgba(chart.figure().to_image(format="png", scale=1)) + reds = ((pixels[:, :, 0] > 150) & (pixels[:, :, 1] < 90) & (pixels[:, :, 2] < 90)).sum() + assert reds > 0, "raster label ignored --chart-text" + + +def test_annotation_shape_paint_is_not_the_theme_text_colour() -> None: + """Only the LABEL follows the theme text colour. Widening it to shapes + diverged the SVG exporter from the raster and the live client.""" + doc = ( + xy.line_chart( + xy.line([1.0, 2.0], [1.0, 2.0]), + xy.hline(1.5, text="threshold"), + xy.theme(text_color="#cc0000"), + ) + .figure() + .to_svg() + ) + rules = re.findall(r"]*stroke=\"(#[0-9a-fA-F]{6})\"", doc) + assert "#cc0000" not in rules, "rule stroke took the theme text colour" + + +def test_hover_containment_rejects_the_bounding_box_corner() -> None: + """The client's containment test is the trapezoid, not its bounding box. + A point inside the box but outside the taper must miss — the same rule + `_funnelHover` implements, checked here against the geometry source.""" + layout = compute_layout(["a", "b"], [10.0, 2.0], gap=0.0) + quad = layout.quads[0] + # Mid-segment: the taper has narrowed from 5 to 1, so the half-width is 3. + t = 0.5 + edge = quad.hi0 + (quad.hi1 - quad.hi0) * t + assert edge == pytest.approx(3.0) + # A bounding-box test would accept 4.5 here (it is inside 5, the widest + # edge); trapezoid containment must reject it. + assert edge < 4.5 + assert edge > 2.0 From 8e6d33d2f92e119de3e7963969d131efe5fa5f0f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 17:15:35 -0700 Subject: [PATCH 4/9] Docs CI: the gallery route validator's preview count follows the funnel tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_html_routes.py carries its own hardcoded count, separate from the pytest copies updated with the tile itself, and it only runs against a production export — so the funnel tile passed every local gate and failed the Production docs job. --- docs/app/scripts/check_html_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/app/scripts/check_html_routes.py b/docs/app/scripts/check_html_routes.py index 720097d2..11a18d9d 100644 --- a/docs/app/scripts/check_html_routes.py +++ b/docs/app/scripts/check_html_routes.py @@ -12,7 +12,7 @@ ROUTES_ROOT = APP_ROOT / ".web" / "app" / "routes" LIVE_PREVIEW_MARKERS = ("python demo exec", "python demo-only exec") INLINE_SVG_PREVIEW_ROUTES = {"/overview/gallery/"} -INLINE_SVG_PREVIEW_COUNT = 34 +INLINE_SVG_PREVIEW_COUNT = 35 XY_PAYLOAD_PATTERN = re.compile(r'["\'](?P/docs/xy/xy/[a-f0-9]+\.xyf)["\']') XY_PAYLOAD_MAGIC = b"XYBF" LLMS_DIRECTIVE = "For AI agents: the complete XY documentation index is at" From 316677f412c06b6e98c9a98bfd8e8bc5527a6667 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 17:16:43 -0700 Subject: [PATCH 5/9] Docs test: derive the gallery-validator fixture from its own constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test hardcoded a second copy of the preview count, so bumping the constant for the funnel tile broke it — the same duplicated-number failure one layer down. It now sizes its fixture from INLINE_SVG_PREVIEW_COUNT and tests the validator's behaviour rather than restating the number. --- docs/app/tests/test_docs_site.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index 43fe4c2a..d03f7783 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -1829,18 +1829,17 @@ def test_inline_svg_gallery_validator_requires_every_styled_preview(tmp_path: Pa """Accept only the complete code-native gallery in the production route.""" module_path = tmp_path / "route.jsx" preview = 'viewBox=\\"0 0 320 232\\"' - module_path.write_text( - preview * 34 + "gallery-preview-surface aspect-[320/232] shadow-large", - encoding="utf-8", - ) + # Derived from the validator's own constant, not a second copy of the + # number: adding a gallery tile already updates that constant, and a + # hardcoded fixture here just failed the build a second time. + expected = check_html_routes.INLINE_SVG_PREVIEW_COUNT + surface = "gallery-preview-surface aspect-[320/232] shadow-large" + module_path.write_text(preview * expected + surface, encoding="utf-8") check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) - module_path.write_text( - preview * 33 + "gallery-preview-surface aspect-[320/232] shadow-large", - encoding="utf-8", - ) - with pytest.raises(RuntimeError, match="33 previews, expected 34"): + module_path.write_text(preview * (expected - 1) + surface, encoding="utf-8") + with pytest.raises(RuntimeError, match=f"{expected - 1} previews, expected {expected}"): check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) From b534aa4d26db4802adcd7521adcb5b14d953bd96 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 17:50:28 -0700 Subject: [PATCH 6/9] Address the live bot-review comments on #474 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review batches hit the PR; the first reviewed the pre-fix code and is almost entirely covered by the round-2 commit. This addresses what was still live in the second batch, and skips what is not (reasons in the PR thread summary). Fixed: - animation match="append" paired every vertical-funnel stage with the LAST old stage: append matching keys on decoded x values, and a vertical funnel's x centers are all ~0. Stages are an ordered process, not a stream — the funnel prep now rebuilds the pairs by position and records "index:append-unsupported". A snap-strategy match (the 200k limit) now bails before preparing, instead of mixing six full-size arrays per frame for identical values. - horizontal OUTSIDE labels anchored "start" at the stage midpoint, hanging half the text over the neighbour and clipping the last stage; they center over their own stage now (vertical margin labels keep the start anchor). - the colour-shaped-stage-names palette fallback assigned spare colours silently; it now emits the same unmapped-category RuntimeWarning the resolver path does. - funnel_chart joins CHART_FACTORIES in the type-surface test (with the composing-factory exemption radar/wind-rose/pie already use). - contract wording: `stage` in tooltip_rows is the stage NAME (a string), not a numeric field. - the docs page documents overflow-to-infinity ratios beside the zero-denominator case, and its one backtick fence becomes the tilde style every other chart doc uses. - roadmap depth-table row 18 (the second Funnel row) now points at shipped row 15 instead of reading as still-planned. Skipped, with reasons: - "move numeric tooltip fields out of JSON" — the chart-kind contract documents the small-N semantic-row exemption for exactly these fields (ribbon precedent); §29's raw-buffer rule is for geometry that scales with data. - SVG-vs-raster ANNOTATION DEFAULT colours (token-less charts) — a pre-existing divergence this PR did not introduce and both reviews anchored on touched lines; spun off as its own task rather than widening this diff with cross-chart visual churn. - hover-during-animation reads settled geometry — the bar family behaves identically, and events should report real data, not a mid-flight blend. - theme-flip-while-legend-hovering drops the sibling dim until the next mouse move — matches the LUT marks' existing behaviour. --- docs/charts/funnel-chart.md | 11 +++++----- js/src/56_animation.ts | 15 ++++++++++++- python/xy/_funnel.py | 8 ++++++- python/xy/marks.py | 13 +++++++++++ spec/api/chart-kind-contract.md | 2 +- spec/api/chart-roadmap.md | 2 +- tests/test_funnel.py | 39 +++++++++++++++++++++++++++++++++ tests/test_type_surface.py | 3 ++- 8 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/charts/funnel-chart.md b/docs/charts/funnel-chart.md index 16f1ab2b..86fa0975 100644 --- a/docs/charts/funnel-chart.md +++ b/docs/charts/funnel-chart.md @@ -197,7 +197,7 @@ value/drop-off labels (`annotation_label`) — styles through the standard [chrome slots](/docs/xy/styling/chrome-slots/) with CSS classes, Tailwind utilities, or `styles={...}`: -```python +~~~python xy.funnel_chart( stages, values, @@ -207,7 +207,7 @@ xy.funnel_chart( "tooltip": "rounded-xl shadow-lg", }, ) -``` +~~~ `value_format` and `percent_format` are `str.format` templates, and the kernel applies them once for every surface — segment labels, hover tooltips, @@ -257,9 +257,10 @@ segment in the same colour across every chart that names that stage. Hover reads the full arithmetic for a stage, and because a segment covers an area rather than a point, the tooltip follows the cursor within it. Clicking emits `xy:click` carrying the stage name, value, prior value, overall share, -conversion, and drop-off — the same semantic row the tooltip shows. Ratios -over a zero denominator arrive as `null` in events and print as an em dash -(—) in the tooltip, so a stage after a zero reads as "no meaningful number" +conversion, and drop-off — the same semantic row the tooltip shows. A ratio +with no meaningful value — a zero denominator, or one that would overflow to +infinity on an extreme dynamic range — arrives as `null` in events and prints +as an em dash (—) in the tooltip, so it reads as "no meaningful number" rather than as missing data. Box and lasso selection are deliberately absent rather than approximate. diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index 2292c489..04bb6493 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -471,10 +471,23 @@ Object.assign(ChartView.prototype, { _prepareFunnelPositionInterpolation(previous, next, match) { const oldF = previous._cpuFunnel; const newF = next._cpuFunnel; - if (!oldF || !newF || previous.orientation !== next.orientation) { + // Over the match limit the strategy is already "snap": preparing anyway + // would mix six full-size arrays per frame for identical values. + if (match.strategy === "snap" || !oldF || !newF || + previous.orientation !== next.orientation) { match.fallback ||= "snap:layout-mismatch"; return false; } + if (match.strategy === "append") { + // Append matching pairs rows by their decoded x value, and a vertical + // funnel's x centers are all ~0 (the cross midline), so every new stage + // paired with the LAST old stage. Stages are an ordered process, not a + // stream: match them by position instead, and say so. + match.fallback ||= "index:append-unsupported"; + match.pairs.length = 0; + const count = Math.min(oldF.n, newF.n); + for (let i = 0; i < count; i++) match.pairs.push([i, i]); + } const names = ["pos0", "pos1", "lo0", "hi0", "lo1", "hi1"]; const decode = (value, meta) => value / (Number(meta.scale) || 1) + (Number(meta.offset) || 0); const encode = (value, meta) => (value - (Number(meta.offset) || 0)) * (Number(meta.scale) || 1); diff --git a/python/xy/_funnel.py b/python/xy/_funnel.py index adb5b9cf..d1b7bb44 100644 --- a/python/xy/_funnel.py +++ b/python/xy/_funnel.py @@ -369,7 +369,13 @@ def decide_labels( if _fits(width_px, along_px * 0.92) and _fits(line_px, across_px): placement, cross, anchor = "inside", 0.0, "middle" elif outside_ok: - placement, cross, anchor = "outside", mid_half, "start" + # A vertical outside label runs into the side margin from the + # segment's edge; a horizontal one sits ABOVE its own stage, + # so it centers there — a start anchor at the stage midpoint + # hung half the text over the neighbour and clipped the last + # stage at the plot edge. + anchor = "middle" if horizontal else "start" + placement, cross = "outside", mid_half else: placement, cross, anchor = "hidden", 0.0, "middle" labels.append( diff --git a/python/xy/marks.py b/python/xy/marks.py index f3d5cae5..c2237469 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -794,6 +794,7 @@ def funnel( DEFAULT_PALETTE ) unmapped = 0 + missing: list[str] = [] stage_css = [] for name in stage_names: if name in pinned: @@ -801,6 +802,18 @@ def funnel( else: stage_css.append(spare[unmapped % len(spare)]) unmapped += 1 + missing.append(name) + if missing: + # The resolver path warns about unmapped categories; this + # fallback must too, or a typo in the map is silent exactly + # when the stage names look like colors. + warnings.warn( + f"{len(missing)} stage(s) {missing} are not in the " + "xy.theme(palette={...}) map and fall back to the cycle. " + "Add them to the map to pin their colors.", + RuntimeWarning, + stacklevel=3, + ) else: stage_css = [self.palette_color(i) for i in range(n)] diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index f2dc122d..9634d4e7 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -176,7 +176,7 @@ renderers only ever see quads plus semantics: | `lo1`, `hi1` | cross-axis edges at `pos1` — internally these ride the trace's generic `x`/`y` slots, which is why `_range_columns` has a funnel branch | | `n_marks` | quad count (= stage count) | | `color` | per-stage paint channel: `constant`, `categorical` (codes + palette, categories in DECLARED stage order — the funnel factory builds this by hand because generic factorization sorts labels alphabetically), or `direct_rgba` | -| `tooltip_rows` | one semantic row per stage. Numeric fields — `stage`, `value`, `prior`, `share`, `conversion`, `dropoff` — are the EVENT payload; `value_text`, `share_text`, `conversion_text`, `dropoff_text` are their preformatted twins and are what a tooltip prints. The kernel owns `value_format`/`percent_format` because the client has no `str.format`, so a label, a tooltip and a static export cannot disagree. A ratio whose denominator is zero — or which would overflow to infinity — is `null` numerically and an em dash in its `*_text`. Small-N readouts, not geometry, per §29's raw-buffer rule | +| `tooltip_rows` | one semantic row per stage: `stage` (the stage NAME, a string) plus the numeric fields `value`, `prior`, `share`, `conversion`, `dropoff` are the EVENT payload; `value_text`, `share_text`, `conversion_text`, `dropoff_text` are their preformatted twins and are what a tooltip prints. The kernel owns `value_format`/`percent_format` because the client has no `str.format`, so a label, a tooltip and a static export cannot disagree. A ratio whose denominator is zero — or which would overflow to infinity — is `null` numerically and an em dash in its `*_text`. Small-N readouts, not geometry, per §29's raw-buffer rule | **Geometry.** Corners run A=(`lo0`@`pos0`) B=(`hi0`@`pos0`) C=(`hi1`@`pos1`) D=(`lo1`@`pos1`), transposed per orientation. Edges are STRAIGHT in diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 38361b34..da16a695 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -192,7 +192,7 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer | 15 | Candlestick / OHLC | Important for finance users and appears in Plotly/Highcharts stock tooling. | **Prototyped (PR closed unmerged):** candlestick/OHLC marks with date axes, gaps, and hover format on the closed finance exploration branch. Remaining polish: range selectors. | | 16 | Finance overlays | Volume bars, VWAP, moving averages, Bollinger bands, depth/order-book heatmap, market profile, Renko, Heikin-Ashi, Kagi, point-and-figure. | **Prototyped (PR closed unmerged):** volume pane, SMA, VWAP, Bollinger, RSI, MACD as `FinanceLayer`s reusing composed charts + time axes. Remaining: depth/order-book, market profile, Renko/Heikin-Ashi/Kagi/P&F. | | 17 | Waterfall | Common in business reporting and Plotly/Highcharts. | Mostly categorical bars plus running baseline. | -| 18 | Funnel / funnel area | Common sales/product analytics chart. | Mostly categorical geometry plus labels. | +| 18 | Funnel / funnel area | Common sales/product analytics chart. | Shipped as row 15 above (`xy.funnel_chart`, protocol v13): categorical quad geometry plus build-time labels, as predicted. | | 19 | Calendar/cohort heatmap | Common product analytics and retention surface. | Grid plus date semantics. | | 20 | Gantt/timeline/event charts | Product/project/ops domain chart. | High UI polish and interaction expectations. | diff --git a/tests/test_funnel.py b/tests/test_funnel.py index e5c612a2..d9fe81dc 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -957,3 +957,42 @@ def test_hover_containment_rejects_the_bounding_box_corner() -> None: # edge); trapezoid containment must reject it. assert edge < 4.5 assert edge > 2.0 + + +def test_horizontal_outside_labels_center_over_their_stage() -> None: + """A start anchor at the stage midpoint hung half the text over the + neighbour and clipped the last stage at the plot edge; horizontal outside + labels center instead. Vertical margin labels keep the start anchor.""" + layout = compute_layout( + ["alpha", "beta", "gamma"], + [10.0, 6.0, 1.0], + orientation="horizontal", + geometry="bar", + ) + labels = decide_labels( + layout, + show_values=True, + show_conversion=True, + show_dropoff=False, + value_format="{:,.0f}", + percent_format="{:.0%}", + font_size=12.0, + plot_px=(700.0, 120.0), + ) + outside = [label for label in labels if label.placement == "outside"] + assert outside, "expected the thin stage to fall outside" + assert {label.anchor for label in outside} == {"middle"} + + +def test_funnel_append_matching_downgrades_to_index_pairs() -> None: + """Append matching pairs rows by decoded x value, and a vertical funnel's + x centers are all ~0, so every new stage paired with the LAST old stage. + The funnel prep rebuilds the pairs by position and records the downgrade.""" + source = (Path(__file__).parents[1] / "js" / "src" / "56_animation.ts").read_text( + encoding="utf-8" + ) + prep = source.split("_prepareFunnelPositionInterpolation(previous, next, match) {")[1] + prep = prep.split("\n },")[0] + assert 'match.strategy === "snap"' in prep, "snap strategy must bail before mixing" + assert 'match.strategy === "append"' in prep + assert '"index:append-unsupported"' in prep diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index 7af2d74f..f5e12505 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -85,6 +85,7 @@ "stem_chart", "segments_chart", "triangle_mesh_chart", + "funnel_chart", ) CHROME_FACTORIES = ( "legend", @@ -390,7 +391,7 @@ def test_chart_factories_construct_named_lazy_charts() -> None: chart = getattr(components, name)() assert isinstance(chart, components.Chart), name assert chart.kind == name - if name not in {"radar_chart", "wind_rose", "pie_chart"}: + if name not in {"radar_chart", "wind_rose", "pie_chart", "funnel_chart"}: assert chart.children == () assert chart._figure is None assert chart._widget is None From ba3c18c434fecc11998e8a9b312716214989a606 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 18:09:03 -0700 Subject: [PATCH 7/9] Close out the remaining review threads on #474 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - funnel stage centers leaked into the STANDALONE selection universe. They are retained (`retainCpu`) as a keyboard-navigation aid, but `_selectLocal` and `_selectLocalPolygon` iterate every trace holding `_cpu`, so a box or lasso select counted them and reported a selection the chart never drew — while the kind documents selection as absent. Both paths now skip stageNav marks. Introduced by this PR. - `stroke=` with no `stroke_width=` drew nothing: every renderer skips a zero-width stroke. Implies 1px now, matching the other mark builders. - a theme flip while a funnel legend row was hovered dropped that row's sibling emphasis, because the rebuild uploads undimmed rows. The dim is re-applied against the NEW backdrop (better than the LUT marks, which keep a dim computed against the old one). - the funnel tooltip now shows the prior stage's value, so "From previous 63%" is checkable rather than asserted; stage 0 omits the row. - `_scene.funnel_quad`'s normative comment named the wrong tessellation: the client sweeps A,B,D,C (triangles ABD/BDC), not ABC/ACD. Same quad, but a renderer reading the contract would have built the wrong strip. - the render smoke's a11y probe asserted GROUP MEMBERSHIP, which passed even with a broken walk; it now drives Home/ArrowRight and checks both the selected stage and the live-region text. Its palette check also skipped the middle stage, so only 2 of 3 shipped categories were verified. - spec/api/styling.md gained the funnel row (it was registered as a style surface with no entry in the authoritative table). - the Funnel tile joins the per-title gallery assertion, and the roadmap depth-table cross-reference no longer reads as pointing at rank 15 of its own table (which is Candlestick). Skipped, with reasons: - "move numeric tooltip fields out of JSON" — the chart-kind contract documents the small-N semantic-row exemption for exactly these fields (ribbon precedent); §29's raw-buffer rule targets geometry that scales with data, not five numbers per stage. - hover during animation reads settled geometry — `_barHover` does the same against `_cpu` while bars interpolate, and an event should report the real datum, not a mid-flight blend. - SVG-vs-raster annotation SHAPE defaults (`#667085` shared vs the raster's per-kind `#64748b`/`#2563eb`) — a pre-existing divergence this PR did not introduce; both bots anchored it on lines this PR happens to touch. Spun off rather than widening this diff with cross-chart visual churn. --- docs/app/tests/test_docs_site.py | 1 + js/src/52_tooltip.ts | 5 +++++ js/src/53_interaction.ts | 10 +++++++++ js/src/55_marks.ts | 10 ++++++++- python/xy/_scene.py | 12 ++++++---- python/xy/marks.py | 13 +++++++++++ scripts/render_smoke_nonumpy.py | 19 ++++++++++++++-- spec/api/chart-roadmap.md | 2 +- spec/api/styling.md | 1 + tests/test_funnel.py | 38 ++++++++++++++++++++++++++++++++ 10 files changed, 103 insertions(+), 8 deletions(-) diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index d03f7783..646094c8 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -1304,6 +1304,7 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( assert rendered.count("size:14") == 35 assert "size:6" not in rendered for chart_type in ( + "Funnel", "Line", "Area", "Step", diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index c66ae792..d6617e7a 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -348,6 +348,11 @@ Object.assign(ChartView.prototype, { row.value_text, row.value, ); + // The prior value makes "From previous" checkable rather than asserted; + // stage 0 has no prior and simply omits the row. + if (row.prior_text || (row.prior !== undefined && row.prior !== null)) { + field("From", row.prior_text, row.prior); + } field("Overall", row.share_text, row.share); field("From previous", row.conversion_text, row.conversion); field("Drop-off", row.dropoff_text, row.dropoff); diff --git a/js/src/53_interaction.ts b/js/src/53_interaction.ts index b9222f5c..bda6faae 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -1030,6 +1030,11 @@ Object.assign(ChartView.prototype, { let total = 0; for (const g of this.gpuTraces) { if (!g._cpu || g.tier === "density") continue; + // A stageNav mark retains `_cpu` as a KEYBOARD aid (funnel stage + // centers), not as selectable point geometry: its kind documents + // selection as absent, so counting those centers reported a selection + // the chart never drew. + if (markOf(g.trace.kind).stageNav) continue; // Restoration mirrors the kernel selection universe exactly. Otherwise // a provisional line/hidden-series mask would survive forever because // the authoritative reply only contains visible scatter trace ids. @@ -1070,6 +1075,11 @@ Object.assign(ChartView.prototype, { for (const g of this.gpuTraces) { // _cpu only exists where the standalone entry retained copies (retainCpu). if (!g._cpu || g.tier === "density") continue; + // A stageNav mark retains `_cpu` as a KEYBOARD aid (funnel stage + // centers), not as selectable point geometry: its kind documents + // selection as absent, so counting those centers reported a selection + // the chart never drew. + if (markOf(g.trace.kind).stageNav) continue; if (opts.localMask === true && (g.trace.kind !== "scatter" || g._legendHidden)) continue; const cx = g._cpu.x, cy = g._cpu.y; diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index af564b84..8bee3fa9 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -1,4 +1,4 @@ -import { parseColor } from "./20_theme"; +import { chartBackdrop, parseColor } from "./20_theme"; // --------------------------------------------------------------------------- // Mark-renderer registry — the client-side dispatch for chart kinds. @@ -214,6 +214,14 @@ export const MARK_KINDS = { // re-resolve against the new theme. buffer=null reuses the stashed // codes and re-uploads the recolored per-stage RGBA rows. view._funnelPaint(g, g.trace, null); + // A rebuild uploads UNDIMMED rows, so a theme flip while a legend row + // is hovered dropped that row's emphasis until the next mouse move. + // Re-apply it against the NEW backdrop. + const hover = view._legendHover; + if (hover && hover.cat != null && !hover.off && hover.traces + && hover.traces.includes(view.gpuTraces.indexOf(g))) { + view._dimFunnelPaint(g, hover.cat, chartBackdrop(view.root, view.theme.bg)); + } const style = g.trace.style || {}; g.stroke = style.stroke ? parseColor(view.root, style.stroke, [0, 0, 0, 1]) : null; }, diff --git a/python/xy/_scene.py b/python/xy/_scene.py index 0320d798..df73fe55 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -87,10 +87,14 @@ def funnel_quad( `pos` runs along the stage axis, `lo/hi` are the cross-axis edges at each end; `horizontal` maps pos→x/cross→y and vertical the transpose. Corners - run A=(lo0@pos0) B=(hi0@pos0) C=(hi1@pos1) D=(lo1@pos1) — the same two - triangles (ABC, ACD) the client expands in `_buildFunnelMark`. This is the - single reference both static exporters and the golden geometry test - consume, so SVG and PNG cannot drift from each other or from the client. + run A=(lo0@pos0) B=(hi0@pos0) C=(hi1@pos1) D=(lo1@pos1) as a closed + polygon. The client covers the same quad with a 4-vertex TRIANGLE_STRIP + in the order A, B, D, C — `FUNNEL_VS` takes `t = floor(id/2)` and + `side = id & 1` — so its two triangles are ABD and BDC. A different + tessellation of one identical quad, which is exactly why this POLYGON is + the shared reference rather than a triangle pair. Both static exporters + and the golden geometry test consume it, so SVG and PNG cannot drift from + each other or from the client. """ if horizontal: corners = [(pos0, lo0), (pos0, hi0), (pos1, hi1), (pos1, lo1)] diff --git a/python/xy/marks.py b/python/xy/marks.py index c2237469..b6892a2e 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -849,6 +849,16 @@ def funnel( if width_channel is not None: raise ValueError("funnel stroke_width is per-trace") stroke_width_value = 0.0 if width_constant is None else float(width_constant) + if stroke_value is not None and not stroke_width_value: + # `stroke=` with no width drew nothing: every renderer skips a + # zero-width stroke. The other mark builders imply 1px in exactly this + # case, so a documented option is never silently inert. + stroke_width_value = 1.0 + if stroke_value is not None and not stroke_width_value: + # `stroke=` with no width drew nothing: every renderer skips a + # zero-width stroke. The other mark builders imply 1px in exactly this + # case, so a documented option is not silently inert. + stroke_width_value = 1.0 stage_dim = "y" if orientation == "vertical" else "x" # The checkpoint is taken BEFORE the stage names commit to the axis @@ -934,6 +944,9 @@ def funnel( "conversion": s.conversion, "dropoff": s.dropoff, "value_text": _funnel.format_value(s.value, value_format), + "prior_text": ( + None if s.prior is None else _funnel.format_value(s.prior, value_format) + ), "share_text": _funnel.format_ratio(s.share, percent_format), "conversion_text": _funnel.format_ratio(s.conversion, percent_format), "dropoff_text": _funnel.format_ratio(s.dropoff, percent_format), diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 3577846c..977d6856 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -1257,14 +1257,29 @@ def main() -> None: // Stage A (data y=0, bottom sixth of the y range) and stage C (top). glFn.readPixels(Math.round(WF/2),Math.round(HF*0.17),1,1,glFn.RGBA,glFn.UNSIGNED_BYTE,apx); glFn.readPixels(Math.round(WF/2),Math.round(HF*0.83),1,1,glFn.RGBA,glFn.UNSIGNED_BYTE,cpx); - const fnInk=(apx[0]>90 && apx[0]>apx[2]*2 && cpx[2]>90 && cpx[2]>cpx[0]*2)?1:0; + const bpx=new Uint8Array(4); + glFn.readPixels(Math.round(WF/2),Math.round(HF*0.5),1,1,glFn.RGBA,glFn.UNSIGNED_BYTE,bpx); + const fnInk=(apx[0]>90 && apx[0]>apx[2]*2 // stage A red + && bpx[1]>90 && bpx[1]>bpx[0]*2 // stage B green (was unchecked) + && cpx[2]>90 && cpx[2]>cpx[0]*2)?1:0; // stage C blue // Containment: stage B center hits index 1; a point beside B's taper at // the same height (cross 4.5 > its widest half-width 3) misses. const fnHit=vFn._funnelHover(gFn,0,1.0); const fnMiss=vFn._funnelHover(gFn,4.5,1.0); const fnRow=fnHit?vFn._localRow(fnHit):null; const fnRowOk=(fnRow && fnRow.stage==="B" && fnRow.value===6 && fnRow.dropoff===0.4)?1:0; - const fnNav=vFn._a11yPointGroups().some((g)=>g===gFn)?1:0; + // Traversal, not membership: Home then ArrowRight must land on stage 1 + // and the live region must name it. Membership alone passed even when the + // walk was broken. + vFn.canvas.dispatchEvent(new KeyboardEvent("keydown",{{key:"Home",bubbles:true}})); + const navHome=vFn._hoverTarget && vFn._hoverTarget.index===0; + const homeSaid=(vFn.a11yLive.textContent||"").includes("Stage 1 of 3"); + vFn.canvas.dispatchEvent(new KeyboardEvent("keydown",{{key:"ArrowRight",bubbles:true}})); + const navNext=vFn._hoverTarget && vFn._hoverTarget.index===1; + const nextSaid=(vFn.a11yLive.textContent||"").includes("Stage 2 of 3"); + vFn.canvas.dispatchEvent(new KeyboardEvent("keydown",{{key:"Escape",bubbles:true}})); + const fnNav=(vFn._a11yPointGroups().some((g)=>g===gFn) + && navHome && homeSaid && navNext && nextSaid)?1:0; // Review-fix probes (PR #474 round 2), all EXECUTED, not grepped: // (a) legend category filter narrows draw count, hover, and the keyboard // walk, and the row index stays in shipped space; diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index da16a695..df1a7764 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -192,7 +192,7 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer | 15 | Candlestick / OHLC | Important for finance users and appears in Plotly/Highcharts stock tooling. | **Prototyped (PR closed unmerged):** candlestick/OHLC marks with date axes, gaps, and hover format on the closed finance exploration branch. Remaining polish: range selectors. | | 16 | Finance overlays | Volume bars, VWAP, moving averages, Bollinger bands, depth/order-book heatmap, market profile, Renko, Heikin-Ashi, Kagi, point-and-figure. | **Prototyped (PR closed unmerged):** volume pane, SMA, VWAP, Bollinger, RSI, MACD as `FinanceLayer`s reusing composed charts + time axes. Remaining: depth/order-book, market profile, Renko/Heikin-Ashi/Kagi/P&F. | | 17 | Waterfall | Common in business reporting and Plotly/Highcharts. | Mostly categorical bars plus running baseline. | -| 18 | Funnel / funnel area | Common sales/product analytics chart. | Shipped as row 15 above (`xy.funnel_chart`, protocol v13): categorical quad geometry plus build-time labels, as predicted. | +| 18 | Funnel / funnel area | Common sales/product analytics chart. | Shipped — see "Funnel" in the coverage-backlog table above (`xy.funnel_chart`, protocol v13): categorical quad geometry plus build-time labels, as predicted. | | 19 | Calendar/cohort heatmap | Common product analytics and retention surface. | Grid plus date semantics. | | 20 | Gantt/timeline/event charts | Product/project/ops domain chart. | High UI polish and interaction expectations. | diff --git a/spec/api/styling.md b/spec/api/styling.md index 6bcdc270..544e26c9 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -1235,6 +1235,7 @@ rendered mark family and its accepted `style=` properties. | `area` | ✅ (+ `line_width`/`line_opacity`) | ✅ | — | line is the stroke | ✅ | ✅ outline | ✅ | | `line` | ✅ | — (stroke gradients: roadmap) | — | is a stroke | ✅ | ✅ | ✅ `width` | | `ribbon` | per-end colours are **channels** (`color`/`color_target`), not style — `style.fill` gradients are rejected so the flow gradient cannot be half-overridden | — | — | ✅ outline, falls back to the band colour | ✅ bump cubic | — | ✅ `stroke_width` | +| `funnel` | per-stage colours are a **channel** (categorical over the stage names, or `colors=`/`color=`), not style — `style.fill` is rejected for the same reason as `ribbon` | — | — | ✅ outline, falls back to each segment's own fill (1px implied when only `stroke` is set) | straight edges in transformed space | — | ✅ `stroke_width` | | `scatter` | ✅ + color/size channels | — | 17 `symbol` glyphs | ✅ `stroke`/`stroke_width` | — | — | ✅ + size channel | | `heatmap` | colormap + `domain` | colormap is the gradient | — | — | — | — | cell-driven | diff --git a/tests/test_funnel.py b/tests/test_funnel.py index d9fe81dc..0f9dcbdd 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -996,3 +996,41 @@ def test_funnel_append_matching_downgrades_to_index_pairs() -> None: assert 'match.strategy === "snap"' in prep, "snap strategy must bail before mixing" assert 'match.strategy === "append"' in prep assert '"index:append-unsupported"' in prep + + +def test_stroke_without_width_still_draws_an_outline() -> None: + """Every renderer skips a zero-width stroke, so `stroke=` alone drew + nothing. The other mark builders imply 1px in exactly this case.""" + doc = xy.funnel_chart(["a", "b"], [4.0, 2.0], stroke="#ff0000").figure().to_svg() + assert 'stroke="#ff0000"' in doc + assert 'stroke-width="1"' in doc + + +def test_tooltip_rows_carry_the_prior_value_text() -> None: + fig = Figure(width=400, height=300) + fig.funnel(["a", "b"], [9800.0, 6200.0], value_format="{:,.0f}") + rows = fig.traces[0].tooltip_rows + assert rows[0]["prior_text"] is None, "stage 0 has no prior" + assert rows[1]["prior_text"] == "9,800" + + +def test_funnel_stage_centers_stay_out_of_the_selection_universe() -> None: + """`retainCpu` puts stage centers in `_cpu` for the KEYBOARD walk, but + funnel selection is documented as absent — counting those centers reported + a selection the chart never drew.""" + source = (Path(__file__).parents[1] / "js" / "src" / "53_interaction.ts").read_text( + encoding="utf-8" + ) + assert source.count("markOf(g.trace.kind).stageNav) continue;") == 2, ( + "both _selectLocal and _selectLocalPolygon must skip stageNav marks" + ) + + +def test_scene_reference_names_the_clients_actual_strip_order() -> None: + """The client sweeps A,B,D,C (triangles ABD/BDC), not ABC/ACD. A normative + comment that names the wrong tessellation misleads the next renderer.""" + from xy import _scene + + doc = _scene.funnel_quad.__doc__ or "" + assert "A, B, D, C" in doc + assert "ABD and BDC" in doc From 6d0d1d4543582f4fbe934e73c8249f96db25cbfe Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 18:30:15 -0700 Subject: [PATCH 8/9] Fix a duplicated stroke fallback; document prior_text in the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stroke-width fallback landed twice — a scripted edit applied to both sides of a near-identical block — so the second copy was dead code sitting under the first. One copy now, with the explicit-zero case spelled out. `prior_text` was added to `tooltip_rows` without reaching the normative wire table, so a client implementing from the contract could not discover it. Listed now, including its `null` on stage 0. The stroke test also grew teeth: it was checking only the SVG string, which left the reviewer's question — does the implied 1px reach the NATIVE exporters, or is it client-only? — unanswered by the suite. It now asserts the wire style, the SVG attribute, and red pixels in the PNG. (It does reach them: the implication happens at build time, so `stroke_width: 1.0` ships on the wire and both exporters gate on it normally.) --- python/xy/marks.py | 9 +++------ spec/api/chart-kind-contract.md | 2 +- tests/test_funnel.py | 23 +++++++++++++++++++++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/python/xy/marks.py b/python/xy/marks.py index b6892a2e..e3eba394 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -852,12 +852,9 @@ def funnel( if stroke_value is not None and not stroke_width_value: # `stroke=` with no width drew nothing: every renderer skips a # zero-width stroke. The other mark builders imply 1px in exactly this - # case, so a documented option is never silently inert. - stroke_width_value = 1.0 - if stroke_value is not None and not stroke_width_value: - # `stroke=` with no width drew nothing: every renderer skips a - # zero-width stroke. The other mark builders imply 1px in exactly this - # case, so a documented option is not silently inert. + # case, so a documented option is never silently inert. Like them, an + # explicit `stroke_width=0` is indistinguishable from the default and + # also takes the 1px — "outline me, zero wide" has no other reading. stroke_width_value = 1.0 stage_dim = "y" if orientation == "vertical" else "x" diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 9634d4e7..7ec9f507 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -176,7 +176,7 @@ renderers only ever see quads plus semantics: | `lo1`, `hi1` | cross-axis edges at `pos1` — internally these ride the trace's generic `x`/`y` slots, which is why `_range_columns` has a funnel branch | | `n_marks` | quad count (= stage count) | | `color` | per-stage paint channel: `constant`, `categorical` (codes + palette, categories in DECLARED stage order — the funnel factory builds this by hand because generic factorization sorts labels alphabetically), or `direct_rgba` | -| `tooltip_rows` | one semantic row per stage: `stage` (the stage NAME, a string) plus the numeric fields `value`, `prior`, `share`, `conversion`, `dropoff` are the EVENT payload; `value_text`, `share_text`, `conversion_text`, `dropoff_text` are their preformatted twins and are what a tooltip prints. The kernel owns `value_format`/`percent_format` because the client has no `str.format`, so a label, a tooltip and a static export cannot disagree. A ratio whose denominator is zero — or which would overflow to infinity — is `null` numerically and an em dash in its `*_text`. Small-N readouts, not geometry, per §29's raw-buffer rule | +| `tooltip_rows` | one semantic row per stage: `stage` (the stage NAME, a string) plus the numeric fields `value`, `prior`, `share`, `conversion`, `dropoff` are the EVENT payload; `value_text`, `prior_text`, `share_text`, `conversion_text`, `dropoff_text` are their preformatted twins (`prior_text` is `null` on stage 0, which has no prior) and are what a tooltip prints. The kernel owns `value_format`/`percent_format` because the client has no `str.format`, so a label, a tooltip and a static export cannot disagree. A ratio whose denominator is zero — or which would overflow to infinity — is `null` numerically and an em dash in its `*_text`. Small-N readouts, not geometry, per §29's raw-buffer rule | **Geometry.** Corners run A=(`lo0`@`pos0`) B=(`hi0`@`pos0`) C=(`hi1`@`pos1`) D=(`lo1`@`pos1`), transposed per orientation. Edges are STRAIGHT in diff --git a/tests/test_funnel.py b/tests/test_funnel.py index 0f9dcbdd..3d81946a 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -1000,10 +1000,29 @@ def test_funnel_append_matching_downgrades_to_index_pairs() -> None: def test_stroke_without_width_still_draws_an_outline() -> None: """Every renderer skips a zero-width stroke, so `stroke=` alone drew - nothing. The other mark builders imply 1px in exactly this case.""" - doc = xy.funnel_chart(["a", "b"], [4.0, 2.0], stroke="#ff0000").figure().to_svg() + nothing. The other mark builders imply 1px in exactly this case, and the + implication happens at BUILD time — so it ships on the wire and both + static exporters honour it, not just the client.""" + from test_png_export import _decode_rgba + + chart = xy.funnel_chart( + ["a", "b"], + [4.0, 2.0], + geometry="bar", + labels=False, + color="#ffffff", + stroke="#ff0000", + width=400, + height=300, + ) + spec, _ = chart.figure().build_payload() + assert spec["traces"][0]["style"]["stroke_width"] == 1.0 + doc = chart.figure().to_svg() assert 'stroke="#ff0000"' in doc assert 'stroke-width="1"' in doc + pixels = _decode_rgba(chart.figure().to_image(format="png", scale=1)) + reds = ((pixels[:, :, 0] > 150) & (pixels[:, :, 1] < 90) & (pixels[:, :, 2] < 90)).sum() + assert reds > 0, "raster dropped the implied 1px outline" def test_tooltip_rows_carry_the_prior_value_text() -> None: From 48b5c43d95cbe68f1bc9a7b3a5fb66aa2cc8274d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 6 Aug 2026 18:41:50 -0700 Subject: [PATCH 9/9] Test: build the stroke-parity figure once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stroke test built the figure three times — once each for the wire, the SVG and the PNG. The point of the test is that the SAME built object reaches all three surfaces identically, so a single build states that better and drops the duplicated work. --- tests/test_funnel.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_funnel.py b/tests/test_funnel.py index 3d81946a..eb329fde 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -1015,12 +1015,15 @@ def test_stroke_without_width_still_draws_an_outline() -> None: width=400, height=300, ) - spec, _ = chart.figure().build_payload() + # One figure for all three surfaces: the point is that the SAME built + # object reaches the wire, the SVG and the raster identically. + fig = chart.figure() + spec, _ = fig.build_payload() assert spec["traces"][0]["style"]["stroke_width"] == 1.0 - doc = chart.figure().to_svg() + doc = fig.to_svg() assert 'stroke="#ff0000"' in doc assert 'stroke-width="1"' in doc - pixels = _decode_rgba(chart.figure().to_image(format="png", scale=1)) + pixels = _decode_rgba(fig.to_image(format="png", scale=1)) reds = ((pixels[:, :, 0] > 150) & (pixels[:, :, 1] < 90) & (pixels[:, :, 2] < 90)).sum() assert reds > 0, "raster dropped the implied 1px outline"