From 41cf87e17da325016bb0cd041e1c210a564bd9a3 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 28 Jul 2026 18:35:31 -0700 Subject: [PATCH 1/6] Sankey: ribbon primitive + layout engine (protocol v11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-class Sankey, in two halves. The `ribbon` primitive: a flow band between two vertical spans, carrying a colour at each end with the gradient running along the flow. It is a new mark kind because no composition can express it: the seam-free triangle_mesh path in both exporters is gated on one uniform fill, so per-triangle colour reintroduces an antialiasing seam on every shared edge, and the client's MESH_VS reads colour per instance, so a mesh is flat-shaded by construction. The chart-kind contract gains a normative ribbon geometry section (wire shape, the curveBumpX cubic with control points at the horizontal midpoint, flow-axis paint, deferred picking) and its "a ribbon is plugin territory" sentence is amended with the evidence. Renderers: SVG emits exact cubics with a new two-point user-space gradient helper (already inside the PDF converter's allowlist); the raster flattens through _scene.ribbon_polygon — the single geometry reference the golden tests pin both exporters to — and paints through cmd.grad's arbitrary gradient vector; the client sweeps an instanced triangle strip of the same 24 segments per edge, mixing the two end colours by flow progress. Hover resolves on the CPU by bisecting the monotone cubic at the cursor's x and testing containment; picking stays off (the id pass is point-geometry only), so selection is absent rather than wrong. The layout (python/xy/_sankey.py): longest-path layering with cycle refusal that names the cycle, node value = max(inflow, outflow), alternating barycentre sweeps for crossing minimisation, value-proportional heights on one shared scale, and endpoint stacking ordered by the opposite end. Nodes draw as a second ribbon trace — a band whose two spans are equal is a rectangle — so the whole diagram is one primitive. Public surface: xy.ribbon, xy.sankey, xy.sankey_chart(links) with hidden axes and a y-inverted unit box. PROTOCOL_VERSION 10 -> 11 in lockstep with the client: markOf() falls back to scatter for unknown kinds, so a v10 client would silently render ribbons as a point cloud. NOTE: PR #370 (polar) also claims protocol v11 off the same base; whichever merges second rebases to v12. Same for the roadmap rows. Tests: 18 new (layout invariants, refusal messages, wire shape, golden geometry incl. a rect-fall-through tripwire and a raster ink probe against the reference polygon). Suite 3,466 passing, ruff clean, capability matrix regenerated. Verified visually in all three renderers. Deferred, recorded in the contract: GPU picking, hover highlight, legend swatches for two-colour bands, style.fill gradients on ribbon, cycle auto-breaking, LOD, matplotlib.sankey shim (recorded in shim-todo.md). --- docs/styling/capabilities.md | 12 +- js/src/00_header.ts | 5 +- js/src/40_gl.ts | 61 ++++ js/src/50_chartview.ts | 118 +++++++- js/src/55_marks.ts | 20 ++ python/xy/__init__.py | 6 + python/xy/_figure.py | 6 + python/xy/_payload.py | 51 ++++ python/xy/_raster.py | 76 +++++ python/xy/_sankey.py | 343 +++++++++++++++++++++++ python/xy/_scene.py | 47 ++++ python/xy/_svg.py | 108 +++++++ python/xy/_trace.py | 6 + python/xy/components.py | 215 ++++++++++++++ python/xy/config.py | 7 +- python/xy/marks.py | 235 ++++++++++++++++ python/xy/styles.py | 11 + spec/api/capability-matrix.md | 12 +- spec/api/chart-kind-contract.md | 70 ++++- spec/api/chart-roadmap.md | 4 +- spec/api/styling.md | 1 + spec/design/wire-protocol.md | 4 +- spec/matplotlib/shim-todo.md | 5 + tests/pyplot/test_tick_side_rendering.py | 2 +- tests/test_api_parity.py | 4 + tests/test_sankey.py | 220 +++++++++++++++ tests/test_type_surface.py | 2 + 27 files changed, 1626 insertions(+), 25 deletions(-) create mode 100644 python/xy/_sankey.py create mode 100644 tests/test_sankey.py 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/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..c0806574 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,63 @@ 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. +export const RIBBON_STEPS = 24; + +// 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, which equals the exporters' +// data-space evaluation under affine axes. 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; +${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); +}`; + +export const RIBBON_FS = `#version 300 es +precision highp float; +uniform float u_opacity; +in vec4 v_rgba; +out vec4 outColor; +void main() { + float alpha = v_rgba.a * u_opacity; + outColor = vec4(v_rgba.rgb * alpha, alpha); +}`; + 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..6c3bca4f 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,116 @@ 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; + } + + _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); + gl.uniform1i(u("u_segments"), RIBBON_STEPS); + gl.uniform1f(u("u_opacity"), this._fillOpacity(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; + // _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); + for (let index = 0; index < g.n; index++) { + const x0 = val("x0", index); + const x1 = val("x1", index); + const lo0 = Math.min(x0, x1); + const hi0 = Math.max(x0, x1); + if (dataX < lo0 || dataX > 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) < dataX) === 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 * val("y0", index) + w1 * val("t0", index); + const edgeHi = w0 * val("y1", index) + w1 * val("t1", index); + if (dataY >= Math.min(edgeLo, edgeHi) && dataY <= 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 +6404,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; diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index f52f0bc5..8ac9c735 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -166,6 +166,26 @@ 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); + }, + }, error_band: AREA_MARK, hexbin: { build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer), diff --git a/python/xy/__init__.py b/python/xy/__init__.py index de2b35cf..bef804de 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", 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..e697fb35 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -797,6 +797,57 @@ 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 + ) + self._ship_trace_styles(entry, t, sel_arg, pw) + return self._transition_entry(entry, t, 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..765134c1 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,78 @@ 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. `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) + stroke_c = _parse_color(_css(style.get("stroke"), color)) if stroke_width > 0 else None + + for i in range(n): + poly_data = _scene.ribbon_polygon( + float(x0v[i]), + float(x1v[i]), + float(slo[i]), + float(shi[i]), + float(tlo[i]), + float(thi[i]), + ) + px = sx(poly_data[:, 0]) + py = sy(poly_data[:, 1]) + if not (np.all(np.isfinite(px)) and np.all(np.isfinite(py))): + continue + poly = list(zip(px.tolist(), py.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]) + if a[:3] == b[:3]: + 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. + gx0, gx1 = float(sx(x0v[i])), float(sx(x1v[i])) + gy = float(py[0]) + cmd.grad(poly, (gx0, gy), (gx1, gy), [(0.0, a), (1.0, b)]) + if stroke_c is not None: + cmd.stroke([*poly, poly[0]], stroke_width, stroke_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..33ce1d82 --- /dev/null +++ b/python/xy/_sankey.py @@ -0,0 +1,343 @@ +"""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 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({n for n in names if names.count(n) > 1}) + if duplicates: + raise ValueError(f"sankey nodes must be unique; repeated: {duplicates}") + else: + names = [] + for source, target, _value in links: + for endpoint in (str(source), str(target)): + if endpoint not in names: + names.append(endpoint) + return names, {name: i for i, name in enumerate(names)} + + +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 = [n.index for n in nodes if indegree[n.index] == 0] + placed = 0 + while queue: + current = queue.pop(0) + 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 = sorted(nodes[i].name for i in range(len(nodes)) if indegree[i] > 0) + 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 _align(nodes: list[SankeyNode], links: list[SankeyLink], layers: int, alignment: str) -> None: + """Push sinks rightward for the flush right edge `justify` implies.""" + if alignment != "justify" or layers < 2: + return + for node in nodes: + if not node.outgoing: + node.layer = layers - 1 + + +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 column in columns: + if not column: + continue + 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..2be49119 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -22,6 +22,53 @@ # 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. +RIBBON_STEPS = 24 + + +def ribbon_edge( + x0: float, x1: float, ya: float, yb: float, steps: int = RIBBON_STEPS +) -> np.ndarray: + """One edge of a flow band, in data 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`). 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 data 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..af34fb1c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1347,6 +1347,30 @@ 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]] + ) -> 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. + `userSpaceOnUse` is already 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 in stops: + escaped = escape(color, {chr(34): """}) + 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 +2768,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 +3937,84 @@ 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) + + 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] + same = all(abs(float(a[k]) - float(b[k])) < 1e-9 for k in range(3)) + if same: + paint = f'fill="{rgb(a)}"' + else: + ramp = svg.gradient_vector(px0, 0.0, px1, 0.0, [(0.0, rgb(a)), (1.0, rgb(b))]) + paint = f'fill="{ramp}"' + # effective_rgba already folded the trace opacity into the channel + # alpha; folding _fill_opacity in again squared it (0.4 -> 0.16). + alpha = float(a[3]) + attrs = paint + (f' fill-opacity="{_num(alpha)}"' if alpha < 1 else "") + if stroke_width > 0 and stroke_css: + attrs += ( + f' stroke="{escape(_css(stroke_css, fallback))}" ' + f'stroke-width="{_num(stroke_width)}" ' + ) + 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..7ba2ee31 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -51,6 +51,12 @@ 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 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/marks.py b/python/xy/marks.py index a2687739..726cf113 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -406,6 +406,241 @@ 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). + """ + 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) + style_channels: dict[str, channels.StyleChannel] = {} + opacity_value = _direct_style( + opacity, + n, + "ribbon opacity", + style_channels, + "opacity", + default=1.0, + minimum=0.0, + maximum=1.0, + ) + stroke_value, stroke_ch = _stroke_channel(stroke, n, "ribbon stroke") + stroke_width_value = _direct_style( + stroke_width, + n, + "ribbon stroke_width", + style_channels, + "stroke_width", + default=0.0, + minimum=0.0, + ) + color_ch = 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_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, + stroke_ch=stroke_ch, + style_channels=style_channels, + 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)] + + link_alpha = float(link_opacity) + 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, + ) + # 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, + ) + 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..41c657bd 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", 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..542e0c64 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,57 @@ 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 | | +| `color_target` | channel record for the **target** end; absent means flat, painted with `color` | | + +**The curve.** A cubic in *data space* with both control points at the +horizontal midpoint `xm = (x0 + x1) / 2`, each holding its own end's y — d3's +`curveBumpX`. The band therefore leaves and arrives horizontally, and its width +is measured vertically the whole way across. Exact under affine axes. + +``` +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 24 steps; the client sweeps a triangle strip of +the same 24 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). When the two ends resolve to the same colour the +renderers must emit a flat fill, not a two-stop gradient, so a plain Sankey +stays cheap in every output format. + +**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. + #### Shared-geometry marks: the hexbin centers-only contract A mark whose cells all share one geometry ships **centers plus channels**, not @@ -211,9 +262,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..67b115b7 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 | — | — | is a fill | ✅ | ✅ | ✅ `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..2f5565ff 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 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..64f68bf2 --- /dev/null +++ b/tests/test_sankey.py @@ -0,0 +1,220 @@ +"""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 + +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)]) + + +@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: + spec, _ = xy.sankey_chart(LINKS, width=680, height=420).figure().build_payload_split() + assert [t["kind"] for t in spec["traces"]] == ["ribbon", "ribbon"] + assert spec["protocol"] == 11 + + +# -- 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_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) 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", From 4b3955812d4c3c350f43ad9bd50cab6446caeac0 Mon Sep 17 00:00:00 2001 From: Alek Date: Wed, 29 Jul 2026 13:51:21 -0700 Subject: [PATCH 2/6] Improve Sankey docs and interactions --- docs/app/scripts/check_html_routes.py | 8 +- docs/app/tests/test_docs_site.py | 26 +- docs/app/xy_docs/api_reference.py | 8 +- docs/app/xy_docs/config.py | 1 + docs/app/xy_docs/gallery.py | 4 + docs/charts/sankey.md | 374 ++++++++++++++++++++++++++ docs/components/marks.md | 2 + docs/overview/gallery.md | 3 +- docs/styling/mark-styles.md | 1 + js/src/40_gl.ts | 17 +- js/src/50_chartview.ts | 12 +- js/src/52_tooltip.ts | 31 ++- js/src/60_entries.ts | 6 + python/xy/_payload.py | 10 +- python/xy/_scene.py | 5 +- python/xy/_trace.py | 5 + python/xy/interaction.py | 4 + python/xy/marks.py | 11 + spec/api/chart-kind-contract.md | 10 +- tests/test_sankey.py | 33 ++- tests/test_ui_issue_regressions.py | 74 +++++ 21 files changed, 616 insertions(+), 29 deletions(-) create mode 100644 docs/charts/sankey.md 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..f2be34d8 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,7 @@ xy.stairs, xy.stem, xy.segments, + 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..18876024 --- /dev/null +++ b/docs/charts/sankey.md @@ -0,0 +1,374 @@ +--- +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"`. 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..efdf0ca4 100644 --- a/docs/components/marks.md +++ b/docs/components/marks.md @@ -20,6 +20,7 @@ components: - xy.stairs - xy.stem - xy.segments + - xy.sankey - xy.triangle_mesh --- @@ -80,6 +81,7 @@ built. | Density and grids | `hexbin`, `heatmap`, `contour` | | Uncertainty | `errorbar`, `error_band` | | Explicit geometry | `stem`, `segments`, `triangle_mesh` | +| Directed flows | `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/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/40_gl.ts b/js/src/40_gl.ts index c0806574..eee74c49 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -656,8 +656,10 @@ void main() { // 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. -export const RIBBON_STEPS = 24; +// 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 @@ -676,6 +678,7 @@ uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_t0meta; uniform vec uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform int u_segments; out vec4 v_rgba; +out float v_side; ${AXIS_GLSL} void main() { float X0 = xyMap(ax0, u_xmap, u_x0meta, u_xmode, u_xconstant); @@ -699,15 +702,23 @@ void main() { // 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); + v_side = side; }`; export const RIBBON_FS = `#version 300 es precision highp float; uniform float u_opacity; in vec4 v_rgba; +in float v_side; out vec4 outColor; void main() { - float alpha = v_rgba.a * u_opacity; + // 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 edge = min(v_side, 1.0 - v_side); + float coverage = smoothstep(0.0, max(fwidth(v_side), 1e-5), edge); + float alpha = v_rgba.a * u_opacity * coverage; + if (alpha <= 0.001) discard; outColor = vec4(v_rgba.rgb * alpha, alpha); }`; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 6c3bca4f..71474d19 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3861,6 +3861,7 @@ export class ChartView { g.colorTarget = t.color_target ? parseColor(this.root, t.color_target.color, g.color) : null; + g.tooltipRows = Array.isArray(t.tooltip_rows) ? t.tooltip_rows : null; } _drawRibbons(g, xm, ym) { @@ -6569,8 +6570,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; 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/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/_payload.py b/python/xy/_payload.py index e697fb35..f0770e14 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -845,8 +845,16 @@ def _emit_ribbon( 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, sel_arg) + 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 diff --git a/python/xy/_scene.py b/python/xy/_scene.py index 2be49119..7e087948 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -25,8 +25,9 @@ # 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. -RIBBON_STEPS = 24 +# 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( diff --git a/python/xy/_trace.py b/python/xy/_trace.py index 7ba2ee31..f26805db 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -63,6 +63,11 @@ class Trace: # 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/interaction.py b/python/xy/interaction.py index ffffc036..74f82d5e 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -260,6 +260,10 @@ def row_dict(fig: "Figure", t: "Trace", idx: int) -> dict[str, Any]: sc = t.size_ch if sc and sc.mode == "continuous" and sc.values is not None: out["size_value"] = _json_scalar(float(sc.values[idx])) + if t.tooltip_rows is not None and idx < len(t.tooltip_rows): + for key, value in t.tooltip_rows[idx].items(): + if key not in out: + out[key] = _json_scalar(value) return out diff --git a/python/xy/marks.py b/python/xy/marks.py index 726cf113..f3623a0e 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -610,6 +610,14 @@ def sankey( 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( @@ -622,6 +630,9 @@ def sankey( 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: diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 542e0c64..6acc9fd4 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -79,6 +79,7 @@ implements it without reading the other three. | `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 | | | `color_target` | channel record for the **target** end; 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 curve.** A cubic in *data space* with both control points at the horizontal midpoint `xm = (x0 + x1) / 2`, each holding its own end's y — d3's @@ -91,8 +92,8 @@ 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 24 steps; the client sweeps a triangle strip of -the same 24 segments. Both consume the same Python reference, +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. @@ -106,7 +107,10 @@ stays cheap in every output format. **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. +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 diff --git a/tests/test_sankey.py b/tests/test_sankey.py index 64f68bf2..ee248fea 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -12,6 +12,7 @@ import re from itertools import pairwise +from pathlib import Path import pytest @@ -142,8 +143,28 @@ def test_ribbon_autorange_covers_both_spans() -> None: def test_sankey_chart_builds_ribbon_traces_only() -> None: - spec, _ = xy.sankey_chart(LINKS, width=680, height=420).figure().build_payload_split() + 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 assert spec["protocol"] == 11 @@ -218,3 +239,13 @@ def test_exporters_share_the_reference_flattening() -> None: 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 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], From 91655cc7e12066f6e6d897b538c5cc1e27f1aa13 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 04:21:00 +0000 Subject: [PATCH 3/6] Sankey/ribbon review fixes: axis modes, outlines, alpha ramps, alignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the ribbon primitive and Sankey layout: - WebGL: _drawRibbons writes the shared u_xmode/u_xconstant/u_ymode/ u_yconstant uniforms RIBBON_VS reads, so log/symlog axes no longer render ribbons with linear geometry; theme refresh re-resolves color_target and the outline stroke, not just the source end. - WebGL outlines: RIBBON_FS draws stroke/stroke-width/stroke-opacity as an inset border along the curved edges — the capability registry claimed webgl=full for stroke while the client drew nothing. The end-face difference vs the exporters' closed-path stroke is recorded in the divergence registry and the ribbon contract. - styles: ribbon joins the stroke/stroke-width target sets, so style={"stroke": ...} reaches the outline instead of repainting the band, and stroke-width is no longer dropped. - SVG/raster parity: stroke_width without a stroke colour falls back to the trace colour in SVG (the raster already did); both exporters fold opacity*stroke_opacity into the outline; flat-fill collapse compares all four channels, and endpoint alphas interpolate along the flow (SVG per-stop stop-opacity, raster RGBA gradient stops). - Sankey layout: align="right" and align="center" are implemented (d3-sankey semantics) instead of silently behaving as "left"; an over-packed node_padding is refused by name instead of inverting every span through a negative shared scale. - interaction: semantic tooltip rows replace the x/y placement projection in pick results, per the contract; the contract records that tooltip_rows values are deliberately JSON scalars (small-N semantic readouts, not bulk geometry). - docs/spec: xy.ribbon exposed in the marks API reference and marks.md; styling.md ribbon row corrects the Stroke/Dash cells; sankey.md fence style (MD048) and alignment prose; capability matrix regenerated for the new divergence entry. --- docs/app/xy_docs/api_reference.py | 1 + docs/charts/sankey.md | 16 ++-- docs/components/marks.md | 3 +- docs/styling/capabilities.md | 1 + js/src/40_gl.ts | 19 ++++- js/src/50_chartview.ts | 16 ++++ js/src/55_marks.ts | 7 ++ python/xy/_raster.py | 10 ++- python/xy/_sankey.py | 58 +++++++++++-- python/xy/_svg.py | 45 +++++++--- python/xy/interaction.py | 16 ++-- python/xy/styles.py | 4 +- python/xy/styling/capabilities.py | 9 ++ spec/api/capability-matrix.md | 3 +- spec/api/chart-kind-contract.md | 19 ++++- spec/api/styling.md | 2 +- tests/test_sankey.py | 136 ++++++++++++++++++++++++++++++ 17 files changed, 321 insertions(+), 44 deletions(-) diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index f2be34d8..f61e95fa 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -84,6 +84,7 @@ xy.stairs, xy.stem, xy.segments, + xy.ribbon, xy.sankey, xy.triangle_mesh, ) diff --git a/docs/charts/sankey.md b/docs/charts/sankey.md index 18876024..55383dad 100644 --- a/docs/charts/sankey.md +++ b/docs/charts/sankey.md @@ -66,12 +66,12 @@ 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 +~~~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. @@ -302,9 +302,11 @@ 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"`. Alternating barycenter -sweeps reduce crossings; increase `iterations` for a denser graph when the -extra layout work improves the result. +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 @@ -331,12 +333,12 @@ 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 +~~~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 diff --git a/docs/components/marks.md b/docs/components/marks.md index efdf0ca4..1312cbda 100644 --- a/docs/components/marks.md +++ b/docs/components/marks.md @@ -20,6 +20,7 @@ components: - xy.stairs - xy.stem - xy.segments + - xy.ribbon - xy.sankey - xy.triangle_mesh --- @@ -81,7 +82,7 @@ built. | Density and grids | `hexbin`, `heatmap`, `contour` | | Uncertainty | `errorbar`, `error_band` | | Explicit geometry | `stem`, `segments`, `triangle_mesh` | -| Directed flows | `sankey` | +| Directed flows | `ribbon`, `sankey` | The [Chart Gallery](/docs/xy/overview/gallery/) explains expected data shapes and family-specific choices. diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 69529d16..d6c18266 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -115,6 +115,7 @@ an undocumented difference reads as a bug; a documented one is a contract. | what | browser | svg | native png | visible when | |---|---|---|---|---| | Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | +| A ribbon outline at the band's two vertical end faces | absent (the outline is an inset border along the curved edges) | stroked (the closed path includes the end faces) | stroked (the closed path includes the end faces) | stroke on a bare ribbon whose ends do not abut node bands | For what is still alpha, see [Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index eee74c49..463e1b13 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -708,6 +708,7 @@ void main() { 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; in vec4 v_rgba; in float v_side; out vec4 outColor; @@ -715,11 +716,23 @@ 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, max(fwidth(v_side), 1e-5), edge); + float coverage = smoothstep(0.0, w, edge); float alpha = v_rgba.a * u_opacity * coverage; - if (alpha <= 0.001) discard; - outColor = vec4(v_rgba.rgb * alpha, alpha); + vec4 premult = vec4(v_rgba.rgb * alpha, alpha); + if (u_strokeWidth > 0.0) { + // Outline as an inset border along the two curved edges: edge/w is the + // distance to the nearer edge in device pixels (RECT_FS's SDF trick). + // The exporters stroke the closed path; the short end faces abut node + // bands in a Sankey, so that is where the recorded divergence lives. + float strokeAlpha = u_stroke.a * u_strokeOpacity * coverage; + vec4 stroke = vec4(u_stroke.rgb * strokeAlpha, strokeAlpha); + float inner = smoothstep(u_strokeWidth - 0.75, u_strokeWidth + 0.75, edge / w); + premult = mix(stroke, premult, inner); + } + if (premult.a <= 0.001) discard; + outColor = premult; }`; export const MESH_VS = `#version 300 es diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 71474d19..c42cfac9 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3861,6 +3861,11 @@ export class ChartView { g.colorTarget = t.color_target ? parseColor(this.root, t.color_target.color, g.color) : null; + // Outline paint (ribbon geometry contract): an omitted stroke colour + // falls back to the band colour, matching both static exporters. + 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; } @@ -3878,8 +3883,19 @@ export class ChartView { 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 || g.color; + gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); + 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 diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index 8ac9c735..b3c3e154 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -184,6 +184,13 @@ export const MARK_KINDS = { 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, diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 765134c1..792211ec 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2101,7 +2101,11 @@ def read(index: int) -> np.ndarray: else: fills2 = fills stroke_width = float(style.get("stroke_width", 0.0) or 0.0) - stroke_c = _parse_color(_css(style.get("stroke"), color)) if stroke_width > 0 else None + # Outline alpha folds opacity * stroke_opacity, the same stack every other + # stroked mark applies and the SVG writer's stroke-opacity mirrors. + stroke_c = ( + _rgba(style.get("stroke"), color, _stroke_opacity(style)) if stroke_width > 0 else None + ) for i in range(n): poly_data = _scene.ribbon_polygon( @@ -2120,7 +2124,9 @@ def read(index: int) -> np.ndarray: # 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]) - if a[:3] == b[:3]: + # 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 diff --git a/python/xy/_sankey.py b/python/xy/_sankey.py index 33ce1d82..af24fc6d 100644 --- a/python/xy/_sankey.py +++ b/python/xy/_sankey.py @@ -132,13 +132,49 @@ def _assign_layers(nodes: list[SankeyNode], links: list[SankeyLink]) -> int: 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 = [n.index for n in nodes if outdegree[n.index] == 0] + while queue: + current = queue.pop(0) + 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: - """Push sinks rightward for the flush right edge `justify` implies.""" - if alignment != "justify" or layers < 2: + """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 - for node in nodes: - if not node.outgoing: - node.layer = layers - 1 + 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( @@ -206,9 +242,19 @@ def _place( whole point of a Sankey: area is comparable. """ spans = [] - for column in columns: + 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: diff --git a/python/xy/_svg.py b/python/xy/_svg.py index af34fb1c..d6678079 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1348,7 +1348,7 @@ def gradient(self, fill: dict[str, Any], mark_color: str, plot: Optional[dict] = return f"url(#{gid})" def gradient_vector( - self, x0: float, y0: float, x1: float, y1: float, stops: list[tuple[float, str]] + 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). @@ -1356,7 +1356,10 @@ def gradient_vector( 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. - `userSpaceOnUse` is already in the PDF converter's allowlist, so this + 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") @@ -1365,9 +1368,10 @@ def gradient_vector( f'x2="{_num(x1)}" y2="{_num(y1)}"' ) parts = [] - for offset, color in stops: + for offset, color, opacity in stops: escaped = escape(color, {chr(34): """}) - parts.append(f'') + alpha = f' stop-opacity="{_num(opacity)}"' if opacity < 1 else "" + parts.append(f'') self.defs.append(f'{"".join(parts)}') return f"url(#{gid})" @@ -3975,6 +3979,7 @@ def read(index: int) -> np.ndarray: fills2 = fills stroke_css = style.get("stroke") stroke_width = float(style.get("stroke_width", 0.0) or 0.0) + stroke_op = _stroke_opacity(style) def rgb(paint: Any) -> str: return f"rgb({round(paint[0] * 255)},{round(paint[1] * 255)},{round(paint[2] * 255)})" @@ -3996,21 +4001,35 @@ def rgb(paint: Any) -> str: f"C {_num(mid)} {_num(y_tlo)} {_num(mid)} {_num(y_slo)} {_num(px0)} {_num(y_slo)} Z" ) a, b = fills[i], fills2[i] - same = all(abs(float(a[k]) - float(b[k])) < 1e-9 for k in range(3)) - if same: - paint = f'fill="{rgb(a)}"' - else: - ramp = svg.gradient_vector(px0, 0.0, px1, 0.0, [(0.0, rgb(a)), (1.0, rgb(b))]) - paint = f'fill="{ramp}"' + 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 = float(a[3]) - attrs = paint + (f' fill-opacity="{_num(alpha)}"' if alpha < 1 else "") - if stroke_width > 0 and stroke_css: + 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: + # No explicit stroke colour falls back to the trace colour, the + # same rule the raster's _emit_ribbon and the area outline follow. attrs += ( f' stroke="{escape(_css(stroke_css, fallback))}" ' f'stroke-width="{_num(stroke_width)}" ' ) + if stroke_op < 1: + attrs += f'stroke-opacity="{_num(stroke_op)}" ' out.append(f'') return "".join(out) diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 74f82d5e..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])), @@ -260,10 +270,6 @@ def row_dict(fig: "Figure", t: "Trace", idx: int) -> dict[str, Any]: sc = t.size_ch if sc and sc.mode == "continuous" and sc.values is not None: out["size_value"] = _json_scalar(float(sc.values[idx])) - if t.tooltip_rows is not None and idx < len(t.tooltip_rows): - for key, value in t.tooltip_rows[idx].items(): - if key not in out: - out[key] = _json_scalar(value) return out diff --git a/python/xy/styles.py b/python/xy/styles.py index 41c657bd..9c3028e8 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -360,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/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 252bf12c..cdd61432 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -225,6 +225,15 @@ class ExtensionPoint: visible_when="stroke-width above ~4px at a sharp angle", tracked_by="no style property selects a join; the default is the whole contract", ), + RendererDivergence( + id="ribbon_outline_end_faces", + what="A ribbon outline at the band's two vertical end faces", + webgl="absent (the outline is an inset border along the curved edges)", + svg="stroked (the closed path includes the end faces)", + native="stroked (the closed path includes the end faces)", + visible_when="stroke on a bare ribbon whose ends do not abut node bands", + tracked_by="the ribbon geometry contract's Outline clause", + ), ) diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 20d3d844..49aa9a62 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -16,7 +16,7 @@ which is sometimes deliberate, and the notes say which. - **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. +- **2** known default divergence between renderers, listed below rather than left to be discovered. ## Mark style properties @@ -129,6 +129,7 @@ until someone diffs two exports. Listed here for that reason. | what | webgl | svg | native | visible when | tracked by | |---|---|---|---|---|---| | Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | no style property selects a join; the default is the whole contract | +| A ribbon outline at the band's two vertical end faces | absent (the outline is an inset border along the curved edges) | stroked (the closed path includes the end faces) | stroked (the closed path includes the end faces) | stroke on a bare ribbon whose ends do not abut node bands | the ribbon geometry contract's Outline clause | ## Regenerating diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 6acc9fd4..8504a095 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -79,7 +79,7 @@ implements it without reading the other three. | `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 | | | `color_target` | channel record for the **target** end; 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` | | +| `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 *data space* with both control points at the horizontal midpoint `xm = (x0 + x1) / 2`, each holding its own end's y — d3's @@ -100,10 +100,23 @@ 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). When the two ends resolve to the same colour the -renderers must emit a flat fill, not a two-stop gradient, so a plain Sankey +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 band. An omitted stroke colour falls back to the trace +colour (the area-outline rule), and the alpha stack is the stroke colour's own +alpha × `opacity` × `stroke_opacity`, as for every other stroked mark. The +static exporters stroke the closed path; the client draws the outline as an +inset border along the two curved edges (the RECT_FS device-pixel trick), so +the short end faces are the one recorded divergence — in a Sankey they abut +the node bands and are invisible. `stroke-dasharray` is **not** in the ribbon +property set. + **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 diff --git a/spec/api/styling.md b/spec/api/styling.md index 67b115b7..8930840c 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -1215,7 +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 | — | — | is a fill | ✅ | ✅ | ✅ `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/tests/test_sankey.py b/tests/test_sankey.py index ee248fea..a0330283 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -79,6 +79,38 @@ def test_cycles_are_refused_by_name() -> None: compute_layout([("a", "b", 1.0), ("b", "a", 1.0)]) +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"), [ @@ -165,6 +197,10 @@ def test_sankey_chart_builds_ribbon_traces_only() -> None: 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 @@ -249,3 +285,103 @@ def test_live_ribbons_use_smooth_antialiased_edges() -> None: 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 as an inset border, not nothing. + assert "u_strokeWidth" in shader.split("RIBBON_FS", 1)[1].split("MESH_VS", 1)[0] + assert "u_strokeOpacity" in shader.split("RIBBON_FS", 1)[1].split("MESH_VS", 1)[0] + + +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_the_band_colour_in_svg() -> None: + """`stroke_width` without a stroke colour must still outline (the raster + already fell back to the trace colour; the SVG writer dropped it).""" + f = Figure(width=420, height=300) + f.ribbon([0.1], [0.9], [0.3], [0.5], [0.4], [0.6], color="#7c3aed", stroke_width=2.0) + doc = f.to_image(format="svg").decode() + band = re.search(r']*)/>', doc) + assert band is not None + assert re.search(r'stroke="[^"]+" stroke-width="2"', band.group(1)) + + +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) From e4b66b578b45728e9399598062986a4d099fc30f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 18:36:05 +0000 Subject: [PATCH 4/6] Ribbon outlines: match-fill per band, close the band's end faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review pass on the ribbon outline added in 91655cc. An implicit outline (stroke_width with no stroke colour) resolved to a single trace-level colour. For a per-band ribbon that is wrong in every renderer and inconsistent between them: a direct_rgba colour channel ships no `color` field, so the client fell back to the hardcoded blue-gray while the exporters used the palette colour. Adopt the edgecolors="face" convention the point and rect programs already use — an omitted stroke colour matches each band's own source-end paint, flat, in all three renderers (cmd.stroke and SVG's stroke= take one colour per band, so the client must not ramp what the exporters cannot). RIBBON_FS also folds the two vertical end faces into the outline distance via v_t's derivative, so the client strokes the closed band exactly as the exporters' closed path does. That removes the end-face gap entirely, so the ribbon_outline_end_faces divergence entry is gone: a style-selected difference never belonged in a registry documented as default-only, and webgl stroke support is now genuinely full. The capability documents return to their previously generated content. --- docs/styling/capabilities.md | 1 - js/src/40_gl.ts | 29 ++++++++++---- js/src/50_chartview.ts | 9 +++-- python/xy/_raster.py | 25 ++++++++++-- python/xy/_svg.py | 19 ++++++---- python/xy/styling/capabilities.py | 9 ----- spec/api/capability-matrix.md | 3 +- spec/api/chart-kind-contract.md | 19 ++++++---- tests/test_sankey.py | 63 ++++++++++++++++++++++++++----- 9 files changed, 125 insertions(+), 52 deletions(-) diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index d6c18266..69529d16 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -115,7 +115,6 @@ an undocumented difference reads as a bug; a documented one is a contract. | what | browser | svg | native png | visible when | |---|---|---|---|---| | Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | -| A ribbon outline at the band's two vertical end faces | absent (the outline is an inset border along the curved edges) | stroked (the closed path includes the end faces) | stroked (the closed path includes the end faces) | stroke on a bare ribbon whose ends do not abut node bands | For what is still alpha, see [Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 463e1b13..d6e7f827 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -678,7 +678,9 @@ uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_t0meta; uniform vec 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); @@ -702,15 +704,25 @@ void main() { // 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 @@ -722,13 +734,16 @@ void main() { float alpha = v_rgba.a * u_opacity * coverage; vec4 premult = vec4(v_rgba.rgb * alpha, alpha); if (u_strokeWidth > 0.0) { - // Outline as an inset border along the two curved edges: edge/w is the - // distance to the nearer edge in device pixels (RECT_FS's SDF trick). - // The exporters stroke the closed path; the short end faces abut node - // bands in a Sankey, so that is where the recorded divergence lives. - float strokeAlpha = u_stroke.a * u_strokeOpacity * coverage; - vec4 stroke = vec4(u_stroke.rgb * strokeAlpha, strokeAlpha); - float inner = smoothstep(u_strokeWidth - 0.75, u_strokeWidth + 0.75, edge / w); + // 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; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index c42cfac9..e990b14b 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3861,8 +3861,10 @@ export class ChartView { g.colorTarget = t.color_target ? parseColor(this.root, t.color_target.color, g.color) : null; - // Outline paint (ribbon geometry contract): an omitted stroke colour - // falls back to the band colour, matching both static exporters. + // 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; @@ -3892,8 +3894,9 @@ export class ChartView { 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 || g.color; + 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 diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 792211ec..95b59dc2 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2102,9 +2102,21 @@ def read(index: int) -> np.ndarray: 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. + # 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_opacity(style)) if stroke_width > 0 else None + _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): @@ -2134,8 +2146,13 @@ def read(index: int) -> np.ndarray: gx0, gx1 = float(sx(x0v[i])), float(sx(x1v[i])) gy = float(py[0]) cmd.grad(poly, (gx0, gy), (gx1, gy), [(0.0, a), (1.0, b)]) - if stroke_c is not None: - cmd.stroke([*poly, poly[0]], stroke_width, stroke_c) + 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( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index d6678079..b87b7b65 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -3980,6 +3980,10 @@ def read(index: int) -> np.ndarray: 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)})" @@ -4022,14 +4026,13 @@ def rgb(paint: Any) -> str: ) attrs = f'fill="{ramp}"' if stroke_width > 0: - # No explicit stroke colour falls back to the trace colour, the - # same rule the raster's _emit_ribbon and the area outline follow. - attrs += ( - f' stroke="{escape(_css(stroke_css, fallback))}" ' - f'stroke-width="{_num(stroke_width)}" ' - ) - if stroke_op < 1: - attrs += f'stroke-opacity="{_num(stroke_op)}" ' + 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) diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index cdd61432..252bf12c 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -225,15 +225,6 @@ class ExtensionPoint: visible_when="stroke-width above ~4px at a sharp angle", tracked_by="no style property selects a join; the default is the whole contract", ), - RendererDivergence( - id="ribbon_outline_end_faces", - what="A ribbon outline at the band's two vertical end faces", - webgl="absent (the outline is an inset border along the curved edges)", - svg="stroked (the closed path includes the end faces)", - native="stroked (the closed path includes the end faces)", - visible_when="stroke on a bare ribbon whose ends do not abut node bands", - tracked_by="the ribbon geometry contract's Outline clause", - ), ) diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 49aa9a62..20d3d844 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -16,7 +16,7 @@ which is sometimes deliberate, and the notes say which. - **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. -- **2** known default divergence between renderers, listed below rather than left to be discovered. +- **1** known default divergence between renderers, listed below rather than left to be discovered. ## Mark style properties @@ -129,7 +129,6 @@ until someone diffs two exports. Listed here for that reason. | what | webgl | svg | native | visible when | tracked by | |---|---|---|---|---|---| | Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | no style property selects a join; the default is the whole contract | -| A ribbon outline at the band's two vertical end faces | absent (the outline is an inset border along the curved edges) | stroked (the closed path includes the end faces) | stroked (the closed path includes the end faces) | stroke on a bare ribbon whose ends do not abut node bands | the ribbon geometry contract's Outline clause | ## Regenerating diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 8504a095..706cf9b3 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -108,14 +108,17 @@ 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 band. An omitted stroke colour falls back to the trace -colour (the area-outline rule), and the alpha stack is the stroke colour's own -alpha × `opacity` × `stroke_opacity`, as for every other stroked mark. The -static exporters stroke the closed path; the client draws the outline as an -inset border along the two curved edges (the RECT_FS device-pixel trick), so -the short end faces are the one recorded divergence — in a Sankey they abut -the node bands and are invisible. `stroke-dasharray` is **not** in the ribbon -property set. +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. **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 diff --git a/tests/test_sankey.py b/tests/test_sankey.py index a0330283..d0cd2175 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -286,9 +286,13 @@ def test_live_ribbons_use_smooth_antialiased_edges() -> None: 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 as an inset border, not nothing. - assert "u_strokeWidth" in shader.split("RIBBON_FS", 1)[1].split("MESH_VS", 1)[0] - assert "u_strokeOpacity" in shader.split("RIBBON_FS", 1)[1].split("MESH_VS", 1)[0] + # 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_svg_ribbon_interpolates_endpoint_alpha_per_stop() -> None: @@ -346,15 +350,54 @@ def test_raster_ribbon_ramps_alpha_along_the_flow() -> None: assert target_ink > 180, "the target end must fade over the page" -def test_ribbon_stroke_defaults_to_the_band_colour_in_svg() -> None: - """`stroke_width` without a stroke colour must still outline (the raster - already fell back to the trace colour; the SVG writer dropped it).""" +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.9], [0.3], [0.5], [0.4], [0.6], color="#7c3aed", stroke_width=2.0) + 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() - band = re.search(r']*)/>', doc) - assert band is not None - assert re.search(r'stroke="[^"]+" stroke-width="2"', band.group(1)) + 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.""" + from test_png_export import _decode_rgba + + 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=3.0, + opacity=1.0, + ) + pixels = _decode_rgba(f.to_image(format="png", scale=1)) + reds = pixels[:, :, 0].astype(int) + greens = pixels[:, :, 1].astype(int) + # A red-dominant and a green-dominant band both present means each band's + # own paint reached its outline; a shared fallback would tint one of them. + assert ((reds > 180) & (greens < 90)).any(), "no red band ink" + assert ((greens > 180) & (reds < 90)).any(), "no green band ink" def test_ribbon_style_stroke_compiles_to_the_outline_not_the_fill() -> None: From 5f8573b4d5e83b5008a2c692bdcc8b1b8267de96 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 13:14:21 -0700 Subject: [PATCH 5/6] Ribbon review batch: transformed-space cubic, resolved paints, scalar styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cubic is now normative in axis-transformed space (the contract): the raster maps the six endpoints first and flattens the cubic they define, and CPU hover transforms the pointer and endpoints through _axisCoord — matching the SVG exporter's exact pixel-space C and the client's clip-space sweep on log/symlog axes, where flatten-then-map bowed a different curve. d3-sankey and ECharts evaluate curveBumpX the same way; under affine axes nothing changes. Ribbon paints ship resolved (constant or direct_rgba): numeric color encodings sample 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 path — the live chart was silently painting the constant fallback for continuous/categorical ribbons. Per-band opacity/stroke/stroke_width arrays are refused by name instead of shipped-and-dropped; per-band alpha rides the RGBA color rows, which every renderer interpolates. Also: ribbon-owned GPU buffers (t0/t1/rgba/rgba2, plus style/stroke) join the trace teardown list; sankey cycle refusal names only the actual cycle members (iterative Tarjan) instead of everything downstream; both Kahn queues use deques and duplicate detection uses a Counter; sankey link_opacity failures name the argument; ribbon/sankey/sankey_chart join the TYPE_CHECKING imports; wire-protocol §7 gains the v11 entry. The raster outline test now diffs against a stroke_width=0 baseline so it sees only outline ink, and new tests pin the log-axis raster geometry, the factory paint resolution, the per-trace style refusals, and the hover transform. --- js/src/40_gl.ts | 11 +- js/src/50_chartview.ts | 27 +++- python/xy/__init__.py | 3 + python/xy/_raster.py | 33 +++-- python/xy/_sankey.py | 73 +++++++++- python/xy/_scene.py | 13 +- python/xy/channels.py | 34 +++++ python/xy/marks.py | 90 ++++++++----- spec/api/chart-kind-contract.md | 30 ++++- spec/design/wire-protocol.md | 8 +- tests/test_sankey.py | 229 ++++++++++++++++++++++++++++---- 11 files changed, 447 insertions(+), 104 deletions(-) diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index d6e7f827..8530124d 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -665,10 +665,13 @@ export const RIBBON_STEPS = 96; // 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, which equals the exporters' -// data-space evaluation under affine axes. The six mesh attribute slots are -// reused with the ribbon column meaning: ay0/ay1 = source span, ax2/ay2 = -// target span. +// 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; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index e990b14b..3045b70f 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3927,15 +3927,27 @@ export class ChartView { _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); + const yAxis = this._axis(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 = val("x0", index); - const x1 = val("x1", index); + const x0 = xVal("x0", index); + const x1 = xVal("x1", index); const lo0 = Math.min(x0, x1); const hi0 = Math.max(x0, x1); - if (dataX < lo0 || dataX > hi0 || hi0 === lo0) continue; + 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; @@ -3948,14 +3960,14 @@ export class ChartView { const rising = x1 >= x0; for (let step = 0; step < 24; step++) { const mid = (a + b) / 2; - if ((xAt(mid) < dataX) === rising) a = mid; else b = mid; + 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 * val("y0", index) + w1 * val("t0", index); - const edgeHi = w0 * val("y1", index) + w1 * val("t1", index); - if (dataY >= Math.min(edgeLo, edgeHi) && dataY <= Math.max(edgeLo, edgeHi)) { + 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 }; } } @@ -6753,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/python/xy/__init__.py b/python/xy/__init__.py index bef804de..ffc149cc 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -318,6 +318,9 @@ def __dir__() -> list[str]: line_chart, marker, modebar, + ribbon, + sankey, + sankey_chart, scatter, scatter_chart, stairs, diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 95b59dc2..f2e44d38 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -2074,8 +2074,14 @@ def _emit_ribbon( 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. `cmd.grad` takes an arbitrary two-point gradient vector, which - is what lets the ramp follow the flow rather than an axis. + 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"]]) @@ -2120,19 +2126,13 @@ def read(index: int) -> np.ndarray: ) for i in range(n): - poly_data = _scene.ribbon_polygon( - float(x0v[i]), - float(x1v[i]), - float(slo[i]), - float(shi[i]), - float(tlo[i]), - float(thi[i]), - ) - px = sx(poly_data[:, 0]) - py = sy(poly_data[:, 1]) - if not (np.all(np.isfinite(px)) and np.all(np.isfinite(py))): + 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 = list(zip(px.tolist(), py.tolist(), strict=True)) + 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]) @@ -2143,9 +2143,8 @@ def read(index: int) -> np.ndarray: else: # Gradient vector spans the two faces horizontally; the y term is # irrelevant because the ramp is purely along the flow. - gx0, gx1 = float(sx(x0v[i])), float(sx(x1v[i])) - gy = float(py[0]) - cmd.grad(poly, (gx0, gy), (gx1, gy), [(0.0, a), (1.0, b)]) + 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 diff --git a/python/xy/_sankey.py b/python/xy/_sankey.py index af24fc6d..e5d7a0f6 100644 --- a/python/xy/_sankey.py +++ b/python/xy/_sankey.py @@ -32,6 +32,7 @@ from __future__ import annotations import math +from collections import Counter, deque from dataclasses import dataclass, field from typing import Any, Optional @@ -88,18 +89,76 @@ def _resolve_nodes( """ if nodes is not None: names = [str(n) for n in nodes] - duplicates = sorted({n for n in names if names.count(n) > 1}) + 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 names: + 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. @@ -111,10 +170,10 @@ def _assign_layers(nodes: list[SankeyNode], links: list[SankeyLink]) -> int: indegree = [0] * len(nodes) for link in links: indegree[link.target] += 1 - queue = [n.index for n in nodes if indegree[n.index] == 0] + queue = deque(n.index for n in nodes if indegree[n.index] == 0) placed = 0 while queue: - current = queue.pop(0) + current = queue.popleft() placed += 1 for link_index in nodes[current].outgoing: link = links[link_index] @@ -123,7 +182,7 @@ def _assign_layers(nodes: list[SankeyNode], links: list[SankeyLink]) -> int: if indegree[link.target] == 0: queue.append(link.target) if placed != len(nodes): - stuck = sorted(nodes[i].name for i in range(len(nodes)) if indegree[i] > 0) + 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 " @@ -139,9 +198,9 @@ def _heights(nodes: list[SankeyNode], links: list[SankeyLink]) -> list[int]: """ outdegree = [len(node.outgoing) for node in nodes] height = [0] * len(nodes) - queue = [n.index for n in nodes if outdegree[n.index] == 0] + queue = deque(n.index for n in nodes if outdegree[n.index] == 0) while queue: - current = queue.pop(0) + current = queue.popleft() for link_index in nodes[current].incoming: link = links[link_index] height[link.source] = max(height[link.source], height[current] + 1) diff --git a/python/xy/_scene.py b/python/xy/_scene.py index 7e087948..2f30d242 100644 --- a/python/xy/_scene.py +++ b/python/xy/_scene.py @@ -33,13 +33,16 @@ def ribbon_edge( x0: float, x1: float, ya: float, yb: float, steps: int = RIBBON_STEPS ) -> np.ndarray: - """One edge of a flow band, in data space. + """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`). Returned flattened because the raster - display list has no curve opcode; the SVG exporter rebuilds the exact `C` - from the same four numbers. + 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 @@ -58,7 +61,7 @@ def ribbon_polygon( dst_hi: float, steps: int = RIBBON_STEPS, ) -> np.ndarray: - """A whole flow band as one closed polygon in data space. + """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 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/marks.py b/python/xy/marks.py index f3623a0e..8c9f7138 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -430,6 +430,12 @@ def ribbon( 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) @@ -452,33 +458,46 @@ def ribbon( if len(lengths) != 1: raise ValueError(f"ribbon columns must be the same length; got {sorted(lengths)}") n = int(arrays[0].size) - style_channels: dict[str, channels.StyleChannel] = {} - opacity_value = _direct_style( - opacity, - n, - "ribbon opacity", - style_channels, - "opacity", - default=1.0, - minimum=0.0, - maximum=1.0, + # 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") - stroke_width_value = _direct_style( - stroke_width, - n, - "ribbon stroke_width", - style_channels, - "stroke_width", - default=0.0, - minimum=0.0, + 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 ) - color_ch = channels.resolve_color( - color, - n, - colormap=colormap, - default_constant=self.next_series_color, - palette=self.palette, + 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 @@ -486,12 +505,14 @@ def ribbon( color2_ch = ( None if color_target is None - else channels.resolve_color( - color_target, - n, - colormap=colormap, - default_constant=self.next_series_color, - palette=self.palette, + 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() @@ -520,8 +541,6 @@ def ribbon( style=style_dict, color_ch=color_ch, color2_ch=color2_ch, - stroke_ch=stroke_ch, - style_channels=style_channels, count=n, ) ) @@ -590,7 +609,12 @@ def sankey( else: node_css = [self.palette_color(i) for i in range(n_nodes)] - link_alpha = float(link_opacity) + 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]") diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 706cf9b3..ef5e242e 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -77,14 +77,24 @@ implements it without reading the other three. | `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 | | -| `color_target` | channel record for the **target** end; absent means flat, painted with `color` | | +| `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 *data space* with both control points at the -horizontal midpoint `xm = (x0 + x1) / 2`, each holding its own end's y — d3's -`curveBumpX`. The band therefore leaves and arrives horizontally, and its width -is measured vertically the whole way across. Exact under affine axes. +**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) @@ -120,6 +130,14 @@ 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 diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 2f5565ff..15101f7c 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -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/tests/test_sankey.py b/tests/test_sankey.py index d0cd2175..cd799fe4 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -79,6 +79,31 @@ def test_cycles_are_refused_by_name() -> None: 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 @@ -204,6 +229,97 @@ def test_sankey_chart_builds_ribbon_traces_only() -> None: 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 --------------------------------------------------------- @@ -263,6 +379,48 @@ def test_raster_ink_lands_on_the_reference_polygon() -> None: 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.""" @@ -295,6 +453,17 @@ def test_live_ribbons_use_smooth_antialiased_edges() -> None: 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_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.""" @@ -372,32 +541,44 @@ def test_ribbon_stroke_defaults_to_each_bands_own_colour_in_svg() -> None: 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.""" + 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 - 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=3.0, - opacity=1.0, - ) - pixels = _decode_rgba(f.to_image(format="png", scale=1)) - reds = pixels[:, :, 0].astype(int) - greens = pixels[:, :, 1].astype(int) - # A red-dominant and a green-dominant band both present means each band's - # own paint reached its outline; a shared fallback would tint one of them. - assert ((reds > 180) & (greens < 90)).any(), "no red band ink" - assert ((greens > 180) & (reds < 90)).any(), "no green band ink" + 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: From 634aa73ed4bf6de6d0656843a72b775de6787836 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 13:36:42 -0700 Subject: [PATCH 6/6] Fix ribbon hover and raster regression probes --- js/src/50_chartview.ts | 4 ++-- tests/test_sankey.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3045b70f..311b38a9 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3933,8 +3933,8 @@ export class ChartView { // 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); - const yAxis = this._axis(g.yAxis); + 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 diff --git a/tests/test_sankey.py b/tests/test_sankey.py index cd799fe4..ad00daab 100644 --- a/tests/test_sankey.py +++ b/tests/test_sankey.py @@ -417,7 +417,7 @@ def test_raster_ribbon_curves_in_axis_transformed_space_on_log_axes() -> None: # 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] + 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" @@ -464,6 +464,16 @@ def test_ribbon_hover_solves_in_axis_transformed_space() -> None: 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."""