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/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" diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index 94544dc4..646094c8 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,9 +1301,10 @@ 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 ( + "Funnel", "Line", "Area", "Step", @@ -1372,7 +1373,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 +1567,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 @@ -1828,18 +1830,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) 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..86fa0975 --- /dev/null +++ b/docs/charts/funnel-chart.md @@ -0,0 +1,276 @@ +--- +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), +[styling](#style-a-funnel), or +[the legend](#add-a-legend). + +## 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 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 + +~~~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. + +## 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: + +~~~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 +emits `xy:click` carrying the stage name, value, prior value, overall share, +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. + +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/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..defb2c05 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"; @@ -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 @@ -3277,11 +3307,44 @@ 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); + } + this._uploadFunnelPaint(g); + } + // 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 @@ -3362,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); } @@ -3821,6 +3888,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); } @@ -4582,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 @@ -4663,6 +4734,268 @@ 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) { + // 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)) { + 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(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; + } + // 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; + 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; + 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]]); @@ -7827,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) { @@ -7844,6 +8181,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 +8401,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..d6617e7a 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,43 @@ 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") { + // 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))) { + 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, + ); + // 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); + return items; + } const seriesName = this._tooltipSeriesName(row); if (seriesName) items.push({ kind: "title", value: seriesName }); const wedge = this._namedWedge(row); @@ -469,10 +509,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 +582,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..bda6faae 100644 --- a/js/src/53_interaction.ts +++ b/js/src/53_interaction.ts @@ -469,9 +469,27 @@ 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. 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 && g.tier !== "density" && g._cpu && - g._cpu.x && g._cpu.y && Math.min(g._cpu.x.length, g._cpu.y.length) > 0); + (markOf(g.trace.kind).pointPick || markOf(g.trace.kind).stageNav) && + 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) { @@ -538,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 @@ -552,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", @@ -1011,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. @@ -1051,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 b3c3e154..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. @@ -194,6 +194,38 @@ 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); + // 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; + }, + }, hexbin: { build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), draw: (view, g) => { diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index 05772054..04bb6493 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,60 @@ 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; + // 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); + 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/__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..3bcebe0f 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 @@ -828,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. @@ -1741,6 +1773,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..d1b7bb44 --- /dev/null +++ b/python/xy/_funnel.py @@ -0,0 +1,424 @@ +"""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 _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. 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 = _ratio(value, first) + if index == 0: + conversion: Optional[float] = None + dropoff: Optional[float] = None + elif prior is not None: + conversion = _ratio(value, prior) + dropoff = None if conversion is None else 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 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 + 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. + """ + 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 + 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) + # 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" + elif outside_ok: + # 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( + 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) + 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, + kind="dropoff", + text=text, + pos=boundary, + cross=edge, + anchor="start", + placement=placement, + ) + ) + return labels 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 9448c189..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) @@ -871,6 +872,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..65898e3d 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) @@ -2016,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[:] = ( @@ -2433,6 +2455,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..df73fe55 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -74,6 +74,35 @@ 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) 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)] + 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..d494109b 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,6 +4768,11 @@ def point(x: float, y: float) -> tuple[float, float]: for ann in annotations: style = ann.get("style") or {} + # 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)))) @@ -4916,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) ) @@ -5586,6 +5614,89 @@ 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])) + # 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'') + 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..ace900d1 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,128 @@ 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``, ``name``, + ``show_dropoff``, …) are forwarded there, and everything else (``width``, + ``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", + "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) + 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)) + 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 + # 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..e3eba394 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -676,6 +676,364 @@ 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. 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, + [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)) + 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 + missing: list[str] = [] + 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 + 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)] + + 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) + 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. 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" + # 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) + 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), + "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), + } + 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( + 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 + # 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 + # 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, + spec.text, + dx=dx, + dy=dy, + anchor="middle" if inside else spec.anchor, + 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 + # 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, 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. + """ + from . import kernels + + 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 + 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..977d6856 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -1219,7 +1219,112 @@ 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 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; + // 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; + // (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}}`; // 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 +1529,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 +1712,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..7ec9f507 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,76 @@ 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` (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 +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. + +**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). + #### 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..df1a7764 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. | @@ -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 — 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/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4f9ccd41..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 @@ -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..4db02d84 100644 --- a/tests/test_accessibility_contract.py +++ b/tests/test_accessibility_contract.py @@ -63,10 +63,12 @@ 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()", - "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..eb329fde --- /dev/null +++ b/tests/test_funnel.py @@ -0,0 +1,1058 @@ +"""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%}") == "—" + + +# -- 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 + + +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 + + +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, 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, + ) + # 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 = fig.to_svg() + assert 'stroke="#ff0000"' in doc + assert 'stroke-width="1"' in doc + 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" + + +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 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..f5e12505 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -19,6 +19,7 @@ "scatter", "ribbon", "sankey", + "funnel", "line", "area", "histogram", @@ -84,6 +85,7 @@ "stem_chart", "segments_chart", "triangle_mesh_chart", + "funnel_chart", ) CHROME_FACTORIES = ( "legend", @@ -389,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