diff --git a/docs/app/scripts/check_html_routes.py b/docs/app/scripts/check_html_routes.py index cab1ab7f..45615f92 100644 --- a/docs/app/scripts/check_html_routes.py +++ b/docs/app/scripts/check_html_routes.py @@ -12,6 +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 = 29 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" @@ -80,8 +81,11 @@ def validate_inline_svg_gallery(page_route: str, module_path: Path) -> None: """Validate the code-native chart tiles in the compiled gallery route.""" source = module_path.read_text(encoding="utf-8") preview_count = source.count('viewBox=\\"0 0 320 232\\"') - if preview_count != 28: - msg = f"Inline SVG gallery has {preview_count} previews, expected 28: {page_route}" + if preview_count != INLINE_SVG_PREVIEW_COUNT: + msg = ( + f"Inline SVG gallery has {preview_count} previews, " + f"expected {INLINE_SVG_PREVIEW_COUNT}: {page_route}" + ) raise RuntimeError(msg) for marker in ("gallery-preview-surface", "aspect-[320/232]", "shadow-large"): if marker not in source: diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index f15c4b8c..5467acee 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -1206,18 +1206,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) == 14 + assert len(chart_section) == 15 assert "XYChart" not in rendered - assert rendered.count("dangerouslySetInnerHTML") == 28 + assert rendered.count("dangerouslySetInnerHTML") == 29 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") == 8 - assert rendered.count("aspect-[320/232]") == 28 - assert rendered.count("shadow-large") == 28 - assert rendered.count("transition-bg") == 28 + assert rendered.count("aspect-[320/232]") == 29 + assert rendered.count("shadow-large") == 29 + assert rendered.count("transition-bg") == 29 assert "--gallery-preview-surface: #fff" in rendered assert "--gallery-preview-fill: #efeaff" in rendered assert "--gallery-preview-soft: #dccfff" in rendered @@ -1228,7 +1228,7 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( assert "object-contain" not in rendered assert "object-center" not in rendered assert "xy-tailwind-bridge" not in rendered - assert rendered.count("size:14") == 28 + assert rendered.count("size:14") == 29 assert "size:6" not in rendered for chart_type in ( "Line", @@ -1249,6 +1249,7 @@ def test_chart_gallery_grid_renders_every_type_as_inline_svg( "Error Bar", "Stem", "Segments", + "Sankey", "Threshold", "Triangle Mesh", "Horizontal Line", @@ -1293,7 +1294,7 @@ def test_chart_gallery_inline_svgs_share_the_component_preview_style() -> None: for item in group.items } - assert len(previews) == 28 + assert len(previews) == 29 for svg in previews.values(): assert 'viewBox="0 0 320 232"' in svg assert ' No "/charts/contour-plot/": "xy.contour(", "/charts/stem-plot/": "xy.stem(", "/charts/segments/": "xy.segments(", + "/charts/sankey/": "xy.sankey_chart(", "/components/triangle-mesh/": "xy.triangle_mesh(", } for route, mark in standalone_chart_marks.items(): @@ -1408,7 +1410,7 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> 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) == 28 + assert len(titles) == 29 assert section_titles[:3] == [ "Line and Area", "Distributions", @@ -1430,6 +1432,7 @@ def test_chart_gallery_combines_only_the_requested_related_tiles() -> None: "Contour", "Error Band", "Error Bar", + "Sankey", "Facet Chart", "Layered Marks", } <= titles @@ -1517,17 +1520,17 @@ def test_inline_svg_gallery_validator_requires_every_styled_preview(tmp_path: Pa module_path = tmp_path / "route.jsx" preview = 'viewBox=\\"0 0 320 232\\"' module_path.write_text( - preview * 28 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 29 + "gallery-preview-surface aspect-[320/232] shadow-large", encoding="utf-8", ) check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) module_path.write_text( - preview * 27 + "gallery-preview-surface aspect-[320/232] shadow-large", + preview * 28 + "gallery-preview-surface aspect-[320/232] shadow-large", encoding="utf-8", ) - with pytest.raises(RuntimeError, match="27 previews, expected 28"): + with pytest.raises(RuntimeError, match="28 previews, expected 29"): check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path) @@ -1945,6 +1948,7 @@ def test_chart_gallery_pages_append_factory_api_tables() -> None: "/charts/uncertainty/": ("xy.error_band_chart", "xy.errorbar_chart"), "/charts/stem-plot/": ("xy.stem_chart",), "/charts/segments/": ("xy.segments_chart",), + "/charts/sankey/": ("xy.sankey_chart",), "/components/triangle-mesh/": ("xy.triangle_mesh_chart",), "/components/facets-and-layers/": ("xy.chart", "xy.facet_chart"), } diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 079366e4..f61e95fa 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -51,7 +51,12 @@ ("Uncertainty", (xy.error_band_chart, xy.errorbar_chart)), ( "Specialized", - (xy.stem_chart, xy.segments_chart, xy.triangle_mesh_chart), + ( + xy.stem_chart, + xy.segments_chart, + xy.sankey_chart, + xy.triangle_mesh_chart, + ), ), ("Annotations", (xy.chart,)), ("Facets and Layers", (xy.chart, xy.facet_chart)), @@ -79,6 +84,8 @@ xy.stairs, xy.stem, xy.segments, + xy.ribbon, + xy.sankey, xy.triangle_mesh, ) diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 041d0429..fb038777 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -70,6 +70,7 @@ ("Uncertainty", "/charts/uncertainty/"), ("Stem", "/charts/stem-plot/"), ("Segments", "/charts/segments/"), + ("Sankey", "/charts/sankey/"), ), ), ( diff --git a/docs/app/xy_docs/gallery.py b/docs/app/xy_docs/gallery.py index 7386346f..c67128c7 100644 --- a/docs/app/xy_docs/gallery.py +++ b/docs/app/xy_docs/gallery.py @@ -165,6 +165,7 @@ class GalleryGroup: ( GalleryItem("Stem", route="/charts/stem-plot/"), GalleryItem("Segments", route="/charts/segments/"), + GalleryItem("Sankey", route="/charts/sankey/"), GalleryItem("Triangle Mesh", route="/components/triangle-mesh/"), ), ), @@ -245,6 +246,9 @@ class GalleryGroup: """, "Segments": """ +""", + "Sankey": """ + """, "Triangle Mesh": """ diff --git a/docs/charts/sankey.md b/docs/charts/sankey.md new file mode 100644 index 00000000..55383dad --- /dev/null +++ b/docs/charts/sankey.md @@ -0,0 +1,376 @@ +--- +title: Sankey Diagram in Python +description: Create interactive Sankey diagrams in Python with xy. Show weighted flows between stages with automatic layout, colored nodes, and gradient ribbons. +components: + - xy.sankey_chart +--- + +# Sankey Diagrams in Python + +A **Sankey diagram** shows how a quantity flows through a sequence of stages. +Node height represents total flow, and each connecting ribbon is proportional +to its value. Use one for budgets, energy transfer, conversion funnels, supply +chains, or any directed flow where the size of each path matters. + +With `xy`, pass `(source, target, value)` triples to `sankey_chart`. XY assigns +layers, minimizes crossings, sizes the nodes, stacks the ribbon endpoints, and +uses each link's source and target colors to paint its gradient. + +Jump to [the basic chart](#create-a-sankey-diagram), +[a dense energy network](#trace-a-dense-energy-network), +[sink alignment](#compare-sink-alignment), or +[custom ribbon styling](#style-the-ribbon-layer). + +## Create a Sankey Diagram + +This example follows an investment inflow through allocations and outcomes: + +~~~python demo exec +import reflex_xy +import xy + +investment_flows = [ + ("Inflow", "Equities", 78_000), + ("Inflow", "Bonds", 46_000), + ("Inflow", "Cash", 24_000), + ("Equities", "Growth", 61_000), + ("Equities", "Income", 17_000), + ("Bonds", "Income", 28_000), + ("Bonds", "Reserve", 18_000), + ("Cash", "Reserve", 24_000), +] + +investment_sankey = xy.sankey_chart( + investment_flows, + colors=[ + "#6e56cf", + "#3b82f6", + "#0ea5e9", + "#14b8a6", + "#8b5cf6", + "#f59e0b", + "#64748b", + ], + link_opacity=0.48, + node_width=0.025, + node_padding=0.035, + title="Investment allocation", +) + + +def sankey_chart_demo(): + return reflex_xy.chart(investment_sankey, height="440px") +~~~ + +Node names default to their first-appearance order in `links`. When you need +stable ordering independent of the input rows, pass every name through +`nodes=`: + +~~~python +chart = xy.sankey_chart( + links, + nodes=["Inflow", "Equities", "Bonds", "Cash", "Growth", "Income", "Reserve"], +) +~~~ + +The `colors` sequence follows that same node order and must contain exactly one +CSS color per node. + +## Trace a Dense Energy Network + +Sankey diagrams are most useful when several branches split and rejoin. This +energy balance uses four stages, a compact `node_padding`, and extra +crossing-minimization `iterations`. Every ribbon blends from its source node +color to its target node color: + +~~~python demo exec +import reflex_xy +import xy + +energy_flows = [ + ("Grid supply", "Homes", 48), + ("Grid supply", "Industry", 36), + ("Grid supply", "Transport", 16), + ("Homes", "Heating", 24), + ("Homes", "Appliances", 16), + ("Homes", "Lighting", 8), + ("Industry", "Processes", 25), + ("Industry", "Motors", 11), + ("Transport", "EV charging", 16), + ("Heating", "Useful energy", 18), + ("Heating", "Losses", 6), + ("Appliances", "Useful energy", 13), + ("Appliances", "Losses", 3), + ("Lighting", "Useful energy", 7), + ("Lighting", "Losses", 1), + ("Processes", "Useful energy", 20), + ("Processes", "Losses", 5), + ("Motors", "Useful energy", 9), + ("Motors", "Losses", 2), + ("EV charging", "Useful energy", 13), + ("EV charging", "Losses", 3), +] + +energy_nodes = [ + "Grid supply", + "Homes", + "Industry", + "Transport", + "Heating", + "Appliances", + "Lighting", + "Processes", + "Motors", + "EV charging", + "Useful energy", + "Losses", +] + +energy_sankey = xy.sankey_chart( + energy_flows, + nodes=energy_nodes, + colors=[ + "#6366f1", + "#8b5cf6", + "#3b82f6", + "#06b6d4", + "#f97316", + "#eab308", + "#facc15", + "#ec4899", + "#14b8a6", + "#0ea5e9", + "#22c55e", + "#ef4444", + ], + node_width=0.018, + node_padding=0.012, + iterations=14, + link_opacity=0.58, + label_size=11, + title="Electricity from supply to outcome", +) + + +def energy_sankey_demo(): + return reflex_xy.chart(energy_sankey, height="500px") +~~~ + +Use an explicit `nodes` list when color meaning must remain stable even if the +input rows are reordered. More `iterations` can improve a busy layout, though +the crossing-minimization algorithm is heuristic rather than guaranteed to +find the mathematical optimum. + +## Compare Sink Alignment + +A sink is a node with no outgoing link. With `align="left"`, an early sink +stays in the first layer where the graph places it. The default +`align="justify"` moves every sink to the final layer, producing a flush right +edge. Compare the position of **Direct purchase**: + +~~~python demo exec +import reflex as rx +import reflex_xy +import xy + +conversion_flows = [ + ("Visitors", "Browse", 820), + ("Visitors", "Direct purchase", 180), + ("Browse", "Cart", 420), + ("Browse", "Leave", 400), + ("Cart", "Purchased", 260), + ("Cart", "Abandoned", 160), +] + +conversion_colors = [ + "#6366f1", + "#8b5cf6", + "#22c55e", + "#f59e0b", + "#16a34a", + "#ef4444", + "#fb7185", +] + +left_aligned_sankey = xy.sankey_chart( + conversion_flows, + colors=conversion_colors, + align="left", + node_padding=0.035, + title="Natural graph layers", +) + +justified_sankey = xy.sankey_chart( + conversion_flows, + colors=conversion_colors, + align="justify", + node_padding=0.035, + title="Sinks justified right", +) + + +def sankey_alignment_demo(): + return rx.grid( + reflex_xy.chart(left_aligned_sankey, height="320px"), + reflex_xy.chart(justified_sankey, height="320px"), + columns="2", + gap="1rem", + width="100%", + ) +~~~ + +For a single-column mobile layout, place the two chart components in an +`rx.vstack` or make the grid's `columns` prop responsive in your Reflex app. + +## Style the Ribbon Layer + +Use the `xy.sankey` mark directly when you want mark-level styling. The `style` +mapping below adds a subtle purple outline, while high-opacity links preserve +the intended lavender-to-violet gradient without competing with the solid node +bars. Composing the mark yourself also means supplying the hidden unit-box axes +that `sankey_chart` normally adds for you: + +~~~python demo exec +import reflex_xy +import xy + +material_flows = [ + ("Virgin material", "Manufacturing", 72), + ("Recovered material", "Manufacturing", 22), + ("Recovered material", "Residual", 6), + ("Manufacturing", "Product", 82), + ("Manufacturing", "Process waste", 12), +] + +material_sankey = xy.chart( + xy.sankey( + material_flows, + colors=[ + "#c4b5fd", # Virgin material — left + "#7c3aed", # Manufacturing — highlighted middle hub + "#a78bfa", # Recovered material — left + "#3b0764", # Residual — right + "#4c1d95", # Product — right + "#5b21b6", # Process waste — right + ], + node_width=0.05, + node_padding=0.05, + link_opacity=0.82, + label_size=13, + style={ + "stroke": "#6d28d9", + "stroke-width": 0.7, + "stroke-opacity": 0.35, + }, + ), + xy.x_axis(domain=(-0.09, 1.09), show=False), + xy.y_axis(domain=(-0.05, 1.05), reverse=True, show=False), + title="Material recovery", + styles={ + "annotation_label": { + "color": "#f8fafc", + "font-weight": 600, + "text-shadow": "0 1px 3px #0f172a, 0 0 4px #0f172a", + }, + }, +) + + +def styled_sankey_demo(): + return reflex_xy.chart(material_sankey, height="400px") +~~~ + +The mark's `style` mapping applies to links, while `colors` controls nodes and +the two ends of each link gradient. Here, pale lavender sources transition +through a saturated purple process hub to deep-violet outcomes, reinforcing +the left-to-right flow. Wider, fully opaque node bars sit above slightly dimmed +ribbons, so every stage remains distinct instead of blending into its attached +flows. Sankey names use the chart's `annotation_label` style slot; a light +color and dark shadow keep labels readable across changing ribbon colors. Set +`labels=False` for a compact, label-free diagram, or increase `label_size` when +the chart has room for larger text. + +## Layout and Flow Rules + +Sankey links form a directed acyclic graph: every path moves from an earlier +stage to a later stage. XY rejects cycles and names the nodes involved instead +of drawing a misleading backward flow. + +Each `(source, target)` pair must appear once. Aggregate repeated pairs before +passing them to the chart, and use finite, non-negative values. Node height is +the larger of total inflow and total outflow, so a node remains large enough +for every ribbon attached to it. + +`align="justify"` places terminal nodes on the final layer. The other supported +alignments are `"left"`, `"right"`, and `"center"`: left keeps each node in the +earliest layer its links allow, right hangs each node by its distance to a +sink, and center moves nodes without incoming links next to their first +target. Alternating barycenter sweeps reduce crossings; increase `iterations` +for a denser graph when the extra layout work improves the result. + +## Sankey Options + +| Option | Purpose | +| --- | --- | +| `links` | `(source, target, value)` triples describing each flow. | +| `nodes` | Explicit node order; defaults to first appearance in `links`. | +| `colors` | One CSS color per node, following node order. | +| `node_width` | Node width as a fraction of the diagram, between 0 and 1. | +| `node_padding` | Vertical gap between nodes in a layer, between 0 and 1. | +| `align` | Node alignment: `"justify"`, `"left"`, `"right"`, or `"center"`. | +| `iterations` | Number of crossing-minimization sweeps. | +| `link_opacity` | Ribbon opacity in the interval `(0, 1]`. | +| `labels` | Whether node names are drawn beside their nodes. | +| `label_size` | Node-label font size in pixels. | + +Chart options such as `width`, `height`, `title`, and theme settings are passed +to `sankey_chart` alongside the Sankey options. + +## Interaction and Export + +The browser resolves ribbon hover by testing the pointer against the same +curved band geometry used for rendering. Link tooltips show +**source → target** with the flow value; node tooltips show the node name and +its total flow. Sankey diagrams also export through the standard chart methods: + +~~~python +investment_sankey.to_html("allocation.html") +investment_sankey.to_png("allocation.png") +investment_sankey.to_svg("allocation.svg") +investment_sankey.to_pdf("allocation.pdf") +~~~ + +GPU picking, ribbon hover highlighting, automatic legend swatches for +two-color ribbons, cycle breaking, and Sankey-specific level of detail are not +implemented yet. + +## Related Charts + +- [Bar and column charts](/docs/xy/charts/bar-chart/) compare independent + category totals without encoding a flow between them. +- [Segments](/docs/xy/charts/segments/) draw independent point-to-point + connections when band width does not carry a value. +- [Annotations](/docs/xy/components/annotations/) add explanatory labels, + callouts, and thresholds around a flow diagram. + +## FAQ + +### How do I create a Sankey diagram in Python? + +Pass `(source, target, value)` triples to `xy.sankey_chart(...)`. XY computes +the node layers, sizes, ordering, and ribbon endpoints automatically. + +### Can a Sankey diagram contain cycles? + +No. A Sankey flows from earlier stages to later stages. XY raises an error that +names the nodes in a cycle so you can break or aggregate that loop explicitly. + +### How do I control Sankey colors? + +Pass one CSS color per node through `colors=`. The source and target colors are +interpolated across each ribbon to make its direction readable. + +### How do I move terminal nodes to the right edge? + +Use the default `align="justify"`. Choose `"left"`, `"right"`, or `"center"` +when a different layer alignment better matches the story in your data. diff --git a/docs/components/marks.md b/docs/components/marks.md index 11842006..1312cbda 100644 --- a/docs/components/marks.md +++ b/docs/components/marks.md @@ -20,6 +20,8 @@ components: - xy.stairs - xy.stem - xy.segments + - xy.ribbon + - xy.sankey - xy.triangle_mesh --- @@ -80,6 +82,7 @@ built. | Density and grids | `hexbin`, `heatmap`, `contour` | | Uncertainty | `errorbar`, `error_band` | | Explicit geometry | `stem`, `segments`, `triangle_mesh` | +| Directed flows | `ribbon`, `sankey` | 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 e2be6f31..8c5e44ad 100644 --- a/docs/overview/gallery.md +++ b/docs/overview/gallery.md @@ -40,7 +40,8 @@ Looking for a specific family? [hexbin](/docs/xy/charts/hexbin/), and [contour](/docs/xy/charts/contour-plot/) - [Uncertainty](/docs/xy/charts/uncertainty/) for error bars and bands - Specialized: [stem](/docs/xy/charts/stem-plot/), - [segments](/docs/xy/charts/segments/), and + [segments](/docs/xy/charts/segments/), + [Sankey](/docs/xy/charts/sankey/), 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 7c0b6291..69529d16 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. -- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **10** mark style properties across **21** mark kinds, drawn by all three renderers. - **29** 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`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `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 | | `fill` | svg | `area`, `bar`, `box`, `column`, `error_band`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `heatmap`, `hexbin`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | 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 | | `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 d473ea71..a0f1e99e 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -23,6 +23,7 @@ renderer cannot silently ignore a declaration that another honors. | `violin` | `fill`, `fill-opacity`, `opacity` | | `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 | 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 443ec35c..ba844334 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -36,7 +36,10 @@ // v10: `title_options` carries independent left/center/right title artists, // including axes-fraction y and pixel padding. A v9 client would silently omit // non-center slots and their placement, so v10 rejects it before rendering. -export const PROTOCOL = 10; +// v11: the `ribbon` trace kind (flow bands; Sankey). markOf() falls back to +// scatter for unknown kinds, so a v10 client would render ribbons as a point +// cloud with no error; it must reject the payload instead. +export const PROTOCOL = 11; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index b048cf43..8530124d 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -36,6 +36,10 @@ export const ATTR_SLOTS = { // a_sval/a_sel — none ever co-resident with a_prev* in the same program. a_prevx: 4, a_prevy: 5, a_prevx1: 7, a_prevy1: 8, a_rgba: 12, a_style: 13, a_stroke: 14, a_radius: 15, + // Ribbon target-end colour. Aliases a_style's slot: the ribbon program uses + // neither the style nor the stroke channel families, so the slot is free + // there, and no other program declares a_rgba2. + a_rgba2: 13, }; export function makeProgram(gl, vs, fs) { @@ -650,6 +654,105 @@ void main() { // Filled triangle meshes: one instance per triangle, with optional scalar LUT // color and antialiased barycentric edge strokes. +// Segments per ribbon edge. Must match _scene.RIBBON_STEPS: the raster +// exporter flattens the same cubic at the same count, so the live chart and a +// PNG disagree by strictly less than one segment. 96 keeps the chord error +// below a visible pixel on wide, high-contrast Sankey diagrams while remaining +// cheap for the tens of links a flow diagram normally contains. +export const RIBBON_STEPS = 96; + +// Flow band between two vertical spans (the ribbon geometry contract in +// spec/api/chart-kind-contract.md). One instance per band, swept as a +// triangle strip of 2*(RIBBON_STEPS+1) vertices; both edges are the +// curveBumpX cubic — control points at the horizontal midpoint, each holding +// its own end's y — evaluated in clip space. The contract makes the cubic +// normative in axis-transformed space, and clip space is an affine image of +// it, so this sweep, the SVG exporter's pixel-space `C`, and the raster's +// transformed-endpoint flattening are the same curve on every axis type +// (cubics are affine-invariant). The six mesh attribute slots are reused +// with the ribbon column meaning: ay0/ay1 = source span, ax2/ay2 = target +// span. +export const RIBBON_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; in vec4 a_rgba2; +uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec2 u_x0meta; uniform vec2 u_x1meta; +uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_t0meta; uniform vec2 u_t1meta; +uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; +uniform int u_segments; +out vec4 v_rgba; +flat out vec4 v_rgba0; +out float v_side; +out float v_t; +${AXIS_GLSL} +void main() { + float X0 = xyMap(ax0, u_xmap, u_x0meta, u_xmode, u_xconstant); + float X1 = xyMap(ax1, u_xmap, u_x1meta, u_xmode, u_xconstant); + float SLO = xyMap(ay0, u_ymap, u_y0meta, u_ymode, u_yconstant); + float SHI = xyMap(ay1, u_ymap, u_y1meta, u_ymode, u_yconstant); + float TLO = xyMap(ax2, u_ymap, u_t0meta, u_ymode, u_yconstant); + float THI = xyMap(ay2, u_ymap, u_t1meta, u_ymode, u_yconstant); + float t = floor(float(gl_VertexID) * 0.5) / float(max(u_segments, 1)); + float side = float(gl_VertexID & 1); + float u = 1.0 - t; + float b0 = u * u * u; + float b1 = 3.0 * u * u * t; + float b2 = 3.0 * u * t * t; + float b3 = t * t * t; + float xm = (X0 + X1) * 0.5; + float x = b0 * X0 + (b1 + b2) * xm + b3 * X1; + float lo = (b0 + b1) * SLO + (b2 + b3) * TLO; + float hi = (b0 + b1) * SHI + (b2 + b3) * THI; + gl_Position = vec4(x, mix(lo, hi, side), 0.0, 1.0); + // The gradient runs along the flow: each fragment mixes the two end colours + // by its own progress across the band. + v_rgba = mix(a_rgba, a_rgba2, t); + // The SOURCE paint, un-interpolated: an outline with no explicit colour + // matches the band's own fill (the edgecolors="face" convention the point + // and rect programs already follow), and both exporters stroke each band in + // one flat colour, so the client must not ramp where they cannot. + v_rgba0 = a_rgba; + v_side = side; + v_t = t; +}`; + +export const RIBBON_FS = `#version 300 es +precision highp float; +uniform float u_opacity; +uniform vec4 u_stroke; uniform float u_strokeWidth; uniform float u_strokeOpacity; +// 0 = paint the outline with u_stroke; 1 = match the band's own fill. +uniform int u_strokeMode; +in vec4 v_rgba; +flat in vec4 v_rgba0; +in float v_side; +in float v_t; +out vec4 outColor; +void main() { + // Triangle edges are not multisampled reliably across browsers. Interpolate + // the band-side coordinate and use its screen-space derivative to soften the + // outermost pixel on both curved edges without changing the interior. + float w = max(fwidth(v_side), 1e-5); + float edge = min(v_side, 1.0 - v_side); + float coverage = smoothstep(0.0, w, edge); + float alpha = v_rgba.a * u_opacity * coverage; + vec4 premult = vec4(v_rgba.rgb * alpha, alpha); + if (u_strokeWidth > 0.0) { + // Inset border in device pixels (RECT_FS's SDF trick): edge/w is the + // distance to the nearer curved edge, and v_t's own derivative gives the + // distance to the two end faces — so the outline closes the band exactly + // as the exporters' closed path does, rather than leaving the ends bare. + vec4 strokeSrc = u_strokeMode == 1 ? v_rgba0 : u_stroke; + float strokeAlpha = strokeSrc.a * u_strokeOpacity * coverage; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + float face = min(v_t, 1.0 - v_t) / max(fwidth(v_t), 1e-9); + float d = min(edge / w, face); + float inner = smoothstep(u_strokeWidth - 0.75, u_strokeWidth + 0.75, d); + premult = mix(stroke, premult, inner); + } + if (premult.a <= 0.001) discard; + outColor = premult; +}`; + export const MESH_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float ax2; in float ay2; in float a_cval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index fda314be..311b38a9 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,7 +2,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, 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, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, RIBBON_FS, RIBBON_STEPS, RIBBON_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -3146,6 +3146,7 @@ export class ChartView { get lineProg() { return this._prog("line", LINE_VS, LINE_FS); } 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); } 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); } @@ -3831,6 +3832,148 @@ export class ChartView { g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; } + // Flow bands (ribbon geometry contract). The six geometry columns reuse the + // mesh attribute slots; the two paints ride a_rgba/a_rgba2. CPU copies are + // retained for hover: picking is deferred (the id pass is point-geometry + // only), so tooltips resolve by containment against the same cubic. + _buildRibbonMark(g, t, buffer) { + const names = [ + ["x0", "x0"], ["x1", "x1"], ["y0", "y0"], ["y1", "y1"], + ["t0", "target_y0"], ["t1", "target_y1"], + ]; + g._cpuRibbon = {}; + for (const [slot, key] of names) { + const values = this._columnView(buffer, this.spec.columns[t[key]]); + g[slot + "Meta"] = { ...this.spec.columns[t[key]] }; + g[slot + "Buf"] = this._upload(values); + g._cpuRibbon[slot] = values; + g.n = g.n === undefined ? values.length : Math.min(g.n, values.length); + } + g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]); + if (t.color && t.color.mode === "direct_rgba") { + g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); + } + if (t.color_target && t.color_target.mode === "direct_rgba") { + g.rgba2Buf = this._upload( + this._columnView(buffer, this.spec.columns[t.color_target.buf]), + ); + } + g.colorTarget = t.color_target + ? parseColor(this.root, t.color_target.color, g.color) + : null; + // Outline paint (ribbon geometry contract). An omitted stroke colour means + // "match the band's own fill" — resolving it to g.color here would paint + // every band of a per-band (direct_rgba) ribbon with the constant + // fallback, which is not a colour the trace uses anywhere. + const style = t.style || {}; + g.stroke = style.stroke ? parseColor(this.root, style.stroke, g.color) : null; + g.strokeWidth = Number(style.stroke_width) || 0; + g.tooltipRows = Array.isArray(t.tooltip_rows) ? t.tooltip_rows : null; + } + + _drawRibbons(g, xm, ym) { + if (g.n < 1) return; + const gl = this.gl; + const prog = this.ribbonProg; + gl.useProgram(prog); + const u = (n) => uniformOf(gl, prog, n); + gl.uniform2f(u("u_xmap"), xm[0], xm[1]); + gl.uniform2f(u("u_ymap"), ym[0], ym[1]); + this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); + this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); + this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); + this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); + this._setAxisUniforms(prog, "u_t0", g.t0Meta, g.yAxis); + this._setAxisUniforms(prog, "u_t1", g.t1Meta, g.yAxis); + // RIBBON_VS reads the SHARED mode/constant uniforms (the RECT_VS design); + // the per-column _setAxisUniforms calls above only cover the *meta pairs, + // so without these four writes log/symlog axes silently render as linear. + gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); + gl.uniform1f(u("u_xconstant"), this._axisConstant(g.xAxis)); + 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 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)); + // 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 + // with a colour from a different band. + const rgba2Buf = g.rgba2Buf || g.rgbaBuf; + const parts = ["x0", "x1", "y0", "y1", "t0", "t1"].map((name) => g[name + "Buf"]._fcId); + parts.push(g.rgbaBuf ? g.rgbaBuf._fcId : 0, rgba2Buf ? rgba2Buf._fcId : 0); + this._bindVao(g, "ribbon", 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.t0Buf, 0, 1); + this._vaoAttr(ATTR_SLOTS.ay2, g.t1Buf, 0, 1); + if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + if (rgba2Buf) this._vaoAttr(ATTR_SLOTS.a_rgba2, rgba2Buf, 0, 1, 4, true); + }); + if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, ...g.color); + if (!rgba2Buf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba2, ...(g.colorTarget || g.color)); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 2 * (RIBBON_STEPS + 1), g.n); + } + + // Containment test against the same cubic the shader sweeps: solve the + // monotone bump curve for the cursor's x by bisection, then compare y with + // the band's two edges at that parameter. + _ribbonHover(g, dataX, dataY) { + const cpu = g._cpuRibbon; + if (!cpu) return null; + // The cubic is normative in axis-transformed space (ribbon geometry + // contract), so the pointer and every decoded endpoint go through the + // same transform the shader's xyMap applies — solving in raw data space + // would hit-test a curve the band does not follow on log/symlog axes. + // On linear axes the transform is the identity. A masked-log NaN endpoint + // fails every comparison and skips the band, matching the renderers. + const xAxis = { ...this._axis(g.xAxis), constant: this._axisConstant(g.xAxis) }; + const yAxis = { ...this._axis(g.yAxis), constant: this._axisConstant(g.yAxis) }; + const pointerX = this._axisCoord(xAxis, dataX); + const pointerY = this._axisCoord(yAxis, dataY); + // _decodeValue already returns data space; decoding twice put every + // containment test in a coordinate system nothing else uses. + const val = (slot, index) => this._decodeValue(cpu[slot], g[slot + "Meta"], index); + const xVal = (slot, index) => this._axisCoord(xAxis, val(slot, index)); + const yVal = (slot, index) => this._axisCoord(yAxis, val(slot, index)); + for (let index = 0; index < g.n; index++) { + const x0 = xVal("x0", index); + const x1 = xVal("x1", index); + const lo0 = Math.min(x0, x1); + const hi0 = Math.max(x0, x1); + if (!(pointerX >= lo0 && pointerX <= hi0) || hi0 === lo0) continue; + // x(t) is monotone between the faces (control points at the midpoint), + // so 24 bisection steps pin t to ~1e-7 of the span. + let a = 0.0; + let b = 1.0; + const xm = (x0 + x1) / 2; + const xAt = (t) => { + const uu = 1 - t; + return uu * uu * uu * x0 + 3 * uu * uu * t * xm + 3 * uu * t * t * xm + t * t * t * x1; + }; + const rising = x1 >= x0; + for (let step = 0; step < 24; step++) { + const mid = (a + b) / 2; + if ((xAt(mid) < pointerX) === rising) a = mid; else b = mid; + } + const t = (a + b) / 2; + const w0 = (1 - t) ** 3 + 3 * (1 - t) ** 2 * t; + const w1 = 1 - w0; + const edgeLo = w0 * yVal("y0", index) + w1 * yVal("t0", index); + const edgeHi = w0 * yVal("y1", index) + w1 * yVal("t1", index); + if (pointerY >= Math.min(edgeLo, edgeHi) && pointerY <= Math.max(edgeLo, edgeHi)) { + return { trace: g.trace.id, index, 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]]); @@ -6293,6 +6436,11 @@ export class ChartView { if (hit) return hit; continue; } + if (g._cpuRibbon) { + const hit = this._ribbonHover(g, dataX, dataY); + if (hit) return hit; + continue; + } if (g._cpuRect) { const hit = this._rectHover(g, dataX, dataY); if (hit) return hit; @@ -6453,8 +6601,15 @@ export class ChartView { const id = hit.trace * 1e9 + hit.index; this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY }; if (id === this._hoverId) { - // Anchored tooltips do not need a per-pointermove DOM rebuild. - if (!this._tooltipAnchor) this._renderTooltip(this._lastRow, e.clientX, e.clientY); + // 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) { + this._setTooltipAnchor(hit, this._lastRow, e.clientX, e.clientY); + this._repositionTooltip(); + } else if (!this._tooltipAnchor) { + this._renderTooltip(this._lastRow, e.clientX, e.clientY); + } return; } this._hoverId = id; @@ -6610,6 +6765,7 @@ export class ChartView { this._deleteBuffers(g, [ "xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "baseBuf", "x0Buf", "x1Buf", "x2Buf", "y0Buf", "y1Buf", "y2Buf", + "t0Buf", "t1Buf", "rgbaBuf", "rgba2Buf", "styleBuf", "strokeBuf", "posBuf", "value1Buf", "value0Buf", "_transitionPrevXBuf", "_transitionPrevYBuf", "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf", diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 31a911f1..3b3e6ab8 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -61,6 +61,9 @@ 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)) { + const semantic = g.tooltipRows[hit.index]; + if (semantic && typeof semantic === "object") Object.assign(row, semantic); } else if (g._cpuRect) { const r = g._cpuRect; const x0 = this._decodeValue(r.x0, r.x0Meta, hit.index); @@ -194,6 +197,28 @@ Object.assign(ChartView.prototype, { _defaultTooltipItems(row, labels = {}, aliases = {}) { const items = []; + if (row.source !== undefined && row.target !== undefined) { + items.push({ kind: "title", value: `${String(row.source)} → ${String(row.target)}` }); + if (row.value !== undefined) { + items.push({ + kind: "field", + label: typeof (labels as any).value === "string" ? (labels as any).value : "Flow", + value: fmtValue(row.value, row.value_kind), + }); + } + return items; + } + if (row.node !== undefined) { + items.push({ kind: "title", value: String(row.node) }); + if (row.value !== undefined) { + items.push({ + kind: "field", + label: typeof (labels as any).value === "string" ? (labels as any).value : "Total flow", + value: fmtValue(row.value, row.value_kind), + }); + } + return items; + } if (row.x !== undefined) { const { label } = this._defaultTooltipLabel("x", "x", labels, aliases); items.push({ kind: "field", label, value: fmtValue(row.x, row.x_kind) }); @@ -308,8 +333,10 @@ Object.assign(ChartView.prototype, { const yAxis = g.yAxis || "y"; let x = row.x; let y = row.y; - if (!Number.isFinite(x) || !Number.isFinite(y)) { - // Category rows carry labels, so derive their numeric anchor from the pick. + 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. const rect = this.canvas.getBoundingClientRect(); [x, y] = this._dataFromCanvas(clientX - rect.left, clientY - rect.top, xAxis, yAxis); } diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index f52f0bc5..b3c3e154 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -166,6 +166,33 @@ export const MARK_KINDS = { contour: SEGMENT_MARK, segments: SEGMENT_MARK, triangle_mesh: MESH_MARK, + ribbon: { + build: (view, g, t, buffer) => view._buildRibbonMark(g, t, buffer), + draw: (view, g) => { + const [x0, x1] = view._axisRange(g.xAxis); + const [y0, y1] = view._axisRange(g.yAxis); + view._drawRibbons( + 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 ribbon are the target span's y values — garbage ids. Hover works + // through the CPU containment path (_ribbonHover); selection is absent + // rather than wrong, per the ribbon geometry contract. + retainCpu: true, + refreshColor: (view, g) => { + if (g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); + // Both ends of the gradient and the outline are theme-resolved CSS, so + // all three go stale together on a light/dark flip, not just the source. + if (g.trace.color_target) { + g.colorTarget = parseColor(view.root, g.trace.color_target.color, g.color); + } + const style = g.trace.style || {}; + if (style.stroke) g.stroke = parseColor(view.root, style.stroke, g.color); + }, + }, error_band: AREA_MARK, hexbin: { build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), diff --git a/js/src/60_entries.ts b/js/src/60_entries.ts index 9248747a..0c780c99 100644 --- a/js/src/60_entries.ts +++ b/js/src/60_entries.ts @@ -70,6 +70,12 @@ export function renderStandalone(el, spec, arrayBuffer) { const column = (idx) => view._columnView(buffer, spec.columns[idx]); for (const g of view.gpuTraces) { if (markOf(g.trace.kind).retainCpu && g.tier !== "density") { + // Ribbon build retains its six specialized geometry columns directly in + // `_cpuRibbon`; it has no generic trace.x/trace.y wire fields. Trying to + // load those nonexistent fields aborts standalone hydration after the + // first paint and leaves hover without a usable ChartView. + if (g._cpuRibbon) continue; + if (!Number.isInteger(g.trace.x) || !Number.isInteger(g.trace.y)) continue; g._cpu = { x: column(g.trace.x), y: column(g.trace.y), diff --git a/python/xy/__init__.py b/python/xy/__init__.py index de2b35cf..ffc149cc 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -91,6 +91,9 @@ "line_chart": ".components", "marker": ".components", "modebar": ".components", + "ribbon": ".components", + "sankey": ".components", + "sankey_chart": ".components", "scatter": ".components", "scatter_chart": ".components", "segments": ".components", @@ -185,6 +188,9 @@ "modebar", "register_mark", "registered_marks", + "ribbon", + "sankey", + "sankey_chart", "scatter", "scatter_chart", "segments", @@ -312,6 +318,9 @@ def __dir__() -> list[str]: line_chart, marker, modebar, + ribbon, + sankey, + sankey_chart, scatter, scatter_chart, stairs, diff --git a/python/xy/_figure.py b/python/xy/_figure.py index f764f751..25e85b21 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -533,6 +533,8 @@ def _rollback(self, checkpoint: _FigureCheckpoint) -> None: stairs = _marks.stairs stem = _marks.stem segments = _marks.segments + ribbon = _marks.ribbon + sankey = _marks.sankey triangle_mesh = _marks.triangle_mesh bar = _marks.bar column = _marks.column @@ -1374,6 +1376,10 @@ def _range_columns(self, t: Trace, axis_id: str) -> list[Column]: and t.y1 is not None ): return [t.x0, t.x1, t.x] if axis == "x" else [t.y0, t.y1, t.y] + if t.kind == "ribbon": + # x is just the two faces; y needs all four span edges, two of + # which ride in the `x`/`y` slots (ribbon geometry contract). + 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/_payload.py b/python/xy/_payload.py index 8f8fe0ff..f0770e14 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -797,6 +797,65 @@ def _emit_segments( raise ValueError("errorbar role-qualified animation key collision") return self._transition_entry(entry, t, pw, source_sel, key_values) + def _emit_ribbon( + self, t: Trace, pw: "_PayloadWriter", xr: tuple, yr: tuple, px_width: int + ) -> dict[str, Any]: + """Ship a flow band: two faces on x, four span edges on y, two paints. + + The six geometry slots are saturated (ribbon geometry contract), so the + target span's y values ride in the `x`/`y` slots and must be shipped on + the **y** scale, not the x one — the single easiest thing to get wrong + here, and it would place every ribbon's far end at the wrong height. + """ + 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("ribbon trace missing geometry columns") + columns = (t.x0, t.x1, t.y0, t.y1, t.x, t.y) + arrays = [column.values for column in columns] + candidates = [ + array for column, array in zip(columns, arrays, strict=True) if column.zone.null_count + ] + sel_arg = kernels.valid_indices_f64(tuple(candidates)) if candidates else None + if sel_arg is not None: + arrays = [array[sel_arg] for array in arrays] + x0v, x1v, slo, shi, tlo, thi = arrays + xs, ys = self._axis_scale(t.x_axis), self._axis_scale(t.y_axis) + entry = { + "id": t.id, + "kind": t.kind, + "name": t.name, + "style": self._default_styled(t), + # Always direct: a flow diagram is small-N by nature, and neither + # decimation nor a density tier means anything for a band (§28). + "tier": "direct", + "n_points": t.n_points, + "n_marks": int(len(x0v)), + "x_axis": t.x_axis, + "y_axis": t.y_axis, + "x0": pw.ship(x0v, t.x0, scale=xs), + "x1": pw.ship(x1v, t.x1, scale=xs), + "y0": pw.ship(slo, t.y0, scale=ys), + "y1": pw.ship(shi, t.y1, scale=ys), + "target_y0": pw.ship(tlo, t.x, scale=ys), + "target_y1": pw.ship(thi, t.y, scale=ys), + } + if t.color_ch is not None: + entry["color"], _size = self._ship_channels(t, sel_arg, pw.ship_scalar, pw.ship_u8) + if t.color2_ch is not None: + entry["color_target"] = channels.ship_color_channel( + t.color2_ch, sel_arg, pw.ship_scalar, pw.ship_u8 + ) + if t.tooltip_rows is not None: + if len(t.tooltip_rows) != t.n_points: + raise ValueError( + "ribbon tooltip rows must match ribbon geometry " + f"({len(t.tooltip_rows)} != {t.n_points})" + ) + indices = range(len(t.tooltip_rows)) if sel_arg is None else (int(i) for i in sel_arg) + entry["tooltip_rows"] = [dict(t.tooltip_rows[i]) for i in indices] + self._ship_trace_styles(entry, t, sel_arg, pw) + return self._transition_entry(entry, t, pw, sel_arg) + 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 42fa8302..f2e44d38 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -920,6 +920,10 @@ def render_raster( ) elif kind == "triangle_mesh": _emit_triangle_mesh(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. + _emit_ribbon(cmd, t, blob, cols, trace_sx, trace_sy, style, color) 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) @@ -2056,6 +2060,100 @@ def _emit_hexbin( cmd.triangles(x0, y0, x1, y1, x2, y2, fills, 0.0, (0, 0, 0, 0)) +def _emit_ribbon( + cmd: _Cmd, + t: dict[str, Any], + blob: bytes, + cols: list[dict[str, Any]], + sx: _Scale, + sy: _Scale, + style: dict[str, Any], + color: str, +) -> None: + """Flow bands, flattened, with the gradient running along the flow. + + Geometry comes from `_scene.ribbon_polygon` — the same reference the SVG + exporter's cubics and the golden test consume — so the two static outputs + cannot drift. The polygon is built from the **axis-mapped** endpoints, not + mapped after flattening: the ribbon cubic is normative in transformed space + (ribbon geometry contract), which is the only curve the SVG exporter's + exact pixel-space `C` and the client's clip-space sweep can both draw — + flattening in data space and mapping each vertex bows a different curve on + log/symlog axes. Under affine axes the two orders are the same curve. + `cmd.grad` takes an arbitrary two-point gradient vector, which is what lets + the ramp follow the flow rather than an axis. + """ + x0v = _column(blob, cols[t["x0"]]) + x1v = _column(blob, cols[t["x1"]]) + slo = _column(blob, cols[t["y0"]]) + shi = _column(blob, cols[t["y1"]]) + tlo = _column(blob, cols[t["target_y0"]]) + thi = _column(blob, cols[t["target_y1"]]) + n = len(x0v) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + source_rgba = _trace_paint_rgba(t, "color", n, color, read) + fills = np.rint( + _paint.effective_rgba(source_rgba, t, read, component="fill", default_opacity=1.0) * 255.0 + ).astype(np.uint8) + if t.get("color_target"): + target_rgba = _trace_paint_rgba(t, "color_target", n, color, read) + fills2 = np.rint( + _paint.effective_rgba(target_rgba, t, read, component="fill", default_opacity=1.0) + * 255.0 + ).astype(np.uint8) + else: + fills2 = fills + stroke_width = float(style.get("stroke_width", 0.0) or 0.0) + # Outline alpha folds opacity * stroke_opacity, the same stack every other + # stroked mark applies and the SVG writer's stroke-opacity mirrors. An + # omitted stroke colour matches each band's own fill (edgecolors="face"), + # resolved per band in the loop rather than once for the whole trace. + 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 + ) + edges = ( + np.rint( + np.column_stack([source_rgba[:, :3] * 255.0, source_rgba[:, 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): + px0, px1 = float(sx(x0v[i])), float(sx(x1v[i])) + py_slo, py_shi = float(sy(slo[i])), float(sy(shi[i])) + py_tlo, py_thi = float(sy(tlo[i])), float(sy(thi[i])) + if not all(math.isfinite(v) for v in (px0, px1, py_slo, py_shi, py_tlo, py_thi)): + continue + poly_data = _scene.ribbon_polygon(px0, px1, py_slo, py_shi, py_tlo, py_thi) + poly = list(zip(poly_data[:, 0].tolist(), poly_data[:, 1].tolist(), strict=True)) + # effective_rgba already folded the trace opacity into the alpha. + a = tuple(int(v) for v in fills[i]) + b = tuple(int(v) for v in fills2[i]) + # Flat only when all FOUR channels agree: ends differing in alpha + # alone still ramp, and cmd.grad's RGBA stops interpolate it. + if a == b: + cmd.fill(poly, a) + else: + # Gradient vector spans the two faces horizontally; the y term is + # irrelevant because the ramp is purely along the flow. + gy = float(poly_data[0, 1]) + cmd.grad(poly, (px0, gy), (px1, gy), [(0.0, a), (1.0, b)]) + 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_triangle_mesh( cmd: _Cmd, t: dict[str, Any], diff --git a/python/xy/_sankey.py b/python/xy/_sankey.py new file mode 100644 index 00000000..e5d7a0f6 --- /dev/null +++ b/python/xy/_sankey.py @@ -0,0 +1,448 @@ +"""Sankey layout — the part of a flow diagram that is not a rendering problem. + +A Sankey is not a new primitive so much as a *placement*: given nodes, weighted +links and a box, decide where every node rectangle and every ribbon endpoint +goes. That decision is what the roadmap means by "requires layout work" +(spec/api/chart-roadmap.md item 30), and it is pure arithmetic, so it lives here +in Python, testable on its own, exactly as `wind_rose`'s binning and +`radar_chart`'s angle derivation do. + +The output is in **data space**: x runs 0..1 across the layers and y runs 0..1 +down the diagram, so the caller maps it through ordinary axes and every +renderer inherits the placement unchanged. Nothing here knows about pixels. + +The algorithm is the conventional one (d3-sankey's shape, and ECharts' before +it), in five passes: + +1. **Layer assignment** — longest path from a source, so a node sits one layer + right of its deepest upstream neighbour. Sinks optionally re-align to the + last layer (`align="justify"`), which is what makes the right edge flush. +2. **Node value** — `max(sum of inflow, sum of outflow)`; a node that emits more + than it receives is still as tall as it emits. +3. **Crossing minimisation** — alternating left/right barycentre sweeps. This is + a heuristic: optimal crossing minimisation is NP-hard, and the median/ + barycentre sweep is the standard, cheap answer that gets most of the way. +4. **Vertical placement** — value-proportional heights, fixed padding between + nodes in a layer, then the whole column centred in the box. +5. **Endpoint stacking** — links leave a node ordered by where they arrive, and + arrive ordered by where they left, which is what stops a node's own ribbons + from crossing each other immediately. +""" + +from __future__ import annotations + +import math +from collections import Counter, deque +from dataclasses import dataclass, field +from typing import Any, Optional + +_ALIGNMENTS = frozenset({"justify", "left", "right", "center"}) + + +@dataclass +class SankeyNode: + """One placed node. `x0/x1/y0/y1` are in the 0..1 layout box.""" + + name: str + index: int + layer: int = 0 + order: int = 0 + value: float = 0.0 + x0: float = 0.0 + x1: float = 0.0 + y0: float = 0.0 + y1: float = 0.0 + incoming: list[int] = field(default_factory=list) + outgoing: list[int] = field(default_factory=list) + + +@dataclass +class SankeyLink: + """One placed link. The two endpoints are vertical spans, not points.""" + + source: int + target: int + value: float + index: int = 0 + source_y0: float = 0.0 + source_y1: float = 0.0 + target_y0: float = 0.0 + target_y1: float = 0.0 + label: Optional[str] = None + + +@dataclass +class SankeyLayout: + nodes: list[SankeyNode] + links: list[SankeyLink] + layers: int + + +def _resolve_nodes( + nodes: Optional[list[Any]], links: list[tuple[Any, Any, float]] +) -> tuple[list[str], dict[str, int]]: + """Node names in a stable order, plus their index. + + When `nodes` is omitted the order is first-appearance across the links, + which keeps a diagram's column order predictable from the source data + rather than from a set's iteration order. + """ + if nodes is not None: + names = [str(n) for n in nodes] + duplicates = sorted(name for name, count in Counter(names).items() if count > 1) + if duplicates: + raise ValueError(f"sankey nodes must be unique; repeated: {duplicates}") + else: + names = [] + seen: set[str] = set() + for source, target, _value in links: + for endpoint in (str(source), str(target)): + if endpoint not in seen: + seen.add(endpoint) + names.append(endpoint) + return names, {name: i for i, name in enumerate(names)} + + +def _cyclic_nodes(nodes: list[SankeyNode], links: list[SankeyLink]) -> list[str]: + """Names of the nodes that actually lie on a cycle. + + Tarjan's strongly connected components, iteratively: a node is cyclic iff + its component has more than one member (self-links are refused earlier). + Kahn's leftover set would also blame everything *downstream* of a cycle, + sending the user off to remove nodes that were never part of the problem. + Only runs to build the refusal message, so clarity beats constant factors. + """ + order = [-1] * len(nodes) + low = [0] * len(nodes) + on_stack = [False] * len(nodes) + stack: list[int] = [] + count = 0 + cyclic: list[str] = [] + for root in range(len(nodes)): + if order[root] != -1: + continue + work: list[tuple[int, int]] = [(root, 0)] + while work: + current, edge = work.pop() + if edge == 0: + order[current] = low[current] = count + count += 1 + stack.append(current) + on_stack[current] = True + descended = False + outgoing = nodes[current].outgoing + while edge < len(outgoing): + child = links[outgoing[edge]].target + edge += 1 + if order[child] == -1: + work.append((current, edge)) + work.append((child, 0)) + descended = True + break + if on_stack[child]: + low[current] = min(low[current], order[child]) + if descended: + continue + if low[current] == order[current]: + component: list[int] = [] + while True: + member = stack.pop() + on_stack[member] = False + component.append(member) + if member == current: + break + if len(component) > 1: + cyclic.extend(nodes[member].name for member in component) + if work: + parent = work[-1][0] + low[parent] = min(low[parent], low[current]) + return sorted(cyclic) + + +def _assign_layers(nodes: list[SankeyNode], links: list[SankeyLink]) -> int: + """Longest-path layering, with an explicit cycle refusal. + + Kahn's algorithm: repeatedly strip nodes with no unplaced predecessor. A + node left over when the queue empties is part of a cycle, which a Sankey + cannot express — flow would have to reach a column left of where it + started — so it is refused by name rather than drawn wrong. + """ + indegree = [0] * len(nodes) + for link in links: + indegree[link.target] += 1 + queue = deque(n.index for n in nodes if indegree[n.index] == 0) + placed = 0 + while queue: + current = queue.popleft() + placed += 1 + for link_index in nodes[current].outgoing: + link = links[link_index] + nodes[link.target].layer = max(nodes[link.target].layer, nodes[current].layer + 1) + indegree[link.target] -= 1 + if indegree[link.target] == 0: + queue.append(link.target) + if placed != len(nodes): + stuck = _cyclic_nodes(nodes, links) + raise ValueError( + f"sankey links form a cycle through {stuck}; a Sankey flows left to right, " + "so every link must point to a later stage. Break the cycle or aggregate " + "the nodes involved." + ) + return max((n.layer for n in nodes), default=0) + 1 + + +def _heights(nodes: list[SankeyNode], links: list[SankeyLink]) -> list[int]: + """Longest path from each node to a sink — `_assign_layers` mirrored. + + Runs after the cycle refusal, so the reverse Kahn sweep always drains. + """ + outdegree = [len(node.outgoing) for node in nodes] + height = [0] * len(nodes) + queue = deque(n.index for n in nodes if outdegree[n.index] == 0) + while queue: + current = queue.popleft() + for link_index in nodes[current].incoming: + link = links[link_index] + height[link.source] = max(height[link.source], height[current] + 1) + outdegree[link.source] -= 1 + if outdegree[link.source] == 0: + queue.append(link.source) + return height + + +def _align(nodes: list[SankeyNode], links: list[SankeyLink], layers: int, alignment: str) -> None: + """Re-layer nodes per the requested alignment (d3-sankey's four). + + `left` keeps the longest-path layering as assigned. `justify` pushes sinks + to the last layer for a flush right edge. `right` hangs every node by its + distance to a sink, so sources start late instead of early. `center` keeps + sinks in place but moves source-only nodes just left of their nearest + target, so an isolated late branch does not open at the far left. + """ + if alignment == "left" or layers < 2: + return + if alignment == "justify": + for node in nodes: + if not node.outgoing: + node.layer = layers - 1 + elif alignment == "right": + height = _heights(nodes, links) + for node in nodes: + node.layer = layers - 1 - height[node.index] + elif alignment == "center": + for node in nodes: + if not node.incoming and node.outgoing: + nearest = min(nodes[links[i].target].layer for i in node.outgoing) + node.layer = max(nearest - 1, 0) + + +def _order_layers( + nodes: list[SankeyNode], links: list[SankeyLink], layers: int, iterations: int +) -> list[list[int]]: + """Barycentre sweeps, alternating direction. + + Each pass re-sorts one layer by the mean position of its neighbours in the + already-ordered adjacent layer. Sweeping both ways matters: a left-to-right + pass only ever considers inflow, so it leaves the last layer ordered by + nothing at all. + """ + columns: list[list[int]] = [[] for _ in range(layers)] + for node in nodes: + columns[node.layer].append(node.index) + for column in columns: + column.sort() + + def positions() -> dict[int, float]: + return {index: rank for column in columns for rank, index in enumerate(column)} + + for sweep in range(iterations): + forward = sweep % 2 == 0 + order = range(1, layers) if forward else range(layers - 2, -1, -1) + pos = positions() + for layer in order: + neighbours = "incoming" if forward else "outgoing" + + # `pos` and `which` are bound as defaults, not captured: the sort + # runs inside the loop that rebinds them, and a late-binding + # closure would silently rank against the *next* pass's positions. + def barycentre( + index: int, which: str = neighbours, ranks: dict[int, float] = pos + ) -> float: + related = getattr(nodes[index], which) + if not related: + # No neighbour to follow: hold the current rank so an + # unconnected node does not jump to the top every sweep. + return float(ranks.get(index, 0)) + other = [ + ranks.get(links[i].source if which == "incoming" else links[i].target, 0) + for i in related + ] + return sum(other) / len(other) + + columns[layer].sort(key=barycentre) + pos = positions() + for column in columns: + for rank, index in enumerate(column): + nodes[index].order = rank + return columns + + +def _place( + nodes: list[SankeyNode], + columns: list[list[int]], + layers: int, + node_width: float, + node_padding: float, +) -> None: + """Value-proportional heights in a 0..1 box, each column centred. + + The scale is shared across columns — the tallest column sets it — so a + node's height means the same thing everywhere in the diagram. That is the + whole point of a Sankey: area is comparable. + """ + spans = [] + for layer, column in enumerate(columns): + if not column: + continue + room = 1.0 - node_padding * (len(column) - 1) + if room <= 0.0: + # A negative room would flip the shared scale and draw every span + # inverted; zero room collapses the whole diagram to nothing. + # Refused by name rather than drawn wrong (§28). + raise ValueError( + f"sankey node_padding {node_padding:g} leaves no room for nodes: " + f"layer {layer} holds {len(column)} of them, so node_padding must " + f"stay below {1.0 / (len(column) - 1):g}" + ) + total = sum(nodes[i].value for i in column) + spans.append((total, len(column))) + if not spans: + return + usable = [1.0 - node_padding * (count - 1) for _total, count in spans] + scale = min( + (room / total if total > 0 else math.inf) + for room, (total, _c) in zip(usable, spans, strict=True) + ) + if not math.isfinite(scale): + scale = 0.0 + + step = 1.0 if layers <= 1 else (1.0 - node_width) / (layers - 1) + for layer, column in enumerate(columns): + if not column: + continue + heights = [nodes[i].value * scale for i in column] + extent = sum(heights) + node_padding * (len(column) - 1) + cursor = (1.0 - extent) / 2.0 + for index, height in zip(column, heights, strict=True): + node = nodes[index] + node.x0 = layer * step + node.x1 = node.x0 + node_width + node.y0 = cursor + node.y1 = cursor + height + cursor = node.y1 + node_padding + + +def _stack_endpoints(nodes: list[SankeyNode], links: list[SankeyLink]) -> None: + """Give every link its two vertical spans. + + Outgoing links are stacked in order of where they land, incoming in order + of where they came from. Sorting each side by the *other* end is what keeps + a node's own ribbons from crossing each other the moment they leave it. + """ + for node in nodes: + outgoing = sorted(node.outgoing, key=lambda i: (nodes[links[i].target].y0, links[i].index)) + cursor = node.y0 + for link_index in outgoing: + link = links[link_index] + height = (node.y1 - node.y0) * (link.value / node.value) if node.value > 0 else 0.0 + link.source_y0 = cursor + link.source_y1 = cursor + height + cursor = link.source_y1 + + incoming = sorted(node.incoming, key=lambda i: (nodes[links[i].source].y0, links[i].index)) + cursor = node.y0 + for link_index in incoming: + link = links[link_index] + height = (node.y1 - node.y0) * (link.value / node.value) if node.value > 0 else 0.0 + link.target_y0 = cursor + link.target_y1 = cursor + height + cursor = link.target_y1 + + +def compute_layout( + links: list[tuple[Any, Any, float]], + *, + nodes: Optional[list[Any]] = None, + node_width: float = 0.02, + node_padding: float = 0.02, + align: str = "justify", + iterations: int = 6, +) -> SankeyLayout: + """Place a Sankey in a 0..1 x 0..1 box. + + Args: + links: ``(source, target, value)`` triples. Endpoints are node names. + nodes: Explicit node order. Defaults to first appearance in `links`. + node_width: Node rectangle width, as a fraction of the box. + node_padding: Vertical gap between nodes in a layer, as a fraction. + align: ``"justify"`` (default) flushes sinks to the last layer. + iterations: Barycentre sweeps for crossing minimisation. + + Returns: + A `SankeyLayout` whose coordinates are all in 0..1. + """ + if align not in _ALIGNMENTS: + raise ValueError(f"sankey align must be one of {sorted(_ALIGNMENTS)}") + if not links: + raise ValueError("sankey needs at least one link") + if not 0.0 < node_width < 1.0: + raise ValueError("sankey node_width must be between 0 and 1") + if not 0.0 <= node_padding < 1.0: + raise ValueError("sankey node_padding must be between 0 and 1") + + names, index_of = _resolve_nodes(nodes, links) + placed_nodes = [SankeyNode(name=name, index=i) for i, name in enumerate(names)] + placed_links: list[SankeyLink] = [] + seen: set[tuple[int, int]] = set() + for position, (source, target, value) in enumerate(links): + source_name, target_name = str(source), str(target) + for endpoint in (source_name, target_name): + if endpoint not in index_of: + raise ValueError( + f"sankey link {position} references unknown node {endpoint!r}; " + f"known nodes are {names}" + ) + si, ti = index_of[source_name], index_of[target_name] + if si == ti: + raise ValueError( + f"sankey link {position} connects {source_name!r} to itself; " + "a self-link has no width to draw and no direction to flow in" + ) + weight = float(value) + if not math.isfinite(weight) or weight < 0.0: + raise ValueError( + f"sankey link {position} ({source_name} -> {target_name}) has value {value!r}; " + "link values must be finite and non-negative" + ) + if (si, ti) in seen: + raise ValueError( + f"sankey has duplicate link {source_name!r} -> {target_name!r}; " + "sum the values into one link" + ) + seen.add((si, ti)) + link = SankeyLink(source=si, target=ti, value=weight, index=len(placed_links)) + placed_nodes[si].outgoing.append(link.index) + placed_nodes[ti].incoming.append(link.index) + placed_links.append(link) + + layers = _assign_layers(placed_nodes, placed_links) + _align(placed_nodes, placed_links, layers, align) + for node in placed_nodes: + node.value = max( + sum(placed_links[i].value for i in node.incoming), + sum(placed_links[i].value for i in node.outgoing), + ) + columns = _order_layers(placed_nodes, placed_links, layers, iterations) + _place(placed_nodes, columns, layers, node_width, node_padding) + _stack_endpoints(placed_nodes, placed_links) + return SankeyLayout(nodes=placed_nodes, links=placed_links, layers=layers) diff --git a/python/xy/_scene.py b/python/xy/_scene.py index f1a0b129..2f30d242 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -22,6 +22,57 @@ # is visually indistinguishable from the SVG's true cubics. _BEZIER_STEPS = 16 +# Segments per ribbon edge. Fixed rather than view-adaptive: a flow diagram has +# tens of links, so the ceiling is free, and a view-dependent count would have +# to be recorded per §28 rather than chosen silently. The client sweeps the same +# count so the live chart and the exports flatten identically. This resolution +# keeps chord error below a visible pixel on wide, high-contrast diagrams. +RIBBON_STEPS = 96 + + +def ribbon_edge( + x0: float, x1: float, ya: float, yb: float, steps: int = RIBBON_STEPS +) -> np.ndarray: + """One edge of a flow band, in the caller's coordinate space. + + The cubic of the ribbon contract: both control points sit at the horizontal + midpoint and hold their own end's y, so the edge leaves and arrives + horizontally (d3's `curveBumpX`). The function is pure arithmetic with no + opinion about units — but the contract makes the curve normative in + **axis-transformed space**, so exporters pass mapped endpoints rather than + mapping the flattened result (the two orders differ on log/symlog axes). + Returned flattened because the raster display list has no curve opcode; the + SVG exporter rebuilds the exact `C` from the same four numbers. + """ + t = np.linspace(0.0, 1.0, steps + 1) + u = 1.0 - t + mid = (x0 + x1) / 2.0 + xs = u**3 * x0 + 3 * u**2 * t * mid + 3 * u * t**2 * mid + t**3 * x1 + ys = u**3 * ya + 3 * u**2 * t * ya + 3 * u * t**2 * yb + t**3 * yb + return np.column_stack([xs, ys]) + + +def ribbon_polygon( + x0: float, + x1: float, + src_lo: float, + src_hi: float, + dst_lo: float, + dst_hi: float, + steps: int = RIBBON_STEPS, +) -> np.ndarray: + """A whole flow band as one closed polygon, in the caller's space. + + One polygon, not two triangles or a mesh: the seam-free fill paths in both + exporters require a single uniform-alpha shape, and a gradient across a + triangle mesh is impossible anyway (the contract explains why). This is the + single reference both static exporters and the golden geometry test consume, + so SVG and PNG cannot drift from each other. + """ + upper = ribbon_edge(x0, x1, src_hi, dst_hi, steps) + lower = ribbon_edge(x0, x1, src_lo, dst_lo, steps) + return np.vstack([upper, lower[::-1]]) + 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 diff --git a/python/xy/_svg.py b/python/xy/_svg.py index d4d6c176..b87b7b65 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1347,6 +1347,34 @@ def gradient(self, fill: dict[str, Any], mark_color: str, plot: Optional[dict] = self.defs.append(f'{stops}') return f"url(#{gid})" + def gradient_vector( + self, x0: float, y0: float, x1: float, y1: float, stops: list[tuple[float, str, float]] + ) -> str: + """Register a two-point in user space; returns url(#id). + + `gradient()` above is closed over four axis-aligned directions, which is + the right vocabulary for a bar or an area but cannot express a ribbon's + gradient — that one runs along the flow, from one face to the other, and + every band in a diagram has its own. Hence an explicit endpoint pair. + Each stop is ``(offset, color, opacity)``: per-stop opacity is how the + alpha channel interpolates along the vector, exactly as the raster's + RGBA stops and the client's `mix` do. `userSpaceOnUse` and + `stop-opacity` are both in the PDF converter's allowlist, so this + survives PDF export unchanged. + """ + gid = self.uid("g") + units = ( + f'gradientUnits="userSpaceOnUse" x1="{_num(x0)}" y1="{_num(y0)}" ' + f'x2="{_num(x1)}" y2="{_num(y1)}"' + ) + parts = [] + for offset, color, opacity in stops: + escaped = escape(color, {chr(34): """}) + alpha = f' stop-opacity="{_num(opacity)}"' if opacity < 1 else "" + parts.append(f'') + self.defs.append(f'{"".join(parts)}') + return f"url(#{gid})" + def _rounded_rect_path( x: float, y: float, w: float, h: float, r_tip: float, r_base: float, tip_top: bool @@ -2744,6 +2772,12 @@ def line_attrs(style: dict[str, Any], color: str) -> str: elif kind == "triangle_mesh": marks.append(_triangle_mesh_marks(t, blob, cols, trace_sx, trace_sy, style, color)) + elif kind == "ribbon": + # MUST precede the rect fall-through below: a ribbon ships + # x0/x1/y0/y1 too, so a later branch would silently draw every + # flow band as a rectangle. + marks.append(_ribbon_marks(t, blob, cols, trace_sx, trace_sy, style, color, svg)) + 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)) @@ -3907,6 +3941,102 @@ def _hexbin_marks( return "".join(out) +def _ribbon_marks( + t: dict, + blob: bytes, + cols: list, + sx: _Scale, + sy: _Scale, + style: dict, + fallback: str, + svg: "_Svg", +) -> str: + """Flow bands as one `` each: exact cubics, gradient along the flow. + + A single path per band, never a mesh — the seam-free mesh route requires one + uniform colour, which is exactly what a two-ended ribbon is not (see the + ribbon geometry contract). When both ends resolve to the same paint the + band gets a plain `fill=` rather than a one-colour gradient, so an ordinary + Sankey stays small. + """ + x0v = _column(blob, cols[t["x0"]]) + x1v = _column(blob, cols[t["x1"]]) + slo = _column(blob, cols[t["y0"]]) + shi = _column(blob, cols[t["y1"]]) + tlo = _column(blob, cols[t["target_y0"]]) + thi = _column(blob, cols[t["target_y1"]]) + n = len(x0v) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + source_rgba = _trace_paint_rgba(t, "color", n, fallback, read) + fills = _paint.effective_rgba(source_rgba, t, read, component="fill", default_opacity=1.0) + if t.get("color_target"): + target_rgba = _trace_paint_rgba(t, "color_target", n, fallback, read) + fills2 = _paint.effective_rgba(target_rgba, t, read, component="fill", default_opacity=1.0) + else: + fills2 = fills + 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 the band's own fill per band + # (edgecolors="face"), so a per-band ribbon does not outline every flow in + # one arbitrary colour. Explicit strokes stay a single declared paint. + 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): + px0, px1 = float(sx(x0v[i])), float(sx(x1v[i])) + y_slo, y_shi = float(sy(slo[i])), float(sy(shi[i])) + y_tlo, y_thi = float(sy(tlo[i])), float(sy(thi[i])) + if not all(math.isfinite(v) for v in (px0, px1, y_slo, y_shi, y_tlo, y_thi)): + continue + # Control points at the horizontal midpoint holding each end's own y: + # the band leaves and arrives horizontally (ribbon geometry contract). + mid = (px0 + px1) / 2.0 + d = ( + f"M {_num(px0)} {_num(y_shi)} " + f"C {_num(mid)} {_num(y_shi)} {_num(mid)} {_num(y_thi)} {_num(px1)} {_num(y_thi)} " + f"L {_num(px1)} {_num(y_tlo)} " + f"C {_num(mid)} {_num(y_tlo)} {_num(mid)} {_num(y_slo)} {_num(px0)} {_num(y_slo)} Z" + ) + a, b = fills[i], fills2[i] + rgb_same = all(abs(float(a[k]) - float(b[k])) < 1e-9 for k in range(3)) + # effective_rgba already folded the trace opacity into the channel + # alpha; folding _fill_opacity in again squared it (0.4 -> 0.16). + alpha_a, alpha_b = float(a[3]), float(b[3]) + alpha_same = abs(alpha_a - alpha_b) < 1e-9 + if rgb_same and alpha_same: + paint = f'fill="{rgb(a)}"' + attrs = paint + (f' fill-opacity="{_num(alpha_a)}"' if alpha_a < 1 else "") + elif alpha_same: + ramp = svg.gradient_vector(px0, 0.0, px1, 0.0, [(0.0, rgb(a), 1.0), (1.0, rgb(b), 1.0)]) + attrs = f'fill="{ramp}"' + (f' fill-opacity="{_num(alpha_a)}"' if alpha_a < 1 else "") + else: + # Differing endpoint alphas ride per-stop stop-opacity so the + # alpha channel interpolates along the flow like the RGB channels + # (the raster and the client already do); a path-level + # fill-opacity would flatten both ends to the source's alpha. + ramp = svg.gradient_vector( + px0, 0.0, px1, 0.0, [(0.0, rgb(a), alpha_a), (1.0, rgb(b), alpha_b)] + ) + attrs = f'fill="{ramp}"' + if stroke_width > 0: + paint_css = stroke_paint if stroke_paint is not None else rgb(source_rgba[i]) + # The band paint's own alpha rides the stroke stack, exactly as + # `effective_rgba` folds it into the fill. + edge_op = stroke_op * (1.0 if stroke_paint is not None else float(source_rgba[i][3])) + attrs += f' stroke="{paint_css}" stroke-width="{_num(stroke_width)}" ' + if edge_op < 1: + attrs += f'stroke-opacity="{_num(edge_op)}" ' + out.append(f'') + return "".join(out) + + def _triangle_mesh_marks( t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str ) -> str: diff --git a/python/xy/_trace.py b/python/xy/_trace.py index fed13064..f26805db 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -51,12 +51,23 @@ class Trace: # no outline; a constant ``None`` color inside the channel means # edgecolors="face" and is resolved against color_ch by the renderers. stroke_ch: Optional[ColorChannel] = None + # Target-end paint for `ribbon`, whose gradient runs along the flow rather + # than along a value axis. `None` means flat: the whole band takes + # `color_ch`. No other kind carries two colors, which is precisely why a + # gradient ribbon could not be expressed as a composition of existing + # marks (spec/api/chart-kind-contract.md, ribbon geometry contract). + color2_ch: Optional[ColorChannel] = None size_ch: Optional[SizeChannel] = None # scatter size encoding # Declarative data-transition metadata. Keys are two uint32 words per # canonical row (a deterministic 64-bit digest), kept out of the f64 # column store because they are identity rather than numeric geometry. animation: Optional[dict[str, Any]] = None transition_keys: Optional[Any] = None + # Small, JSON-safe semantic rows for marks whose geometry columns are not + # meaningful readouts. Sankey uses these to describe a ribbon as + # source/target/value and a node as node/value instead of exposing the six + # internal placement coordinates in hover tooltips. + tooltip_rows: Optional[list[dict[str, Any]]] = None # Direct, final-unit instance attributes (alpha override, opacity, widths, # symbols, corner radii). Constants stay in ``style`` and cost no buffer. style_channels: dict[str, StyleChannel] = field(default_factory=dict) diff --git a/python/xy/channels.py b/python/xy/channels.py index 65c0d9a0..323ed5e0 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -995,6 +995,40 @@ def ship_channels( return color_spec, size_spec +def resolve_direct_rgba(cc: ColorChannel) -> ColorChannel: + """Sample a LUT-encoded color channel down to per-item RGBA, CPU-side. + + For marks that ship **resolved paints only**. The ribbon program binds two + per-instance RGBA attributes (one per band end) and has no LUT path — + `a_rgba2` shares its attribute slot with `a_style`, so the cval/LUT route + physically cannot coexist with the two-ended gradient. Resolving here keeps + the numeric `color=` encodings of the mark signature working and makes the + renderers agree by construction: continuous values run through the same + `normalize_to_unit` + `_svg._lut` chain the static exporters apply to the + shipped buffer, and categorical codes index the same `palette_rows_rgba8` + table, so the resolved bytes match what the exporters computed for + themselves before this existed. Only sensible on small-N direct-tier marks, + where four bytes per item is noise. + """ + if cc.mode == "continuous": + from ._svg import _lut # circular at module scope: _svg reaches back here + + if cc.values is None or cc.domain is None: + raise ValueError("continuous color channel missing values or domain") + rgba = np.empty((len(cc.values), 4), dtype=np.float64) + rgba[:, :3] = _lut(cc.colormap, normalize_to_unit(cc.values, cc.domain)) / 255.0 + rgba[:, 3] = 1.0 + return ColorChannel(mode="direct_rgba", rgba=rgba) + if cc.mode == "categorical": + if cc.codes is None: + raise ValueError("categorical color channel missing codes") + palette = list(cc.palette or config.DEFAULT_PALETTE) + table = palette_rows_rgba8(palette, len(palette)).astype(np.float64) / 255.0 + codes = np.asarray(cc.codes, dtype=np.int64) + return ColorChannel(mode="direct_rgba", rgba=table[codes % len(table)]) + return cc + + def ship_color_channel( cc: ColorChannel, sel: Any, diff --git a/python/xy/components.py b/python/xy/components.py index bd562137..e68116a7 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -117,6 +117,9 @@ "mark", "marker", "modebar", + "ribbon", + "sankey", + "sankey_chart", "scatter", "scatter_chart", "segments", @@ -992,6 +995,138 @@ def segments( ) +def ribbon( + x0: Union[str, ArrayLike, None] = None, + x1: Union[str, ArrayLike, None] = None, + source_lo: Union[str, ArrayLike, None] = None, + source_hi: Union[str, ArrayLike, None] = None, + target_lo: Union[str, ArrayLike, None] = None, + target_hi: Union[str, ArrayLike, None] = None, + *, + data: TableLike = None, + color: Union[str, ColorLike, ArrayLike, None] = None, + color_target: Union[str, ColorLike, ArrayLike, None] = None, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, + name: Optional[str] = None, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, + style: Optional[dict[str, StyleValue]] = None, + class_name: Optional[str] = None, + x_axis: str = "x", + y_axis: str = "y", +) -> Mark: + """Flow bands: a vertical span at `x0` joined to one at `x1` by a cubic. + + The primitive behind `xy.sankey_chart` — and behind alluvial and chord + diagrams later. Each band may carry a different colour at each end + (`color` at the source, `color_target` at the target); the gradient runs + along the flow. Omitting `color_target` paints the band flat. + + Args: + x0: Source face x coordinates or a column name. + x1: Target face x coordinates or a column name. + source_lo: Lower edge of the span at `x0`. + source_hi: Upper edge of the span at `x0`. + target_lo: Lower edge of the span at `x1`. + target_hi: Upper edge of the span at `x1`. + data: Table used to resolve column-name inputs. + color: Source-end colour(s). + color_target: Target-end colour(s); omit for a flat band. + colormap: Ramp for continuous colour values. + name: Series label used by legends and tooltips. + opacity: Band fill opacity from zero to one. + stroke: Optional band outline colour. + stroke_width: Band outline width in pixels. + style: Mark style overrides. + class_name: Adapter-only trace metadata. + x_axis: Identifier of the x axis used by this mark. + y_axis: Identifier of the y axis used by this mark. + """ + return Mark( + kind="ribbon", + x=x0, + y=x1, + data=data, + name=name, + class_name=class_name, + style=_mark_style_dict(style, "ribbon style"), + props={ + "source_lo": source_lo, + "source_hi": source_hi, + "target_lo": target_lo, + "target_hi": target_hi, + "color": color, + "color_target": color_target, + "colormap": colormap, + "opacity": opacity, + "stroke": stroke, + "stroke_width": stroke_width, + "x_axis": x_axis, + "y_axis": y_axis, + }, + ) + + +def sankey( + links: Any = None, + *, + nodes: Optional[Sequence[Any]] = None, + node_width: float = 0.02, + node_padding: float = 0.02, + align: str = "justify", + iterations: int = 6, + colors: Optional[Sequence[str]] = None, + link_opacity: Any = 0.4, + labels: bool = True, + label_size: float = 12.0, + style: Optional[dict[str, StyleValue]] = None, +) -> Mark: + """A Sankey flow diagram from ``(source, target, value)`` links. + + Layout — layering, crossing minimisation, value-proportional heights and + endpoint stacking — happens in Python at build time, the way `hist` owns + its binning; the renderers only ever see `ribbon` geometry. Prefer + `xy.sankey_chart(...)`, which also hides the axes. + + Args: + links: ``(source, target, value)`` triples; endpoints are node names. + nodes: Explicit node order. Defaults to first appearance in `links`. + node_width: Node rectangle width as a fraction of the diagram. + node_padding: Vertical gap between nodes in a layer, as a fraction. + align: ``"justify"`` (default) flushes sinks to the last layer. + iterations: Crossing-minimisation sweeps. + colors: One CSS colour per node, in node order. + link_opacity: Ribbon opacity; node rectangles stay opaque. + labels: Draw node names beside the nodes. + label_size: Node label font size in px. + style: Style overrides for the link ribbons. + """ + return Mark( + kind="sankey", + x=None, + y=None, + data=None, + name=None, + class_name=None, + style=_mark_style_dict(style, "sankey style"), + props={ + # Validation is deferred to figure build, like every factory: + # compute_layout refuses an empty or missing link list by name. + "links": [] if links is None else list(links), + "nodes": None if nodes is None else list(nodes), + "node_width": node_width, + "node_padding": node_padding, + "align": align, + "iterations": iterations, + "colors": None if colors is None else list(colors), + "link_opacity": link_opacity, + "labels": labels, + "label_size": label_size, + }, + ) + + def triangle_mesh( x0: Union[str, ArrayLike, None] = None, y0: Union[str, ArrayLike, None] = None, @@ -5393,6 +5528,44 @@ def _apply_segments(fig: Figure, m: Mark, data: Any) -> None: ) +def _apply_ribbon(fig: Figure, m: Mark, data: Any) -> None: + fig.ribbon( + _resolve_axis_values(fig, data, m.x, "x", f"{m.kind}.x0"), + _resolve_axis_values(fig, data, m.y, "x", f"{m.kind}.x1"), + _resolve_axis_values(fig, data, m.props["source_lo"], "y", f"{m.kind}.source_lo"), + _resolve_axis_values(fig, data, m.props["source_hi"], "y", f"{m.kind}.source_hi"), + _resolve_axis_values(fig, data, m.props["target_lo"], "y", f"{m.kind}.target_lo"), + _resolve_axis_values(fig, data, m.props["target_hi"], "y", f"{m.kind}.target_hi"), + color=_resolve_color(data, m.props["color"], context=f"{m.kind}.color"), + color_target=_resolve_color( + data, m.props["color_target"], context=f"{m.kind}.color_target" + ), + colormap=m.props["colormap"], + name=m.name, + opacity=m.props["opacity"], + stroke=m.props["stroke"], + stroke_width=m.props["stroke_width"], + style=m.style, + ) + + +def _apply_sankey(fig: Figure, m: Mark, data: Any) -> None: + del data # links are literal triples; there is no frame to resolve against + fig.sankey( + m.props["links"], + nodes=m.props["nodes"], + node_width=m.props["node_width"], + node_padding=m.props["node_padding"], + align=m.props["align"], + iterations=m.props["iterations"], + colors=m.props["colors"], + link_opacity=m.props["link_opacity"], + labels=m.props["labels"], + label_size=m.props["label_size"], + style=m.style, + ) + + def _apply_triangle_mesh(fig: Figure, m: Mark, data: Any) -> None: color = m.props["color"] fig.triangle_mesh( @@ -5762,6 +5935,8 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: "step": _apply_step, "stairs": _apply_stairs, "stem": _apply_stem, + "ribbon": _apply_ribbon, + "sankey": _apply_sankey, "triangle_mesh": _apply_triangle_mesh, "violin": _apply_violin, } @@ -6013,6 +6188,46 @@ def segments_chart(*children: Component, **props: Any) -> Chart: return Chart("segments_chart", children, **props) +def sankey_chart( + links: Any = None, + *children: Component, + **props: Any, +) -> Chart: + """A Sankey diagram chart: flow layout, gradient ribbons, hidden axes. + + xy.sankey_chart([ + ("Inflow", "Equities", 78000), + ("Inflow", "Bonds", 46000), + ("Equities", "Growth", 61000), + ]) + + Keyword arguments that belong to `xy.sankey` (``nodes``, ``colors``, + ``node_width``, ``link_opacity``, ``labels``, …) are forwarded there; + everything else (``width``, ``height``, ``title``, …) styles the chart. + The diagram lives in a unit box with a small margin, y inverted so flow + reads top-down like every other Sankey. + """ + mark_keys = ( + "nodes", + "node_width", + "node_padding", + "align", + "iterations", + "colors", + "link_opacity", + "labels", + "label_size", + ) + mark_kwargs = {key: props.pop(key) for key in mark_keys if key in props} + children = ( + sankey(links, **mark_kwargs), + x_axis(domain=(-0.09, 1.09), show=False), + y_axis(domain=(-0.05, 1.05), reverse=True, show=False), + *children, + ) + return Chart("sankey_chart", children, **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 5bbf3502..f86946ad 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -28,7 +28,12 @@ # including axes-fraction y and pixel padding. A v9 client would ignore that # field and silently omit non-center slots and their placement, so it must # reject the payload. -PROTOCOL_VERSION = 10 +# v11 adds the `ribbon` trace kind (flow bands: six geometry columns and an +# optional second paint channel, `color_target`). markOf() falls back to +# scatter for unknown kinds, so a v10 client would silently draw every ribbon +# as a point cloud of its y-corner columns — a plausible wrong picture, so it +# must reject the payload. +PROTOCOL_VERSION = 11 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/interaction.py b/python/xy/interaction.py index ffffc036..77fc39a6 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -242,7 +242,17 @@ def row_dict(fig: "Figure", t: "Trace", idx: int) -> dict[str, Any]: if np.isfinite(val): out["color_value"] = val return out - out: dict[str, Any] = { + if t.tooltip_rows is not None and idx < len(t.tooltip_rows): + # Semantic rows replace the coordinate projection outright: for these + # marks the x/y slots hold internal placement coordinates (a ribbon's + # target span), and the contract promises the pick describes the flow + # or node, never its placement. + out: dict[str, Any] = {"trace": t.id, "index": idx} + for key, value in t.tooltip_rows[idx].items(): + if key not in out: + out[key] = _json_scalar(value) + return out + out = { "trace": t.id, "index": idx, "x": _json_scalar(float(t.x.values[idx])), diff --git a/python/xy/marks.py b/python/xy/marks.py index a2687739..8c9f7138 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -406,6 +406,276 @@ def segments( return self +def ribbon( + self: "Figure", + x0: ArrayLike, + x1: ArrayLike, + source_lo: ArrayLike, + source_hi: ArrayLike, + target_lo: ArrayLike, + target_hi: ArrayLike, + *, + color: Union[str, ArrayLike, None] = None, + color_target: Union[str, ArrayLike, None] = None, + colormap: channels.ColormapLike = channels.DEFAULT_COLORMAP, + name: Optional[str] = None, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, + style: styles.StyleMapping | None = None, +) -> "Figure": + """Add flow bands: a span at `x0` joined to a span at `x1` by a cubic. + + The primitive behind Sankey, and the reason it is a primitive rather than a + composition: each band carries a colour at *each* end and the gradient runs + along the flow, which no existing mark can express (see the ribbon geometry + contract in spec/api/chart-kind-contract.md). + + `color`/`color_target` take a CSS colour, per-band colours (RGBA rows carry + per-band alpha), or numeric values sampled through `colormap`; every + encoding resolves to concrete per-band paint before shipping. `opacity`, + `stroke` and `stroke_width` are per-trace scalars — per-band styling rides + the colour channels, nowhere else. + """ + css = styles.compile_mark_style("ribbon", style) + color = css.get("color", color) + opacity = css.get("opacity", opacity) + stroke = css.get("stroke", stroke) + stroke_width = css.get("stroke_width", stroke_width) + name = self._optional_text(name, "ribbon name") + arrays = [ + self._as_1d_float(values, f"ribbon {label}") + for label, values in ( + ("x0", x0), + ("x1", x1), + ("source_lo", source_lo), + ("source_hi", source_hi), + ("target_lo", target_lo), + ("target_hi", target_hi), + ) + ] + lengths = {len(values) for values in arrays} + if len(lengths) != 1: + raise ValueError(f"ribbon columns must be the same length; got {sorted(lengths)}") + n = int(arrays[0].size) + # Ribbon styles are per-trace scalars, refused as arrays rather than + # silently flattened: the ribbon program's a_rgba2 shares its attribute + # slot with a_style, so the standard per-instance style route cannot + # coexist with the two-ended gradient, and a capability one renderer + # cannot draw is absent everywhere (parity is identity). Per-band alpha + # is not lost — RGBA rows in `color`/`color_target` carry it, and every + # renderer interpolates all four channels along the band. + opacity_constant, opacity_channel = channels.resolve_style_channel( + opacity, n, "ribbon opacity", minimum=0.0, maximum=1.0 + ) + if opacity_channel is not None: + raise ValueError( + "ribbon opacity is per-trace; put per-band alpha in the color " + "arrays instead (RGBA rows interpolate along each band)" + ) + opacity_value = 1.0 if opacity_constant is None else float(opacity_constant) + stroke_value, stroke_ch = _stroke_channel(stroke, n, "ribbon stroke") + if stroke_ch is not None: + raise ValueError( + "ribbon stroke is per-trace; omit it to outline each band with " + "its own fill color (edgecolors='face')" + ) + width_constant, width_channel = channels.resolve_style_channel( + stroke_width, n, "ribbon stroke_width", minimum=0.0 + ) + if width_channel is not None: + raise ValueError("ribbon stroke_width is per-trace") + stroke_width_value = 0.0 if width_constant is None else float(width_constant) + # Ribbon ships resolved paints only (constant or direct RGBA): numeric + # `color=` encodings are sampled through the shared exporter LUT here, + # once, instead of teaching the two-ended ribbon program a cval path it + # has no attribute slot for (ribbon geometry contract). + color_ch = channels.resolve_direct_rgba( + channels.resolve_color( + color, + n, + colormap=colormap, + default_constant=self.next_series_color, + palette=self.palette, + ) + ) + # No target colour means a flat band. Resolving one anyway would ship a + # second buffer and turn every plain ribbon into a two-stop gradient in + # three renderers for no visible difference. + color2_ch = ( + None + if color_target is None + else channels.resolve_direct_rgba( + channels.resolve_color( + color_target, + n, + colormap=colormap, + default_constant=self.next_series_color, + palette=self.palette, + ) + ) + ) + checkpoint = self._checkpoint() + try: + x0c, x1c, slo, shi, tlo, thi = [self.store.ingest(values) for values in arrays] + style_dict: dict[str, Any] = {"opacity": opacity_value, "role": "ribbon"} + 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 + self.traces.append( + Trace( + id=len(self.traces), + kind="ribbon", + # The six geometry slots are saturated: `x`/`y` carry the + # TARGET span's y values, which is why `_range_columns` needs a + # ribbon branch to autorange them on the y axis. + x=tlo, + y=thi, + x0=x0c, + x1=x1c, + y0=slo, + y1=shi, + name=name, + style=style_dict, + color_ch=color_ch, + color2_ch=color2_ch, + count=n, + ) + ) + return self + except Exception: + self._rollback(checkpoint) + raise + + +def sankey( + self: "Figure", + links: Any, + *, + nodes: Optional[Sequence[Any]] = None, + node_width: float = 0.02, + node_padding: float = 0.02, + align: str = "justify", + iterations: int = 6, + colors: Optional[Sequence[str]] = None, + link_opacity: Any = 0.4, + labels: bool = True, + label_size: float = 12.0, + style: styles.StyleMapping | None = None, +) -> "Figure": + """Add a Sankey flow diagram: placed nodes, gradient ribbons, labels. + + The layout (layering, crossing minimisation, value-proportional heights, + endpoint stacking) is pure Python in `_sankey.compute_layout`, exactly as + `hist` owns its binning; the drawing is two `ribbon` traces — the links, + and the nodes themselves, since a band whose two spans are equal *is* a + rectangle. Each link takes its source node's colour at the source end and + its target node's at the target end, so the gradient reads as flow. + + Args: + links: ``(source, target, value)`` triples; endpoints are node names. + nodes: Explicit node order. Defaults to first appearance in `links`. + node_width: Node rectangle width as a fraction of the diagram. + node_padding: Vertical gap between nodes in a layer, as a fraction. + align: ``"justify"`` flushes sinks to the last layer. + iterations: Barycentre sweeps for crossing minimisation. + colors: One CSS colour per node, in node order. Defaults to the + figure's palette cycle. + link_opacity: Ribbon fill opacity; nodes stay opaque. + labels: Draw node names beside the nodes. + label_size: Node label font size in px. + style: Mark style overrides for the LINK ribbons. + """ + from . import _sankey + + triples = [(s_, t_, v_) for s_, t_, v_ in links] + layout = _sankey.compute_layout( + triples, + nodes=list(nodes) if nodes is not None else None, + node_width=node_width, + node_padding=node_padding, + align=align, + iterations=iterations, + ) + n_nodes = len(layout.nodes) + if colors is not None: + if len(colors) != n_nodes: + raise ValueError( + f"sankey colors must have one entry per node ({n_nodes}); got {len(colors)}" + ) + node_css = [str(c) for c in colors] + else: + node_css = [self.palette_color(i) for i in range(n_nodes)] + + try: + link_alpha = float(link_opacity) + except (TypeError, ValueError): + raise ValueError( + f"sankey link_opacity must be a number in (0, 1], got {link_opacity!r}" + ) from None + if not 0.0 < link_alpha <= 1.0: + raise ValueError("sankey link_opacity must be in (0, 1]") + + checkpoint = self._checkpoint() + try: + if layout.links: + self.ribbon( + [layout.nodes[link.source].x1 for link in layout.links], + [layout.nodes[link.target].x0 for link in layout.links], + [link.source_y0 for link in layout.links], + [link.source_y1 for link in layout.links], + [link.target_y0 for link in layout.links], + [link.target_y1 for link in layout.links], + color=[node_css[link.source] for link in layout.links], + color_target=[node_css[link.target] for link in layout.links], + name=None, + opacity=link_alpha, + style=style, + ) + self.traces[-1].tooltip_rows = [ + { + "source": layout.nodes[link.source].name, + "target": layout.nodes[link.target].name, + "value": float(link.value), + } + for link in layout.links + ] + # The nodes: a ribbon whose two spans are equal is an axis-aligned + # rectangle, so nodes need no second primitive. + self.ribbon( + [node.x0 for node in layout.nodes], + [node.x1 for node in layout.nodes], + [node.y0 for node in layout.nodes], + [node.y1 for node in layout.nodes], + [node.y0 for node in layout.nodes], + [node.y1 for node in layout.nodes], + color=node_css, + opacity=1.0, + ) + self.traces[-1].tooltip_rows = [ + {"node": node.name, "value": float(node.value)} for node in layout.nodes + ] + if labels: + last = max(node.layer for node in layout.nodes) + for node in layout.nodes: + at_right = node.layer >= (last + 1) / 2 + self.text( + node.x0 - 0.008 if at_right else node.x1 + 0.008, + (node.y0 + node.y1) / 2.0, + node.name, + dx=0.0, + dy=0.0, + anchor="end" if at_right else "start", + style={"font_size": label_size}, + ) + return self + except Exception: + self._rollback(checkpoint) + raise + + def triangle_mesh( self: "Figure", x0: ArrayLike, diff --git a/python/xy/styles.py b/python/xy/styles.py index f4e29dee..9c3028e8 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -35,6 +35,11 @@ _RECT_KINDS = frozenset({"histogram", "hist", "bar", "column"}) _FILL_KINDS = frozenset({"box", "violin"}) _MESH_KINDS = frozenset({"triangle_mesh"}) +# A ribbon is filled and outlined but never gradient-filled through `style`: +# its two end colors are channels, not per-trace state, so "fill" is +# deliberately absent from its property set and the guard below rejects a +# `linear-gradient(...)` on it with the standard message. +_RIBBON_KINDS = frozenset({"ribbon"}) _DENSITY_KINDS = frozenset({"heatmap", "hexbin"}) _AXIS_COLOR_PROPERTIES = frozenset( @@ -82,6 +87,7 @@ | _RECT_KINDS | _FILL_KINDS | _MESH_KINDS + | _RIBBON_KINDS | _DENSITY_KINDS ) ) @@ -285,6 +291,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. + props |= {"fill-opacity", "stroke", "stroke-width", "stroke-opacity"} elif kind in _MESH_KINDS: props |= { "fill", @@ -349,12 +360,12 @@ 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 | {"box"}: + if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_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 | {"box"}: + if kind in _POINT_KINDS | _RECT_KINDS | _MESH_KINDS | _RIBBON_KINDS | {"box"}: target = "stroke_width" _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen) elif prop == "stroke-dasharray": diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index ebce32d8..20d3d844 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 -- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **10** mark style properties across **21** mark kinds, drawn by all three renderers. - **29** 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`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped | +| `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 | | `fill` | svg | `area`, `bar`, `box`, `column`, `error_band`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `heatmap`, `hexbin`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped | -| `stroke` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-opacity` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped | -| `stroke-width` | svg | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | 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 | | `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 b9452ebe..ef5e242e 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -24,9 +24,9 @@ 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 eighteen kinds today — `area`, `bar`, `box`, `box_median`, `box_whisker`, +holds nineteen kinds today — `area`, `bar`, `box`, `box_median`, `box_whisker`, `column`, `contour`, `error_band`, `errorbar`, `heatmap`, `hexbin`, `histogram`, -`line`, `scatter`, `segments`, `stem`, `triangle_mesh`, `violin` — each with a +`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`. @@ -57,6 +57,95 @@ by the string `K` on the wire (`trace.kind`). carries its own palette (`ColorChannel.palette`, resolved at build against the figure's cycle), so no palette is threaded through the ship call. +#### The ribbon geometry contract + +A `ribbon` is a flow band: it leaves a vertical span on one x and arrives at a +vertical span on another, carrying a colour at each end. It is the primitive +behind Sankey, and behind alluvial, chord and parallel-categories later. + +Three renderers draw it — SVG emits true cubics, the raster flattens them, WebGL +evaluates them per vertex — so this section is normative and a fourth renderer +implements it without reading the other three. + +**On the wire.** The six geometry slots are saturated; there is no `base`: + +| Field | Meaning | Axis | +| --- | --- | --- | +| `kind` | `"ribbon"` | | +| `tier` | always `"direct"` — a Sankey is small-N by nature, and no decimation or density tier is meaningful for a flow band | | +| `x0` | source face x | x | +| `x1` | target face x | x | +| `y0`, `y1` | source span, lower and upper edge | y | +| `x`, `y` | **target** span, lower and upper edge — y values in the `x`/`y` slots, which is why `_range_columns` needs a ribbon branch | y | +| `color` | channel record for the **source** end — always **resolved paint** (`constant` or `direct_rgba`): numeric encodings are sampled through the shared exporter LUT at the factory (`channels.resolve_direct_rgba`), because the ribbon program's `a_rgba2` shares its attribute slot with `a_style` and has no cval/LUT path, and a small-N direct-tier mark makes CPU sampling free | | +| `color_target` | channel record for the **target** end, same resolved-paint rule; absent means flat, painted with `color` | | +| `tooltip_rows` | optional per-band semantic objects; Sankey links carry `source`, `target`, `value`, while node bands carry `node`, `value`. The values are deliberately JSON scalars: these are small-N semantic readouts (labels and one flow value per band), not geometry that scales with data, which is what §29's raw-buffer rule exists for | | + +**The curve.** A cubic in *axis-transformed space* with both control points at +the horizontal midpoint `xm = (x0 + x1) / 2`, each holding its own end's y — +d3's `curveBumpX`, which d3-sankey and ECharts likewise evaluate on +already-scaled coordinates. The band therefore leaves and arrives horizontally +on screen, and its width is measured vertically the whole way across. +Transformed space, not data space, because only that choice lets all three +renderers draw literally the same curve on every axis type: an SVG `C` is +necessarily a cubic in pixel space and the client sweeps one in clip space — +both affine images of transformed space, where cubics are invariant — while a +data-space cubic on a log axis is a shape neither can represent exactly. The +raster therefore transforms the six endpoint values *first* and flattens the +cubic they define, never the reverse. Under affine axes the two orders +coincide, so this distinction is invisible on the linear 0..1 axes a Sankey +actually uses. CPU hover bisects the same transformed-space cubic. + +``` +upper edge: (x0, y1) C (xm, y1) (xm, y) -> (x1, y) +lower edge: (x1, x) C (xm, x) (xm, y0) -> (x0, y0) +closed path: M x0,y1 C… L x1,x C… Z +``` + +The raster flattens each edge at 96 steps; the client sweeps a triangle strip of +the same 96 segments. Both consume the same Python reference, +`_scene.ribbon_polygon`, so a divergence is a test failure rather than a +rendering difference. + +**Paint.** The gradient runs along the **flow axis**, from `x0` to `x1` — not +along a value axis, which is what separates a ribbon from every other filled +mark and is why `style.fill` gradients are rejected on it (per-end colour is a +channel, not per-trace style). The interpolation covers all **four** channels: +ends that differ only in alpha still ramp (SVG rides per-stop `stop-opacity`, +already in the PDF allowlist; the raster's gradient stops are RGBA; the client +mixes RGBA per fragment). Only when the two ends resolve to the same RGBA do +the renderers emit a flat fill, not a two-stop gradient, so a plain Sankey +stays cheap in every output format. + +**Outline.** `style.stroke` / `stroke-width` / `stroke-opacity` draw an +outline over the closed band — both curved edges *and* the two vertical end +faces, in every renderer (the exporters stroke the closed path; the client +takes the smaller of the side and flow-parameter device-pixel distances). An +omitted stroke colour means **match the band's own fill** per band — the +`edgecolors="face"` rule the point and rect programs already follow — because +a per-band ribbon has no single trace colour to fall back to. That paint is +the band's **source-end** colour, flat: `cmd.stroke` and SVG's `stroke=` take +one colour per band, so the client must not ramp an outline the exporters +cannot. The alpha stack is the stroke paint's own alpha × `opacity` × +`stroke_opacity`, as for every other stroked mark. `stroke-dasharray` is +**not** in the ribbon property set. + +`opacity`, `stroke` and `stroke_width` are **per-trace scalars**; the factory +refuses arrays rather than shipping channels one renderer would drop. The +ribbon program cannot bind the per-instance style attribute (`a_rgba2` +occupies its slot), and a capability the live chart cannot draw must be +absent everywhere, not exporter-only. Nothing is lost: per-band *alpha* rides +the RGBA rows of `color`/`color_target` — which every renderer interpolates +along the band — and the implicit match-fill outline is already per-band. + +**Picking is deferred.** `pointPick` is false: the GPU id-pass is wired to +`gl.POINTS`. Hover resolves on the CPU by evaluating the same cubic at the +cursor's data x and testing vertical containment, so tooltips work and box or +lasso selection is correctly absent rather than present and wrong. When +`tooltip_rows` is present, the client and kernel exact-pick path preserve those +semantic fields so a Sankey tooltip describes the flow or node rather than its +internal placement coordinates. + #### Shared-geometry marks: the hexbin centers-only contract A mark whose cells all share one geometry ships **centers plus channels**, not @@ -211,9 +300,18 @@ mark. `_MARK_APPLIERS` is consulted first, so a plugin can never shadow a built-in. The dividing line is whether the kind needs a **new primitive**. A candlestick, -a dumbbell, a ribbon, a high-low band — all compositions, all plugin territory. -A kind that needs geometry no shader draws yet is a core kind and takes the -checklist. Composition is one level deep on purpose: plugins compose built-ins, +a dumbbell, a high-low band — all compositions, all plugin territory. A kind +that needs geometry no shader draws yet is a core kind and takes the checklist. + +A **ribbon** was listed here as plugin territory until Sankey was built, and the +attempt is what moved it. A ribbon carries two colours — one per end — and no +existing primitive can hold that. The seam-free `triangle_mesh` path in both +exporters is gated on a single uniform fill (`_svg.py`, `_raster.py`: +`np.all(fills == fills[0])`), so per-triangle colour falls out of the fast path +and re-introduces an antialiasing seam on every shared edge; and on the client +`MESH_VS` reads colour per *instance*, so a mesh triangle is flat-shaded by +construction and cannot interpolate at all. Two renderers, two independent +reasons, same conclusion: the gradient ribbon is a primitive, not a composition. Composition is one level deep on purpose: plugins compose built-ins, not each other, which keeps the registry a lookup rather than a dependency graph. diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 45fd9d5e..364f9262 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -136,7 +136,7 @@ not fall out of sight. | 27 | Timeline/Gantt/event charts | event timeline, bar range, Gantt, milestone chart | Partially implemented in `xy.pyplot` | `eventplot(positions, orientation=, lineoffsets=, linelengths=, linewidths=, colors=, linestyles=)` renders event timelines through instanced segments; `broken_barh(xranges, yrange)` renders bar ranges as horizontal `bar` marks with a per-range base and categorical y support. Gantt/milestone composition and date-axis polish remain planned later. | | 28 | Calendar charts | calendar heatmap, contribution graph, daily cohort grid | Planned later | Product analytics and ops compatibility; grid + date-axis specialization. | | 29 | Parallel coordinate/category | parallel coordinates, parallel categories, alluvial-lite | Planned later | Present in Plotly/ECharts; useful for high-dimensional EDA. | -| 30 | Sankey / alluvial | Sankey, alluvial, dependency wheel | Planned later | Important flow chart, but requires layout and interaction work. | +| 30 | Sankey / alluvial | Sankey, alluvial, dependency wheel | Implemented core | `xy.sankey_chart(links)` — Python layout (`_sankey.py`: layering, barycentre crossing minimisation, endpoint stacking) over the new `ribbon` primitive (per-end colours, flow-axis gradient, protocol v11). Alluvial/dependency-wheel remain compositions to build on the same primitive. Deferred: GPU picking (CPU hover only), legend swatches, cycle auto-breaking. | | 31 | Network/tree/org | network graph, force graph, tree, dendrogram, org chart, arc diagram | Planned later | Valuable but layout-heavy; should follow core 2D marks. | | 32 | Scientific vector fields | quiver, barbs, streamplot, wind rose | Implemented in `xy.pyplot` | Quiver, barbs, and bounded streamlines feed shared instanced segments; wind rose remains tied to future polar axes. | | 33 | Irregular grid science | pcolormesh, tricontour, tripcolor, triangular mesh | Implemented in `xy.pyplot` | Curvilinear quads and explicit/native triangulations route through indexed meshes and marching-triangle kernels. | @@ -194,7 +194,7 @@ depth: strip/swarm/boxen/rug distributions, regression diagnostics, richer |---:|---|---|---| | 21 | Treemap | Common BI hierarchy chart. | Requires layout algorithm, labels, and color scale polish. | | 22 | Sunburst / icicle | Plotly/ECharts hierarchy compatibility. | Requires hierarchy layout and radial/rectangular variants. | -| 23 | Sankey / alluvial / dependency wheel | Common flow visualization in BI and systems analysis. | Layout and interaction are the hard parts. | +| 23 | Sankey / alluvial / dependency wheel | Common flow visualization in BI and systems analysis. | Core implemented: `xy.sankey_chart` + `ribbon`. Interaction depth (link picking, hover highlight) tracked in the ribbon contract's deferred list. | | 24 | Network / tree / org / dendrogram / arc | Graph and hierarchy compatibility. | Requires graph layout algorithms and selection semantics. | | 25 | Gauge / bullet / indicator | Dashboard/KPI compatibility. | Mostly chrome and layout, not large-array rendering. | | 26 | Table-adjacent views | Table, pivot-like summary, annotated table. | Basic `table` is implemented through `xy.pyplot` as tessellated cell geometry; pivot-like summary and annotated-table depth remain deferred. | diff --git a/spec/api/styling.md b/spec/api/styling.md index da242396..8930840c 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -1215,6 +1215,7 @@ rendered mark family and its accepted `style=` properties. | `histogram` | ✅ | ✅ | ✅ all or `(tip, base)` | ✅ | — | — | bin-driven | | `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` | | `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 3b4ceebd..15101f7c 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 = 10` (`python/xy/config.py`) +- **Renderer/spec protocol.** `PROTOCOL_VERSION = 11` (`python/xy/config.py`) rides every first-paint spec as `spec["protocol"]`; the client's - `PROTOCOL = 10` (`js/src/00_header.ts`) is checked in the `ChartView` + `PROTOCOL = 11` (`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 @@ -453,7 +453,13 @@ Two independent version constants: axes-fraction `y` and pixel `pad` occupy a two-f32 raw geometry column referenced by `geometry`, keeping numeric data out of JSON. A cached v9 client would ignore the field and silently omit non-center slots and their - placement, so the v10 mismatch rejects it before rendering. + placement, so the v10 mismatch rejects it before rendering. v11 adds the + `ribbon` mark kind (the flow band behind `sankey`: six span columns, two + resolved paints, optional semantic `tooltip_rows` — the ribbon geometry + contract in `spec/api/chart-kind-contract.md`). `markOf()` falls back to + scatter for unknown kinds, so a cached v10 client would silently draw every + flow diagram as a point cloud of span endpoints; the v11 mismatch rejects + it before rendering. - **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/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index 32608962..18728f95 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -692,3 +692,8 @@ discard boundary, dash geometry, interpolated fills, violin extrema, and the colormap validation boundary are implemented and regression-tested. Items listed above as approximations or open inconsistencies are exactly that — none of this is claimed as full Matplotlib parity. + +- `matplotlib.sankey.Sankey`: deliberately NOT shimmed. Its API is a + path-drawing toolkit (trunk/branch offsets in axes units), not a flow-data + API; `xy.sankey_chart(links)` is the supported spelling. Revisit only if + corpus evidence shows real notebooks using mpl's Sankey. diff --git a/tests/pyplot/test_tick_side_rendering.py b/tests/pyplot/test_tick_side_rendering.py index 0e079b69..a89a53b9 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 == 10 + assert spec["protocol"] == PROTOCOL_VERSION == 11 assert f"PROTOCOL = {PROTOCOL_VERSION};" in header assert 'import { PROTOCOL, xyByteSpan } from "./00_header";' in client assert "spec.protocol !== PROTOCOL" in client diff --git a/tests/test_api_parity.py b/tests/test_api_parity.py index f06ab740..8195a4fe 100644 --- a/tests/test_api_parity.py +++ b/tests/test_api_parity.py @@ -36,6 +36,8 @@ # explicit so a future rename must update the guard deliberately). MARK_PAIRS = [ ("scatter", "scatter"), + ("ribbon", "ribbon"), + ("sankey", "sankey"), ("line", "line"), ("area", "area"), ("histogram", "histogram"), @@ -60,6 +62,8 @@ # One inline-data Mark per applier kind, used to exercise real forwarding. SAMPLE_MARKS = { "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)]), "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_sankey.py b/tests/test_sankey.py new file mode 100644 index 00000000..ad00daab --- /dev/null +++ b/tests/test_sankey.py @@ -0,0 +1,621 @@ +"""Sankey and the ribbon primitive: layout, wire shape, and cross-renderer +geometry. + +The layout is pinned by direct assertions on `_sankey.compute_layout`; the +geometry is pinned by comparing both static exporters against +`_scene.ribbon_polygon`, the single reference the contract names — the failure +mode being guarded is a renderer quietly keeping its own curve (or falling +through to the rect family, which shares the ribbon's column names). +""" + +from __future__ import annotations + +import re +from itertools import pairwise +from pathlib import Path + +import pytest + +import xy +from xy._figure import Figure +from xy._sankey import compute_layout +from xy._scene import RIBBON_STEPS, ribbon_polygon + +LINKS = [ + ("Inflow", "Equities", 78000.0), + ("Inflow", "Bonds", 46000.0), + ("Inflow", "Cash", 24000.0), + ("Equities", "Growth", 61000.0), + ("Equities", "Income", 17000.0), + ("Bonds", "Income", 28000.0), + ("Bonds", "Reserve", 18000.0), + ("Cash", "Reserve", 24000.0), +] + + +# -- layout ------------------------------------------------------------------ + + +def test_layers_follow_the_longest_path() -> None: + layout = compute_layout(LINKS) + layer = {n.name: n.layer for n in layout.nodes} + assert layer["Inflow"] == 0 + assert layer["Equities"] == layer["Bonds"] == layer["Cash"] == 1 + assert layer["Growth"] == layer["Income"] == layer["Reserve"] == 2 + assert layout.layers == 3 + + +def test_node_value_is_max_of_inflow_and_outflow() -> None: + layout = compute_layout([("a", "b", 5.0), ("b", "c", 9.0)]) + value = {n.name: n.value for n in layout.nodes} + # b receives 5 but emits 9: it must be tall enough for what it emits. + assert value["b"] == 9.0 + + +def test_every_ribbon_is_equal_width_at_both_ends() -> None: + layout = compute_layout(LINKS) + for link in layout.links: + source_h = link.source_y1 - link.source_y0 + target_h = link.target_y1 - link.target_y0 + assert source_h == pytest.approx(target_h, abs=1e-12) + + +def test_outgoing_stacks_fill_each_node_exactly() -> None: + layout = compute_layout(LINKS) + for node in layout.nodes: + spans = sorted( + (layout.links[i].source_y0, layout.links[i].source_y1) for i in node.outgoing + ) + if not spans: + continue + assert spans[0][0] == pytest.approx(node.y0) + assert spans[-1][1] == pytest.approx(node.y1) + for (_, top_end), (next_start, _) in pairwise(spans): + assert top_end == pytest.approx(next_start) + + +def test_cycles_are_refused_by_name() -> None: + with pytest.raises(ValueError, match=r"cycle.*'a'.*'b'|cycle through \['a', 'b'\]"): + compute_layout([("a", "b", 1.0), ("b", "a", 1.0)]) + + +def test_cycle_refusal_names_only_the_cycle_members() -> None: + """Kahn's leftover set includes everything *downstream* of a cycle; the + refusal must not send the user off to remove an innocent node.""" + links = [("a", "b", 1.0), ("b", "a", 1.0), ("b", "c", 1.0), ("c", "d", 1.0)] + with pytest.raises(ValueError, match=r"cycle through \['a', 'b'\]") as excinfo: + compute_layout(links) + assert "'c'" not in str(excinfo.value) and "'d'" not in str(excinfo.value) + + +def test_cycle_refusal_names_every_cycle_but_no_bridge() -> None: + """Two disjoint cycles joined by an acyclic bridge: both cycles are named, + the bridge node is not — it lies between cycles, not on one.""" + links = [ + ("a", "b", 1.0), + ("b", "a", 1.0), + ("b", "x", 1.0), # bridge into the second cycle + ("x", "c", 1.0), + ("c", "d", 1.0), + ("d", "c", 1.0), + ] + with pytest.raises(ValueError, match=r"cycle through \['a', 'b', 'c', 'd'\]") as excinfo: + compute_layout(links) + assert "'x'" not in str(excinfo.value) + + +def test_right_alignment_hangs_every_node_by_its_distance_to_a_sink() -> None: + # Two disjoint chains of different length: the short one starts late under + # `right`, is stretched by its sink under `justify`, and stays at the far + # left under `left`. + links = [("a", "b", 1.0), ("b", "c", 1.0), ("x", "y", 1.0)] + for align, expected in ( + ("left", {"x": 0, "y": 1}), + ("justify", {"x": 0, "y": 2}), + ("right", {"x": 1, "y": 2}), + ): + layer = {n.name: n.layer for n in compute_layout(links, align=align).nodes} + assert (layer["a"], layer["b"], layer["c"]) == (0, 1, 2), align + assert {name: layer[name] for name in expected} == expected, align + + +def test_center_alignment_moves_source_only_nodes_toward_their_targets() -> None: + links = [("a", "b", 1.0), ("b", "c", 1.0), ("late", "c", 1.0)] + layer = {n.name: n.layer for n in compute_layout(links, align="center").nodes} + assert layer == {"a": 0, "b": 1, "late": 1, "c": 2} + # `left` keeps the longest-path layering, so `late` opens at the far left. + layer = {n.name: n.layer for n in compute_layout(links, align="left").nodes} + assert layer["late"] == 0 + + +def test_overpacked_node_padding_is_refused_by_name() -> None: + # Three nodes in a layer with padding 0.6 would need 1.2 of a 1.0 box for + # the gaps alone; a negative room flips the shared scale into inverted + # spans, so it is refused rather than drawn wrong. + with pytest.raises(ValueError, match=r"node_padding 0\.6 leaves no room .* layer 1 holds 3"): + compute_layout(LINKS, node_padding=0.6) + + +@pytest.mark.parametrize( + ("links", "message"), + [ + ([("a", "a", 1.0)], "connects 'a' to itself"), + ([("a", "b", 1.0), ("a", "b", 2.0)], "duplicate link"), + ([("a", "b", -1.0)], "finite and non-negative"), + ([], "at least one link"), + ], +) +def test_bad_links_are_refused(links: list, message: str) -> None: + with pytest.raises(ValueError, match=message): + compute_layout(links) + + +def test_unknown_node_in_explicit_node_list_is_refused() -> None: + with pytest.raises(ValueError, match="unknown node 'c'"): + compute_layout([("a", "c", 1.0)], nodes=["a", "b"]) + + +# -- wire -------------------------------------------------------------------- + + +def _ribbon_figure() -> Figure: + f = Figure(width=420, height=300) + f.ribbon( + [0.1, 0.1], + [0.9, 0.9], + [0.0, 0.5], + [0.4, 0.9], + [0.2, 0.6], + [0.55, 0.95], + color=["#7c3aed", "#0891b2"], + color_target=["#34d399", "#f59e0b"], + ) + return f + + +def test_ribbon_ships_direct_with_both_paints() -> None: + spec, _ = _ribbon_figure().build_payload_split() + t = spec["traces"][0] + assert t["kind"] == "ribbon" + assert t["tier"] == "direct" + for key in ("x0", "x1", "y0", "y1", "target_y0", "target_y1"): + assert key in t + assert t["color"]["mode"] == "direct_rgba" + assert t["color_target"]["mode"] == "direct_rgba" + + +def test_flat_ribbon_ships_no_target_channel() -> None: + f = Figure(width=300, height=200) + f.ribbon([0.0], [1.0], [0.0], [0.3], [0.2], [0.5], color="#7c3aed") + spec, _ = f.build_payload_split() + assert "color_target" not in spec["traces"][0] + + +def test_ribbon_autorange_covers_both_spans() -> None: + spec, _ = _ribbon_figure().build_payload_split() + lo, hi = spec["y_axis"]["range"] + # Data spans 0.0..0.95 across the four span edges; the two ridden in the + # x/y slots must reach autorange or the far ends clip. + assert lo <= 0.0 and hi >= 0.95 + + +def test_sankey_chart_builds_ribbon_traces_only() -> None: + chart = xy.sankey_chart(LINKS, width=680, height=420) + figure = chart.figure() + spec, _ = figure.build_payload_split() + assert [t["kind"] for t in spec["traces"]] == ["ribbon", "ribbon"] + assert spec["traces"][0]["tooltip_rows"][0] == { + "source": "Inflow", + "target": "Equities", + "value": 78000.0, + } + assert spec["traces"][1]["tooltip_rows"][0] == { + "node": "Inflow", + "value": 148000.0, + } + exact_link = figure.pick(0, 0) + assert exact_link is not None + assert exact_link["source"] == "Inflow" + assert exact_link["target"] == "Equities" + assert exact_link["value"] == 78000.0 + exact_node = figure.pick(1, 0) + assert exact_node is not None + assert exact_node["node"] == "Inflow" + assert exact_node["value"] == 148000.0 + # Semantic rows REPLACE the coordinate projection: the x/y slots hold a + # 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"] == 11 + + +# -- resolved paints and per-trace styles ------------------------------------ + + +def test_numeric_ribbon_color_resolves_to_direct_rgba_at_the_factory() -> None: + """The ribbon program has no LUT path (`a_rgba2` occupies the style + attribute slot), so numeric encodings must reach the wire as resolved + RGBA — sampled through the same LUT chain the exporters apply — or the + live chart silently paints the constant fallback.""" + import numpy as np + + from xy import channels + from xy._svg import _lut + + values = np.array([0.0, 5.0, 10.0]) + f = Figure(width=300, height=200) + f.ribbon( + [0.1] * 3, + [0.9] * 3, + [0.0, 0.3, 0.6], + [0.1, 0.4, 0.7], + [0.0, 0.3, 0.6], + [0.1, 0.4, 0.7], + color=values, + colormap="viridis", + ) + ch = f.traces[-1].color_ch + assert ch is not None and ch.mode == "direct_rgba" and ch.rgba is not None + expected = _lut("viridis", channels.normalize_to_unit(values, (0.0, 10.0))) / 255.0 + assert np.allclose(ch.rgba[:, :3], expected, atol=1e-12) + assert (ch.rgba[:, 3] == 1.0).all() + spec, _ = f.build_payload_split() + assert spec["traces"][0]["color"]["mode"] == "direct_rgba" + + +def test_categorical_ribbon_color_resolves_through_the_shared_palette() -> None: + import numpy as np + + from xy import channels + from xy.config import DEFAULT_PALETTE + + f = Figure(width=300, height=200) + f.ribbon( + [0.1] * 3, + [0.9] * 3, + [0.0, 0.3, 0.6], + [0.1, 0.4, 0.7], + [0.0, 0.3, 0.6], + [0.1, 0.4, 0.7], + color=["alpha", "beta", "alpha"], + ) + ch = f.traces[-1].color_ch + assert ch is not None and ch.mode == "direct_rgba" and ch.rgba is not None + table = channels.palette_rows_rgba8(list(DEFAULT_PALETTE), len(DEFAULT_PALETTE)) + expected = table.astype(np.float64)[[0, 1, 0]] / 255.0 + assert np.allclose(ch.rgba, expected, atol=1e-12) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"opacity": [0.5, 0.6]}, "ribbon opacity is per-trace"), + ({"stroke": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]}, "ribbon stroke is per-trace"), + ({"stroke_width": [1.0, 2.0]}, "ribbon stroke_width is per-trace"), + ], +) +def test_per_band_ribbon_styles_are_refused_by_name(kwargs: dict, message: str) -> None: + """Per-band style arrays are refused rather than shipped: the client + cannot bind them (the ribbon geometry contract), and a channel one + renderer drops silently is a parity break, not a capability. Per-band + alpha rides the RGBA colour rows instead.""" + f = Figure(width=300, height=200) + with pytest.raises(ValueError, match=message): + f.ribbon( + [0.1, 0.1], + [0.9, 0.9], + [0.0, 0.5], + [0.2, 0.7], + [0.1, 0.6], + [0.3, 0.8], + color="#7c3aed", + **kwargs, + ) + assert not f.traces, "a refused ribbon must roll back cleanly" + + +def test_sankey_link_opacity_failure_names_the_argument() -> None: + f = Figure(width=300, height=200) + with pytest.raises(ValueError, match=r"sankey link_opacity .* got 'dim'"): + f.sankey([("a", "b", 1.0)], link_opacity="dim") + + +# -- golden geometry --------------------------------------------------------- + + +def test_svg_ribbon_is_the_contract_cubic_not_a_rectangle() -> None: + """A ribbon ships x0/x1/y0/y1, so the rect fall-through would happily draw + it as a rectangle; the dispatch order is all that prevents it.""" + doc = _ribbon_figure().to_image(format="svg").decode() + paths = re.findall(r' None: + doc = _ribbon_figure().to_image(format="svg").decode() + d = re.findall(r' None: + """The PNG must have ink where `_scene.ribbon_polygon` says the band runs, + and none at the straight-chord midpoint the cubic pulls away from.""" + from test_png_export import _decode_rgba + + f = Figure(width=400, height=300) + f.set_axis("x", domain=(0.0, 1.0), tick_label_strategy="none") + f.set_axis("y", domain=(0.0, 1.0), tick_label_strategy="none") + f.ribbon([0.05], [0.95], [0.05], [0.25], [0.7], [0.9], color="#000000", opacity=1.0) + pixels = _decode_rgba(f.to_image(format="png", scale=1)) + + from xy._svg import _Scale, layout + + spec, _ = f.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"]) + poly = ribbon_polygon(0.05, 0.95, 0.05, 0.25, 0.7, 0.9) + mid = poly[RIBBON_STEPS // 2] # a point on the upper edge, mid-flow + upper_px, upper_py = float(sx(mid[0])), float(sy(mid[1])) + # 3px inside the band, measured downward from the upper edge (y grows up + # in data space, down in screen space). + inside = pixels[int(upper_py) + 3, int(upper_px), 0] + assert inside < 128, "no ink just inside the band's upper edge" + + # The straight chord between the two upper corners passes well above the + # bump cubic at mid-flow; a renderer that drew chords (or a rectangle) + # would put ink here. + chord_y = float(sy((0.25 + 0.9) / 2.0)) + off_band = pixels[int(chord_y) - 8, int(upper_px), 0] + assert off_band >= 128, "ink on the straight chord: the cubic was not drawn" + + +def test_raster_ribbon_curves_in_axis_transformed_space_on_log_axes() -> None: + """The ribbon cubic is normative in axis-transformed space (the contract): + the raster must transform the six endpoints first and flatten the cubic + they define — matching SVG's exact pixel-space `C` and the client's + clip-space sweep — never flatten in data space and map each vertex, which + bows a visibly different curve on a log axis.""" + from test_png_export import _decode_rgba + + f = Figure(width=400, height=300) + f.set_axis("x", domain=(0.0, 1.0), tick_label_strategy="none") + f.set_axis("y", type_="log", domain=(1.0, 1000.0), tick_label_strategy="none") + f.ribbon([0.05], [0.95], [1.0], [10.0], [100.0], [1000.0], color="#000000", opacity=1.0) + pixels = _decode_rgba(f.to_image(format="png", scale=1)) + + from xy._svg import _Scale, layout + + spec, _ = f.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"]) + # The reference polygon built from the MAPPED endpoints, as the contract + # and both other renderers define the curve. + poly = ribbon_polygon( + float(sx(0.05)), + float(sx(0.95)), + float(sy(1.0)), + float(sy(10.0)), + float(sy(100.0)), + float(sy(1000.0)), + ) + mid = poly[RIBBON_STEPS // 2] # upper edge, mid-flow, already in pixels + inside = pixels[int(mid[1]) + 3, int(mid[0]), 0] + assert inside < 128, "no ink inside the transformed-space cubic's upper edge" + + # A data-space cubic evaluated at mid-flow sits at (10 + 1000)/2 = 505, + # log10 = 2.70 — well above the transformed-space midpoint log10 = 2.0 + # (y = 100). Ink there means the raster flattened before transforming. + data_space_mid_y = float(sy((10.0 + 1000.0) / 2.0)) + off_band = pixels[int(data_space_mid_y) + 3, int(mid[0]), 0] + assert off_band >= 128, "ink on the data-space curve: endpoints were not mapped first" + + +def test_exporters_share_the_reference_flattening() -> None: + """`ribbon_polygon` is the single geometry source; its ends must be exactly + the four corners and its width exact at every step.""" + poly = ribbon_polygon(0.0, 1.0, 0.1, 0.3, 0.6, 0.8) + assert poly.shape == (2 * (RIBBON_STEPS + 1), 2) + upper, lower = poly[: RIBBON_STEPS + 1], poly[RIBBON_STEPS + 1 :][::-1] + assert tuple(upper[0]) == pytest.approx((0.0, 0.3)) + assert tuple(upper[-1]) == pytest.approx((1.0, 0.8)) + assert tuple(lower[0]) == pytest.approx((0.0, 0.1)) + assert tuple(lower[-1]) == pytest.approx((1.0, 0.6)) + for i in range(RIBBON_STEPS + 1): + assert upper[i][0] == pytest.approx(lower[i][0], abs=1e-12) + + +def test_live_ribbons_use_smooth_antialiased_edges() -> None: + """Wide, high-contrast flows must not expose polygon-strip corners.""" + assert RIBBON_STEPS >= 96 + root = Path(__file__).resolve().parents[1] + shader = (root / "js/src/40_gl.ts").read_text(encoding="utf-8") + assert "export const RIBBON_STEPS = 96" in shader + assert "fwidth(v_side)" in shader + assert "u_opacity * coverage" in shader + # The outline clause of the ribbon contract: the client draws stroke / + # stroke-width / stroke-opacity, closes the band over its two end faces, + # and matches the band's own fill when no stroke colour was declared. + ribbon_fs = shader.split("RIBBON_FS", 1)[1].split("MESH_VS", 1)[0] + assert "u_strokeWidth" in ribbon_fs + assert "u_strokeOpacity" in ribbon_fs + assert "fwidth(v_t)" in ribbon_fs, "the end faces must join the outline distance" + assert "u_strokeMode == 1 ? v_rgba0" in ribbon_fs, "match-fill outline missing" + + +def test_ribbon_hover_solves_in_axis_transformed_space() -> None: + """The CPU containment test must bisect the same transformed-space cubic + the shader sweeps (ribbon geometry contract): hit-testing raw data values + selects the wrong band on log/symlog axes.""" + root = Path(__file__).resolve().parents[1] + view = (root / "js/src/50_chartview.ts").read_text(encoding="utf-8") + hover = view.split("_ribbonHover(g, dataX, dataY)", 1)[1].split("_buildMeshMark", 1)[0] + assert "this._axisCoord(xAxis" in hover, "pointer/endpoint x must be transformed" + assert "this._axisCoord(yAxis" in hover, "pointer/endpoint y must be transformed" + + +def test_ribbon_hover_uses_the_renderers_sanitized_symlog_constant() -> None: + """Malformed wire input must not make hover solve a different curve than + the shader, which clamps a non-positive symlog constant to one.""" + root = Path(__file__).resolve().parents[1] + view = (root / "js/src/50_chartview.ts").read_text(encoding="utf-8") + hover = view.split("_ribbonHover(g, dataX, dataY)", 1)[1].split("_buildMeshMark", 1)[0] + assert "constant: this._axisConstant(g.xAxis)" in hover + assert "constant: this._axisConstant(g.yAxis)" in hover + + +def test_svg_ribbon_interpolates_endpoint_alpha_per_stop() -> None: + """Ends differing only in alpha still ramp: the gradient carries per-stop + stop-opacity instead of flattening both ends to the source's alpha.""" + f = Figure(width=420, height=300) + f.ribbon( + [0.1], + [0.9], + [0.3], + [0.5], + [0.4], + [0.6], + color="rgba(20,40,60,0.9)", + color_target="rgba(20,40,60,0.2)", + ) + doc = f.to_image(format="svg").decode() + assert 'stop-opacity="0.9"' in doc + assert 'stop-opacity="0.2"' in doc + band = re.search(r']*)/>', doc) + assert band is not None + assert "fill-opacity" not in band.group(1), "path-level opacity would flatten the ramp" + + +def test_raster_ribbon_ramps_alpha_along_the_flow() -> None: + """The PNG's ink must fade with the target end's alpha, matching the SVG's + per-stop opacity and the client's per-fragment RGBA mix.""" + from test_png_export import _decode_rgba + + f = Figure(width=400, height=300) + f.set_axis("x", domain=(0.0, 1.0), tick_label_strategy="none") + f.set_axis("y", domain=(0.0, 1.0), tick_label_strategy="none") + f.ribbon( + [0.05], + [0.95], + [0.4], + [0.6], + [0.4], + [0.6], + color="rgba(0,0,0,1.0)", + color_target="rgba(0,0,0,0.08)", + ) + pixels = _decode_rgba(f.to_image(format="png", scale=1)) + + from xy._svg import _Scale, layout + + spec, _ = f.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_row = int(float(sy(0.5))) + source_ink = pixels[mid_row, int(float(sx(0.1))), 0] + target_ink = pixels[mid_row, int(float(sx(0.9))), 0] + assert source_ink < 90, "the source end must stay near-opaque" + assert target_ink > 180, "the target end must fade over the page" + + +def test_ribbon_stroke_defaults_to_each_bands_own_colour_in_svg() -> None: + """`stroke_width` without a stroke colour must still outline, and must + match EACH band's fill: a per-band ribbon has no single trace colour, so + one shared fallback would outline every flow in a colour it never uses.""" + f = Figure(width=420, height=300) + f.ribbon( + [0.1, 0.1], + [0.9, 0.9], + [0.0, 0.5], + [0.2, 0.7], + [0.1, 0.6], + [0.3, 0.8], + color=["#7c3aed", "#0891b2"], + stroke_width=2.0, + ) + doc = f.to_image(format="svg").decode() + strokes = re.findall(r']*stroke="([^"]+)" stroke-width="2"', doc) + assert strokes == ["rgb(124,58,237)", "rgb(8,145,178)"], strokes + + +def test_ribbon_raster_outline_matches_each_bands_own_colour() -> None: + """The PNG outline follows the band paint too, so an implicit outline is + the same colour in both exporters rather than one arbitrary fallback. + + Compared against an otherwise identical `stroke_width=0` render so the + assertion sees *only* outline ink: a whole-image hue scan is satisfied by + the fills alone and would keep passing with every outline painted one + shared fallback colour.""" + import numpy as np + + from test_png_export import _decode_rgba + + def render(stroke_width: float) -> np.ndarray: + f = Figure(width=420, height=320) + f.set_axis("x", domain=(0.0, 1.0), tick_label_strategy="none") + f.set_axis("y", domain=(0.0, 1.0), tick_label_strategy="none") + # Two flat bands, well separated, each a saturated primary: the + # outline pixels must carry the band's own hue, not a shared blue-gray. + f.ribbon( + [0.1, 0.1], + [0.9, 0.9], + [0.05, 0.6], + [0.3, 0.85], + [0.05, 0.6], + [0.3, 0.85], + color=["#ff0000", "#00ff00"], + stroke_width=stroke_width, + opacity=1.0, + ) + return _decode_rgba(f.to_image(format="png", scale=1)).astype(int) + + outlined = render(3.0) + changed = (outlined != render(0.0)).any(axis=2) + assert changed.any(), "a 3px outline must change some pixels" + reds, greens = outlined[:, :, 0], outlined[:, :, 1] + # The outline straddles the band edge, so its outer half lands on fresh + # background pixels in each band's own hue; a shared fallback would leave + # one hue missing from the changed set. + assert (changed & (reds > 180) & (greens < 90)).any(), "no red outline ink" + assert (changed & (greens > 180) & (reds < 90)).any(), "no green outline ink" + + +def test_ribbon_style_stroke_compiles_to_the_outline_not_the_fill() -> None: + """style={'stroke': ...} must reach the trace outline; before the ribbon + kinds joined the stroke target sets it silently repainted the band.""" + from xy import styles + + css = styles.compile_mark_style( + "ribbon", {"stroke": "#112233", "stroke-width": "2px", "stroke-opacity": 0.5} + ) + assert css == {"stroke": "#112233", "stroke_width": 2.0, "stroke_opacity": 0.5} + + f = Figure(width=420, height=300) + f.ribbon( + [0.1], + [0.9], + [0.3], + [0.5], + [0.4], + [0.6], + color="#7c3aed", + style={"stroke": "#112233", "stroke-width": 2, "stroke-opacity": 0.5}, + ) + trace_style = f.traces[-1].style + assert trace_style["stroke"] == "#112233" + assert trace_style["stroke_width"] == 2.0 + assert trace_style["stroke_opacity"] == 0.5 + doc = f.to_image(format="svg").decode() + assert 'fill="rgb(124,58,237)"' in doc, "the outline must not repaint the band" + assert re.search(r'stroke="#112233" stroke-width="2" stroke-opacity="0.5"', doc) diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index 636fee84..b28417f9 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -17,6 +17,8 @@ MARK_FACTORIES = ( "scatter", + "ribbon", + "sankey", "line", "area", "histogram", diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index 19b954dc..22b58d06 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -240,6 +240,80 @@ def test_tooltip_labels_and_semantic_slots_are_independently_styleable(tmp_path: assert result["valueAlign"] == "right", result +def test_sankey_tooltips_describe_flows_and_nodes(tmp_path: Path) -> None: + chart = xy.sankey_chart( + [ + ("Visitors", "Checkout", 42), + ("Visitors", "Browse", 58), + ("Browse", "Checkout", 21), + ], + width=520, + height=320, + ) + script = ( + _PRELUDE + + """ + const linkTrace = view.gpuTraces.find( + (trace) => Array.isArray(trace.tooltipRows) && trace.tooltipRows[0].source + ); + const nodeTrace = view.gpuTraces.find( + (trace) => Array.isArray(trace.tooltipRows) && trace.tooltipRows[0].node + ); + if (!linkTrace || !nodeTrace) throw new Error("semantic Sankey rows missing"); + const linkRow = view._localRow({g: linkTrace, index: 0}); + const nodeRow = view._localRow({g: nodeTrace, index: 0}); + const linkLines = view._tooltipLines(view._tooltipItems(linkRow)); + const nodeLines = view._tooltipLines(view._tooltipItems(nodeRow)); + view._renderTooltip(linkRow, 200, 120, {announce: false}); + const linkText = view.tooltip.textContent; + view._renderTooltip(nodeRow, 200, 120, {announce: false}); + const nodeText = view.tooltip.textContent; + const hit = {g: linkTrace, trace: 0, index: 0}; + const canvasRect = view.canvas.getBoundingClientRect(); + const firstClientX = canvasRect.left + view.plot.x + 30; + const firstClientY = canvasRect.top + view.plot.y + 30; + view._setTooltipAnchor(hit, linkRow, firstClientX, firstClientY); + const firstAnchor = view._tooltipAnchorPx(); + view._hoverId = 0; + view._hoverTarget = hit; + view._lastRow = linkRow; + view._pickAt = () => hit; + view._hoverAt = () => null; + view._hover({ + clientX: firstClientX + 90, + clientY: firstClientY + 25, + }); + const movedAnchor = view._tooltipAnchorPx(); + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + linkRow, + nodeRow, + linkLines, + nodeLines, + linkText, + nodeText, + anchorDelta: { + x: movedAnchor.lx - firstAnchor.lx, + y: movedAnchor.ly - firstAnchor.ly, + }, + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "sankey semantic tooltips") + + assert result["linkRow"]["source"] == "Visitors", result + assert result["linkRow"]["target"] == "Checkout", result + assert result["linkRow"]["value"] == 42, result + assert result["linkLines"] == ["Visitors → Checkout", "Flow: 42"], result + assert result["nodeRow"]["node"] == "Visitors", result + assert result["nodeRow"]["value"] == 100, result + assert result["nodeLines"] == ["Visitors", "Total flow: 100"], result + assert result["linkText"] == "Visitors → CheckoutFlow42", result + assert result["nodeText"] == "VisitorsTotal flow100", result + assert result["anchorDelta"]["x"] == pytest.approx(90), result + assert result["anchorDelta"]["y"] == pytest.approx(25), result + + def test_tooltip_labels_customize_default_channel_rows(tmp_path: Path) -> None: data = { "quarter": [1, 2],