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],