From d9105088ec3b4325720de976fbce1834e5242ea9 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:09:51 -0600 Subject: [PATCH 1/9] feat(catalog): add Apple income Sankey diagram --- API-FRICTION.md | 9 + .../cases/111-sankey-flow/case.json | 31 ++ .../conformance/cases/111-sankey-flow/data.ts | 264 ++++++++++++++++ .../cases/111-sankey-flow/recharts.ts | 219 +++++++++++++ .../cases/111-sankey-flow/tanstack.test.ts | 71 +++++ .../cases/111-sankey-flow/tanstack.ts | 296 ++++++++++++++++++ docs/examples/networks-and-hierarchies.md | 24 ++ examples/conformance/package.json | 2 + package.json | 2 + .../docs/examples/networks-and-hierarchies.md | 24 ++ pnpm-lock.yaml | 87 +++++ scripts/catalog-artifact.mjs | 4 +- 12 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 benchmarks/conformance/cases/111-sankey-flow/case.json create mode 100644 benchmarks/conformance/cases/111-sankey-flow/data.ts create mode 100644 benchmarks/conformance/cases/111-sankey-flow/recharts.ts create mode 100644 benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts create mode 100644 benchmarks/conformance/cases/111-sankey-flow/tanstack.ts diff --git a/API-FRICTION.md b/API-FRICTION.md index 1fe0fe64..cdaf7ce8 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -1001,6 +1001,15 @@ Each entry records: scatter drivers report hex colors while TanStack inspection reads computed RGB; both now pass the six-variant standard paint gate without case-specific color rewriting. +- Sankey evidence: that same six-variant gate passed while the TanStack + implementation used `round` line caps for width-encoded links, producing + large circular endpoint lobes that were absent from the reference chart. + Counts, bounds, paint, and similarity did not encode that cap topology. +- Decision: keep the general gate bounded and make cap topology an explicit + case-level invariant for stroke-width-encoded flows. +- Verification: the Sankey scene regression requires `butt` caps for every + link, so each flow now ends flush with its node instead of expanding into a + circular lobe. ### F-037 — Facets repeat shared axes in every panel diff --git a/benchmarks/conformance/cases/111-sankey-flow/case.json b/benchmarks/conformance/cases/111-sankey-flow/case.json new file mode 100644 index 00000000..bf476b3b --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/case.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1110, + "id": "111-sankey-flow", + "title": "Apple FY22 income statement Sankey", + "family": "network", + "intent": "Trace Apple FY22 product and service revenue through gross profit, operating costs, operating profit, and net profit with flow width proportional to reported value.", + "support": "composed", + "features": [ + "d3-sankey layout", + "responsive node positioning", + "proportional link width", + "direct value labels", + "profit and cost color", + "custom mark" + ], + "geometry": [ + { "role": "link", "count": 17 }, + { "role": "rect", "count": 26 }, + { "role": "text", "count": 37 } + ], + "source": { + "title": "D3 Graph Gallery Sankey Diagram", + "url": "https://d3-graph-gallery.com/sankey.html" + }, + "ai": { + "create": "Recreate the Apple FY22 income statement as a responsive Sankey diagram using explicit node and link records, the official d3-sankey layout, a centered title, direct two-line value labels, neutral revenue paths, green profit paths, and red cost paths.", + "maintain": "Change statement values while preserving unique IDs, flow conservation at every intermediate subtotal, left-aligned sink depths, responsive bounds, semantic profit and cost colors, flat link caps, direct labels, and the direct d3-sankey dependency." + } +} diff --git a/benchmarks/conformance/cases/111-sankey-flow/data.ts b/benchmarks/conformance/cases/111-sankey-flow/data.ts new file mode 100644 index 00000000..992e0046 --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/data.ts @@ -0,0 +1,264 @@ +export type FlowTone = 'Neutral' | 'Profit' | 'Cost' + +export interface FlowNode { + readonly id: string + readonly label: string + readonly compactLabel?: string + readonly displayValue: string + readonly tone: FlowTone + readonly order: number + readonly labelSide: 'left' | 'right' + readonly labelBackdrop?: boolean +} + +export interface FlowLink { + readonly source: string + readonly target: string + readonly value: number + readonly tone: FlowTone +} + +export const incomeStatementTitle = 'Apple FY22 Income Statement' + +export const toneColors = { + Neutral: '#666666', + Profit: '#00b51a', + Cost: '#b50905', +} as const satisfies Record + +export const linkColors = { + Neutral: '#8a8a8a', + Profit: '#50c955', + Cost: '#c96363', +} as const satisfies Record + +export const flowNodes: readonly FlowNode[] = [ + { + id: 'iphone', + label: 'iPhone', + displayValue: '$205.5B', + tone: 'Neutral', + order: 0, + labelSide: 'left', + }, + { + id: 'macbook', + label: 'MacBook', + displayValue: '$40.2B', + tone: 'Neutral', + order: 1, + labelSide: 'left', + }, + { + id: 'ipad', + label: 'iPad', + displayValue: '$29.3B', + tone: 'Neutral', + order: 2, + labelSide: 'left', + }, + { + id: 'wearables', + label: 'Watch and AirPods', + compactLabel: 'Watch + Pods', + displayValue: '$41.2B', + tone: 'Neutral', + order: 3, + labelSide: 'left', + }, + { + id: 'products', + label: 'Products', + displayValue: '$316.2B', + tone: 'Neutral', + order: 0, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'services', + label: 'Services', + displayValue: '$78.2B', + tone: 'Neutral', + order: 1, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'revenue', + label: 'Revenue', + displayValue: '$394.3B', + tone: 'Neutral', + order: 0, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'gross-profit', + label: 'Gross profit', + displayValue: '$170.9B', + tone: 'Profit', + order: 0, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'cost-of-revenue', + label: 'Cost of revenue', + compactLabel: 'Cost of rev.', + displayValue: '$223.5B', + tone: 'Cost', + order: 1, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'operating-profit', + label: 'Operating profit', + compactLabel: 'Op. profit', + displayValue: '$119.5B', + tone: 'Profit', + order: 0, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'operating-expenses', + label: 'Operating expenses', + compactLabel: 'Op. expenses', + displayValue: '$51.4B', + tone: 'Cost', + order: 1, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'product-costs', + label: 'Product costs', + displayValue: '$201.4B', + tone: 'Cost', + order: 2, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'service-costs', + label: 'Service costs', + displayValue: '$22.1B', + tone: 'Cost', + order: 3, + labelSide: 'right', + }, + { + id: 'net-profit', + label: 'Net profit', + displayValue: '$99.8B', + tone: 'Profit', + order: 0, + labelSide: 'right', + }, + { + id: 'tax', + label: 'Tax', + displayValue: '$19.3B', + tone: 'Cost', + order: 1, + labelSide: 'right', + }, + { + id: 'other', + label: 'Other', + displayValue: '$0.3B', + tone: 'Cost', + order: 2, + labelSide: 'right', + }, + { + id: 'research-development', + label: 'R&D', + displayValue: '$26.3B', + tone: 'Cost', + order: 3, + labelSide: 'right', + }, + { + id: 'selling-general-administrative', + label: 'SG&A', + displayValue: '$25.1B', + tone: 'Cost', + order: 4, + labelSide: 'right', + }, +] + +// Apple-reported values in billions retain enough precision for every +// intermediate node to conserve flow; display labels mirror the supplied chart. +export const flowLinks: readonly FlowLink[] = [ + { source: 'iphone', target: 'products', value: 205.489, tone: 'Neutral' }, + { source: 'macbook', target: 'products', value: 40.177, tone: 'Neutral' }, + { source: 'ipad', target: 'products', value: 29.292, tone: 'Neutral' }, + { source: 'wearables', target: 'products', value: 41.241, tone: 'Neutral' }, + { source: 'products', target: 'revenue', value: 316.199, tone: 'Neutral' }, + { source: 'services', target: 'revenue', value: 78.129, tone: 'Neutral' }, + { source: 'revenue', target: 'gross-profit', value: 170.782, tone: 'Profit' }, + { + source: 'revenue', + target: 'cost-of-revenue', + value: 223.546, + tone: 'Cost', + }, + { + source: 'gross-profit', + target: 'operating-profit', + value: 119.437, + tone: 'Profit', + }, + { + source: 'gross-profit', + target: 'operating-expenses', + value: 51.345, + tone: 'Cost', + }, + { + source: 'cost-of-revenue', + target: 'product-costs', + value: 201.471, + tone: 'Cost', + }, + { + source: 'cost-of-revenue', + target: 'service-costs', + value: 22.075, + tone: 'Cost', + }, + { + source: 'operating-profit', + target: 'net-profit', + value: 99.803, + tone: 'Profit', + }, + { + source: 'operating-profit', + target: 'tax', + value: 19.3, + tone: 'Cost', + }, + { + source: 'operating-profit', + target: 'other', + value: 0.334, + tone: 'Cost', + }, + { + source: 'operating-expenses', + target: 'research-development', + value: 26.251, + tone: 'Cost', + }, + { + source: 'operating-expenses', + target: 'selling-general-administrative', + value: 25.094, + tone: 'Cost', + }, +] diff --git a/benchmarks/conformance/cases/111-sankey-flow/recharts.ts b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts new file mode 100644 index 00000000..98125d8b --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts @@ -0,0 +1,219 @@ +import { createElement } from 'react' +import { Sankey } from 'recharts' +import { + flowLinks, + flowNodes, + incomeStatementTitle, + linkColors, + toneColors, +} from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' +import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' + +function chart(input: ConformanceInput) { + const layout = responsiveLayout(input.width, input.height) + const nodeIndexes = new Map(flowNodes.map((node, index) => [node.id, index])) + const renderLink = ({ + sourceX, + sourceY, + sourceControlX, + targetX, + targetY, + targetControlX, + linkWidth, + index, + }: LinkProps) => { + const link = flowLinks[index] + if (!link) return createElement('path') + return createElement('path', { + className: 'recharts-sankey-link', + d: [ + `M${sourceX},${sourceY}`, + `C${sourceControlX},${sourceY}`, + `${targetControlX},${targetY}`, + `${targetX},${targetY}`, + ].join(' '), + fill: 'none', + stroke: linkColors[link.tone], + strokeOpacity: link.tone === 'Neutral' ? 0.58 : 0.64, + strokeWidth: Math.max(1, linkWidth), + strokeLinecap: 'butt', + }) + } + const renderNode = ({ x, y, width, height, index }: NodeProps) => { + const node = flowNodes[index] + if (!node) return createElement('g') + const labelOnRight = node.labelSide === 'right' + const labelX = labelOnRight + ? x + width + layout.labelOffset + : x - layout.labelOffset + const centerY = y + height / 2 + const label = + input.width < 720 && node.compactLabel ? node.compactLabel : node.label + const children = [ + createElement('rect', { + key: 'rect', + className: 'recharts-rectangle', + x, + y, + width, + height: Math.max(1, height), + fill: toneColors[node.tone], + }), + ...(node.labelBackdrop + ? [ + createElement('rect', { + key: 'label-backdrop', + className: 'recharts-rectangle', + ...labelBackdropBounds({ + anchor: labelOnRight ? 'start' : 'end', + centerY, + fontSize: layout.labelFontSize, + label, + labelX, + value: node.displayValue, + }), + rx: 1, + fill: 'var(--panel, #ffffff)', + fillOpacity: 0.82, + }), + ] + : []), + createElement( + 'text', + { + key: 'name', + className: 'recharts-text', + x: labelX, + y: centerY - layout.labelFontSize * 0.5, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 700, + textAnchor: labelOnRight ? 'start' : 'end', + dominantBaseline: 'middle', + }, + label, + ), + createElement( + 'text', + { + key: 'value', + className: 'recharts-text', + x: labelX, + y: centerY + layout.labelFontSize * 0.58, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 500, + textAnchor: labelOnRight ? 'start' : 'end', + dominantBaseline: 'middle', + }, + node.displayValue, + ), + ] + + if (index === 0) { + children.unshift( + createElement( + 'text', + { + key: 'title', + className: 'recharts-text', + x: input.width / 2, + y: layout.titleY, + fill: '#155477', + fontSize: layout.titleFontSize, + fontWeight: 750, + textAnchor: 'middle', + dominantBaseline: 'middle', + }, + incomeStatementTitle, + ), + ) + } + + return createElement('g', null, children) + } + + return createElement(Sankey, { + width: input.width, + height: input.height, + data: { + nodes: flowNodes.map((node) => ({ ...node })), + links: flowLinks.map((link) => ({ + source: requiredNodeIndex(nodeIndexes, link.source), + target: requiredNodeIndex(nodeIndexes, link.target), + value: link.value, + })), + }, + node: renderNode, + link: renderLink, + nodeWidth: layout.nodeWidth, + nodePadding: layout.nodePadding, + iterations: 32, + sort: false, + align: 'left', + verticalAlign: 'justify', + margin: { + top: layout.topMargin, + right: layout.rightMargin, + bottom: layout.bottomMargin, + left: layout.leftMargin, + }, + accessibilityLayer: true, + }) +} + +function responsiveLayout(width: number, height: number) { + return { + leftMargin: clamp(width * 0.15, 56, 122), + rightMargin: clamp(width * 0.13, 48, 105), + topMargin: clamp(height * 0.14, 38, 70), + bottomMargin: clamp(height * 0.025, 8, 14), + nodeWidth: clamp(width * 0.032, 10, 24), + nodePadding: clamp(height * 0.11, 12, 40), + labelFontSize: clamp(width * 0.013, 6.5, 10.5), + labelOffset: clamp(width * 0.008, 3, 6), + titleFontSize: clamp(width * 0.034, 14, 26), + titleY: clamp(height * 0.065, 17, 32), + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} + +function labelBackdropBounds(options: { + anchor: 'start' | 'end' + centerY: number + fontSize: number + label: string + labelX: number + value: string +}) { + const width = + Math.max(options.label.length, options.value.length) * + options.fontSize * + 0.58 + + 5 + const height = options.fontSize * 2.25 + return { + x: + options.anchor === 'start' + ? options.labelX - 2 + : options.labelX - width + 2, + y: options.centerY - height / 2, + width, + height, + } +} + +function requiredNodeIndex(indexes: ReadonlyMap, id: string) { + const index = indexes.get(id) + if (index === undefined) { + throw new TypeError(`Unknown Sankey node "${id}"`) + } + return index +} + +export const mount = rechartsMount(chart, incomeStatementTitle) diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts new file mode 100644 index 00000000..0f1a61ad --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts @@ -0,0 +1,71 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { flowLinks, flowNodes, incomeStatementTitle, linkColors } from './data' +import { sankeyDefinition } from './tanstack' +import type { FlowNode } from './data' +import type { SceneNode } from '@tanstack/charts' + +describe('Apple income statement Sankey composition', () => { + it.each([ + { width: 320, height: 240 }, + { width: 768, height: 500 }, + ])('lays out every node and link inside $width×$height', (size) => { + const runtime = createChartRuntime() + const scene = runtime.render(sankeyDefinition(), size) + const nodes = flatten(scene.nodes) + const links = nodes.filter((node) => node.kind === 'polyline' && node.path) + const rectangles = nodes.filter((node) => node.kind === 'rect') + const labels = nodes.filter((node) => node.kind === 'label') + const labelBackdropCount = flowNodes.filter( + (node) => node.labelBackdrop, + ).length + + expect(links).toHaveLength(flowLinks.length) + expect(links.map((link) => link.style?.lineCap)).toEqual( + Array.from({ length: flowLinks.length }, () => 'butt'), + ) + expect(new Set(links.map((link) => link.style?.stroke))).toEqual( + new Set(Object.values(linkColors)), + ) + expect(rectangles).toHaveLength(flowNodes.length + labelBackdropCount) + expect(labels).toHaveLength(flowNodes.length * 2 + 1) + expect( + labels.some( + (label) => + label.kind === 'label' && label.text === incomeStatementTitle, + ), + ).toBe(true) + expect(scene.points.map((point) => point.datum.id)).toEqual( + flowNodes.map((node) => node.id), + ) + + for (const rectangle of rectangles) { + if (rectangle.kind !== 'rect') continue + expect(rectangle.x).toBeGreaterThanOrEqual(0) + expect(rectangle.y).toBeGreaterThanOrEqual(0) + expect(rectangle.x + rectangle.width).toBeLessThanOrEqual(scene.width) + expect(rectangle.y + rectangle.height).toBeLessThanOrEqual(scene.height) + } + }) + + it('conserves value through every intermediate subtotal', () => { + for (const node of flowNodes) { + const incoming = flowLinks + .filter((link) => link.target === node.id) + .reduce((total, link) => total + link.value, 0) + const outgoing = flowLinks + .filter((link) => link.source === node.id) + .reduce((total, link) => total + link.value, 0) + + if (incoming > 0 && outgoing > 0) { + expect(incoming).toBeCloseTo(outgoing, 6) + } + } + }) +}) + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => + node.kind === 'group' ? [node, ...flatten(node.children)] : [node], + ) +} diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts new file mode 100644 index 00000000..1c4c9734 --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts @@ -0,0 +1,296 @@ +import { defineChart } from '@tanstack/charts' +import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' +import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' +import { + flowLinks, + flowNodes, + incomeStatementTitle, + linkColors, + toneColors, +} from './data' +import { tanstackMount } from '../../shared/mount' +import type { ChartPoint, SceneNode } from '@tanstack/charts' +import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' +import type { FlowLink, FlowNode, FlowTone } from './data' + +const toneDomain = [ + 'Neutral', + 'Profit', + 'Cost', +] as const satisfies readonly FlowTone[] + +export const sankeyDefinition = () => + defineChart({ + marks: [sankeyFlow(flowNodes, flowLinks)], + color: { + domain: toneDomain, + range: toneDomain.map((tone) => toneColors[tone]), + }, + margin: 0, + }) + +function sankeyFlow(nodes: readonly FlowNode[], links: readonly FlowLink[]) { + return createMarkWithScaleValues( + ({ markIndex }) => { + const id = `sankey-${markIndex}` + const sourceNodes = new Map(nodes.map((node) => [node.id, node])) + + return { + id, + channels: { + color: { + scale: 'color', + values: nodes.map((node) => node.tone), + }, + }, + render: ({ chart, color, theme }) => { + const layout = responsiveLayout(chart.width, chart.height) + const graph = sankey() + .nodeId((node) => node.id) + .nodeAlign(sankeyLeft) + .nodeSort((left, right) => left.order - right.order) + .nodeWidth(layout.nodeWidth) + .nodePadding(layout.nodePadding) + .extent([ + [chart.x + layout.leftMargin, chart.y + layout.topMargin], + [ + chart.x + chart.width - layout.rightMargin, + chart.y + chart.height - layout.bottomMargin, + ], + ]) + .iterations(32)(cloneGraph(nodes, links)) + const linkPath = sankeyLinkHorizontal() + const linkNodes: SceneNode[] = [] + const rectNodes: SceneNode[] = [] + const labelNodes: SceneNode[] = [ + { + kind: 'label', + key: `${id}:title`, + x: chart.x + chart.width / 2, + y: chart.y + layout.titleY, + text: incomeStatementTitle, + anchor: 'middle', + baseline: 'middle', + fontSize: layout.titleFontSize, + fontWeight: 750, + style: { fill: '#155477' }, + }, + ] + const points: ChartPoint[] = [] + + for (const link of graph.links) { + const source = resolvedLinkNode(link.source, 'source') + const target = resolvedLinkNode(link.target, 'target') + const path = linkPath(link) + if (path === null) continue + linkNodes.push({ + kind: 'polyline', + key: `${id}:link:${source.id}:${target.id}`, + points: [], + path, + style: { + fill: 'none', + stroke: linkColors[link.tone], + strokeOpacity: link.tone === 'Neutral' ? 0.58 : 0.64, + strokeWidth: Math.max(1, link.width ?? 1), + lineCap: 'butt', + }, + }) + } + + for (const node of graph.nodes) { + const bounds = resolvedNodeBounds(node) + const datum = sourceNodes.get(node.id) + if (!datum) { + throw new TypeError(`Unknown Sankey node "${node.id}"`) + } + const fill = color(node.tone) + const key = `${id}:node:${node.id}` + const centerX = (bounds.x0 + bounds.x1) / 2 + const centerY = (bounds.y0 + bounds.y1) / 2 + const labelOnRight = node.labelSide === 'right' + const labelX = labelOnRight + ? bounds.x1 + layout.labelOffset + : bounds.x0 - layout.labelOffset + const labelAnchor = labelOnRight ? 'start' : 'end' + const label = + chart.width < 720 && node.compactLabel + ? node.compactLabel + : node.label + + rectNodes.push({ + kind: 'rect', + key, + x: bounds.x0, + y: bounds.y0, + width: bounds.x1 - bounds.x0, + height: Math.max(1, bounds.y1 - bounds.y0), + style: { fill }, + }) + if (node.labelBackdrop) { + const backdrop = labelBackdropBounds({ + anchor: labelAnchor, + centerY, + fontSize: layout.labelFontSize, + label, + labelX, + value: node.displayValue, + }) + rectNodes.push({ + kind: 'rect', + key: `${key}:label-backdrop`, + ...backdrop, + radius: 1, + style: { + fill: 'var(--panel, #ffffff)', + fillOpacity: 0.82, + }, + }) + } + labelNodes.push( + { + kind: 'label', + key: `${key}:name`, + x: labelX, + y: centerY - layout.labelFontSize * 0.5, + text: label, + anchor: labelAnchor, + baseline: 'middle', + fontSize: layout.labelFontSize, + fontWeight: 700, + style: { fill: theme.foreground }, + }, + { + kind: 'label', + key: `${key}:value`, + x: labelX, + y: centerY + layout.labelFontSize * 0.58, + text: node.displayValue, + anchor: labelAnchor, + baseline: 'middle', + fontSize: layout.labelFontSize, + fontWeight: 500, + style: { fill: theme.foreground }, + }, + ) + points.push({ + key, + markId: id, + group: node.tone, + groupLabel: node.tone, + datum, + datumIndex: points.length, + xValue: node.id, + yValue: node.value ?? 0, + x: centerX, + y: centerY, + color: fill, + }) + } + + return { + nodes: [ + sceneGroup(`${id}:links`, 'ts-chart__link', linkNodes), + sceneGroup(`${id}:nodes`, 'ts-chart__rect', rectNodes), + sceneGroup(`${id}:labels`, 'ts-chart__text', labelNodes), + ], + points, + } + }, + } + }, + ) +} + +function responsiveLayout(width: number, height: number) { + return { + leftMargin: clamp(width * 0.15, 56, 122), + rightMargin: clamp(width * 0.13, 48, 105), + topMargin: clamp(height * 0.14, 38, 70), + bottomMargin: clamp(height * 0.025, 8, 14), + nodeWidth: clamp(width * 0.032, 10, 24), + nodePadding: clamp(height * 0.11, 12, 40), + labelFontSize: clamp(width * 0.013, 6.5, 10.5), + labelOffset: clamp(width * 0.008, 3, 6), + titleFontSize: clamp(width * 0.034, 14, 26), + titleY: clamp(height * 0.065, 17, 32), + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} + +function labelBackdropBounds(options: { + anchor: 'start' | 'end' + centerY: number + fontSize: number + label: string + labelX: number + value: string +}) { + const width = + Math.max(options.label.length, options.value.length) * + options.fontSize * + 0.58 + + 5 + const height = options.fontSize * 2.25 + return { + x: + options.anchor === 'start' + ? options.labelX - 2 + : options.labelX - width + 2, + y: options.centerY - height / 2, + width, + height, + } +} + +function cloneGraph( + nodes: readonly FlowNode[], + links: readonly FlowLink[], +): SankeyGraph { + return { + nodes: nodes.map((node) => ({ ...node })), + links: links.map((link) => ({ ...link })), + } +} + +function resolvedLinkNode( + node: SankeyLink['source'], + endpoint: 'source' | 'target', +): SankeyNode { + if (typeof node === 'object') return node + throw new TypeError(`Unresolved Sankey link ${endpoint}`) +} + +function resolvedNodeBounds(node: SankeyNode) { + const { x0, x1, y0, y1 } = node + if ( + x0 === undefined || + x1 === undefined || + y0 === undefined || + y1 === undefined + ) { + throw new TypeError(`Sankey node "${node.id}" has no layout bounds`) + } + return { x0, x1, y0, y1 } +} + +function sceneGroup( + key: string, + className: string, + children: readonly SceneNode[], +): SceneNode { + return { + kind: 'group', + key, + className, + ariaHidden: true, + children, + } +} + +export const mount = tanstackMount(sankeyDefinition, incomeStatementTitle, { + format: ({ datum }) => `${datum.label} · ${datum.displayValue}`, +}) diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index 836d002c..0592c8b8 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,7 @@ become less legible than a matrix, grouped summary, or searchable table. | What is the parent-child structure and depth? | Tidy hierarchy tree | | Which positioned observations are spatial neighbors? | Delaunay adjacency network | | Which dependency clusters emerge without fixed positions? | Force-directed network | +| How does value move through staged subtotals? | Sankey flow diagram | | How large are branches within a strict hierarchy? | Packed or rectangular hierarchy | | Must many entities be compared by attributes, not connections? | A table, facets, or quantitative chart | @@ -26,6 +27,29 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. +## Trace value through staged totals + +A Sankey diagram makes conservation and decomposition visible at the same +time: link width carries quantity, while each node marks a meaningful subtotal +or outcome. This Apple FY22 income statement follows product and service +revenue through gross profit, operating costs, operating profit, and net +profit. + + + +The example runs the official `d3-sankey` layout inside a custom mark and +renders its nodes, horizontal links, direct labels, and interaction points as a +normal TanStack Charts scene. Keep the source data explicit and verify that +every intermediate subtotal has equal incoming and outgoing value. Use direct +labels and tone as well as color so profit and cost paths remain identifiable. + ## Show a strict hierarchy A tidy tree assigns one position per node and one link per parent-child diff --git a/examples/conformance/package.json b/examples/conformance/package.json index 5147f8be..7475d3b4 100644 --- a/examples/conformance/package.json +++ b/examples/conformance/package.json @@ -13,11 +13,13 @@ "@observablehq/plot": "0.6.17", "@tanstack/charts": "workspace:*", "d3-array": "3.2.4", + "d3-sankey": "0.12.3", "d3-scale": "4.0.2", "d3-shape": "3.2.0" }, "devDependencies": { "@types/d3-array": "^3.2.2", + "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "vite": "^8.0.16" diff --git a/package.json b/package.json index f9ed45e0..0c69b777 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@types/d3-interpolate": "^3.0.4", "@types/d3-polygon": "^3.0.2", "@types/d3-quadtree": "^3.0.6", + "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/d3-selection": "^3.0.11", "@types/d3-shape": "^3.1.8", @@ -111,6 +112,7 @@ "d3-interpolate": "3.0.1", "d3-polygon": "3.0.1", "d3-quadtree": "3.0.1", + "d3-sankey": "0.12.3", "d3-scale": "4.0.2", "d3-selection": "3.0.0", "d3-shape": "3.2.0", diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index 836d002c..0592c8b8 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,7 @@ become less legible than a matrix, grouped summary, or searchable table. | What is the parent-child structure and depth? | Tidy hierarchy tree | | Which positioned observations are spatial neighbors? | Delaunay adjacency network | | Which dependency clusters emerge without fixed positions? | Force-directed network | +| How does value move through staged subtotals? | Sankey flow diagram | | How large are branches within a strict hierarchy? | Packed or rectangular hierarchy | | Must many entities be compared by attributes, not connections? | A table, facets, or quantitative chart | @@ -26,6 +27,29 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. +## Trace value through staged totals + +A Sankey diagram makes conservation and decomposition visible at the same +time: link width carries quantity, while each node marks a meaningful subtotal +or outcome. This Apple FY22 income statement follows product and service +revenue through gross profit, operating costs, operating profit, and net +profit. + + + +The example runs the official `d3-sankey` layout inside a custom mark and +renders its nodes, horizontal links, direct labels, and interaction points as a +normal TanStack Charts scene. Keep the source data explicit and verify that +every intermediate subtotal has equal incoming and outgoing value. Use direct +labels and tone as well as color so profit and cost paths remain identifiable. + ## Show a strict hierarchy A tidy tree assigns one position per node and one link per parent-child diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cb263a0..f3b54867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@types/d3-quadtree': specifier: ^3.0.6 version: 3.0.6 + '@types/d3-sankey': + specifier: ^0.12.5 + version: 0.12.5 '@types/d3-scale': specifier: ^4.0.9 version: 4.0.9 @@ -169,6 +172,9 @@ importers: d3-quadtree: specifier: 3.0.1 version: 3.0.1 + d3-sankey: + specifier: 0.12.3 + version: 0.12.3 d3-scale: specifier: 4.0.2 version: 4.0.2 @@ -342,6 +348,9 @@ importers: d3-array: specifier: 3.2.4 version: 3.2.4 + d3-sankey: + specifier: 0.12.3 + version: 0.12.3 d3-scale: specifier: 4.0.2 version: 4.0.2 @@ -352,6 +361,9 @@ importers: '@types/d3-array': specifier: ^3.2.2 version: 3.2.2 + '@types/d3-sankey': + specifier: ^0.12.5 + version: 0.12.5 '@types/d3-scale': specifier: ^4.0.9 version: 4.0.9 @@ -3098,6 +3110,12 @@ packages: integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, } + '@types/d3-path@1.0.11': + resolution: + { + integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==, + } + '@types/d3-path@3.1.1': resolution: { @@ -3116,6 +3134,12 @@ packages: integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==, } + '@types/d3-sankey@0.12.5': + resolution: + { + integrity: sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==, + } + '@types/d3-scale@4.0.9': resolution: { @@ -3128,6 +3152,12 @@ packages: integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==, } + '@types/d3-shape@1.3.12': + resolution: + { + integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==, + } + '@types/d3-shape@3.1.8': resolution: { @@ -3806,6 +3836,12 @@ packages: integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, } + d3-array@2.12.1: + resolution: + { + integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==, + } + d3-array@3.2.4: resolution: { @@ -3932,6 +3968,12 @@ packages: } engines: { node: '>=12' } + d3-path@1.0.9: + resolution: + { + integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==, + } + d3-path@3.1.0: resolution: { @@ -3960,6 +4002,12 @@ packages: } engines: { node: '>=12' } + d3-sankey@0.12.3: + resolution: + { + integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==, + } + d3-scale-chromatic@3.1.0: resolution: { @@ -3981,6 +4029,12 @@ packages: } engines: { node: '>=12' } + d3-shape@1.3.7: + resolution: + { + integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==, + } + d3-shape@3.2.0: resolution: { @@ -4733,6 +4787,12 @@ packages: integrity: sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==, } + internmap@1.0.1: + resolution: + { + integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==, + } + internmap@2.0.3: resolution: { @@ -7929,18 +7989,28 @@ snapshots: dependencies: '@types/d3-color': 3.1.3 + '@types/d3-path@1.0.11': {} + '@types/d3-path@3.1.1': {} '@types/d3-polygon@3.0.2': {} '@types/d3-quadtree@3.0.6': {} + '@types/d3-sankey@0.12.5': + dependencies: + '@types/d3-shape': 1.3.12 + '@types/d3-scale@4.0.9': dependencies: '@types/d3-time': 3.0.4 '@types/d3-selection@3.0.11': {} + '@types/d3-shape@1.3.12': + dependencies: + '@types/d3-path': 1.0.11 + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -8324,6 +8394,10 @@ snapshots: csstype@3.2.3: {} + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -8391,6 +8465,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} d3-polygon@3.0.1: {} @@ -8399,6 +8475,11 @@ snapshots: d3-random@3.0.1: {} + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + d3-scale-chromatic@3.1.0: dependencies: d3-color: 3.1.0 @@ -8414,6 +8495,10 @@ snapshots: d3-selection@3.0.0: {} + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -8873,6 +8958,8 @@ snapshots: dependencies: tslib: 2.8.1 + internmap@1.0.1: {} + internmap@2.0.3: {} interval-tree-1d@1.0.4: diff --git a/scripts/catalog-artifact.mjs b/scripts/catalog-artifact.mjs index 02dd9c4a..1d3aef5e 100644 --- a/scripts/catalog-artifact.mjs +++ b/scripts/catalog-artifact.mjs @@ -15,9 +15,9 @@ export const catalogArtifactTotalSizeLimit = 6 * 1024 * 1024 export const catalogBuildGraphPath = '.vite/catalog-graph.json' export const catalogBuildGraphSchemaVersion = 1 export const expectedCatalogImplementationCounts = Object.freeze({ - tanstack: 100, + tanstack: 101, 'observable-plot': 68, - recharts: 21, + recharts: 22, echarts: 11, }) From 968c6e68947092fcb95e67f4f861ea7bef56c1da Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:31:41 -0600 Subject: [PATCH 2/9] feat(catalog): add basic Sankey example --- .../cases/111-basic-sankey/case.json | 30 +++ .../cases/111-basic-sankey/data.ts | 24 +++ .../cases/111-basic-sankey/recharts.ts | 124 +++++++++++ .../cases/111-basic-sankey/tanstack.test.ts | 65 ++++++ .../cases/111-basic-sankey/tanstack.ts | 194 ++++++++++++++++++ .../cases/111-sankey-flow/case.json | 4 +- docs/examples/networks-and-hierarchies.md | 22 +- .../docs/examples/networks-and-hierarchies.md | 22 +- scripts/catalog-artifact.mjs | 4 +- 9 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 benchmarks/conformance/cases/111-basic-sankey/case.json create mode 100644 benchmarks/conformance/cases/111-basic-sankey/data.ts create mode 100644 benchmarks/conformance/cases/111-basic-sankey/recharts.ts create mode 100644 benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts create mode 100644 benchmarks/conformance/cases/111-basic-sankey/tanstack.ts diff --git a/benchmarks/conformance/cases/111-basic-sankey/case.json b/benchmarks/conformance/cases/111-basic-sankey/case.json new file mode 100644 index 00000000..240438e2 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/case.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1110, + "id": "111-basic-sankey", + "title": "Basic Sankey", + "family": "network", + "intent": "Show one input splitting into two paths and recombining into one output, with link width proportional to value.", + "support": "composed", + "features": [ + "d3-sankey layout", + "responsive node positioning", + "proportional link width", + "theme-default styling", + "custom mark" + ], + "geometry": [ + { "role": "link", "count": 4 }, + { "role": "rect", "count": 4 }, + { "role": "text", "count": 4 } + ], + "source": { + "title": "D3 Graph Gallery Sankey Diagram", + "url": "https://d3-graph-gallery.com/sankey.html" + }, + "ai": { + "create": "Create a minimal responsive Sankey diagram with four named nodes, four links, the official d3-sankey layout, one label per node, and the chart theme's default foreground and muted colors.", + "maintain": "Keep this example intentionally minimal: preserve the simple split-and-recombine dataset, flow conservation, responsive bounds, flat link caps, one label per node, and the direct d3-sankey dependency without adding semantic color overrides or decorative labels." + } +} diff --git a/benchmarks/conformance/cases/111-basic-sankey/data.ts b/benchmarks/conformance/cases/111-basic-sankey/data.ts new file mode 100644 index 00000000..3c842388 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/data.ts @@ -0,0 +1,24 @@ +export interface BasicFlowNode { + readonly id: string + readonly label: string +} + +export interface BasicFlowLink { + readonly source: string + readonly target: string + readonly value: number +} + +export const basicFlowNodes = [ + { id: 'input', label: 'Input' }, + { id: 'path-a', label: 'Path A' }, + { id: 'path-b', label: 'Path B' }, + { id: 'output', label: 'Output' }, +] as const satisfies readonly BasicFlowNode[] + +export const basicFlowLinks = [ + { source: 'input', target: 'path-a', value: 6 }, + { source: 'input', target: 'path-b', value: 4 }, + { source: 'path-a', target: 'output', value: 6 }, + { source: 'path-b', target: 'output', value: 4 }, +] as const satisfies readonly BasicFlowLink[] diff --git a/benchmarks/conformance/cases/111-basic-sankey/recharts.ts b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts new file mode 100644 index 00000000..10840063 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts @@ -0,0 +1,124 @@ +import { createElement } from 'react' +import { Sankey } from 'recharts' +import { basicFlowLinks, basicFlowNodes } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' +import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' + +function chart(input: ConformanceInput) { + const layout = responsiveLayout(input.width, input.height) + const nodeIndexes = new Map( + basicFlowNodes.map((node, index) => [node.id, index]), + ) + const renderLink = ({ + sourceX, + sourceY, + sourceControlX, + targetX, + targetY, + targetControlX, + linkWidth, + }: LinkProps) => + createElement('path', { + className: 'recharts-sankey-link', + d: [ + `M${sourceX},${sourceY}`, + `C${sourceControlX},${sourceY}`, + `${targetControlX},${targetY}`, + `${targetX},${targetY}`, + ].join(' '), + fill: 'none', + stroke: 'currentColor', + strokeOpacity: 0.35, + strokeWidth: Math.max(1, linkWidth), + strokeLinecap: 'butt', + }) + const renderNode = ({ x, y, width, height, index }: NodeProps) => { + const node = basicFlowNodes[index] + if (!node) return createElement('g') + const labelOnRight = index !== 0 + + return createElement( + 'g', + null, + createElement('rect', { + className: 'recharts-rectangle', + x, + y, + width, + height: Math.max(1, height), + fill: 'currentColor', + fillOpacity: 0.72, + }), + createElement( + 'text', + { + className: 'recharts-text', + x: labelOnRight + ? x + width + layout.labelOffset + : x - layout.labelOffset, + y: y + height / 2, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 650, + textAnchor: labelOnRight ? 'start' : 'end', + dominantBaseline: 'middle', + }, + node.label, + ), + ) + } + + return createElement(Sankey, { + width: input.width, + height: input.height, + data: { + nodes: basicFlowNodes.map((node) => ({ ...node })), + links: basicFlowLinks.map((link) => ({ + source: requiredNodeIndex(nodeIndexes, link.source), + target: requiredNodeIndex(nodeIndexes, link.target), + value: link.value, + })), + }, + node: renderNode, + link: renderLink, + nodeWidth: layout.nodeWidth, + nodePadding: layout.nodePadding, + iterations: 16, + sort: false, + align: 'left', + verticalAlign: 'justify', + margin: { + top: layout.verticalMargin, + right: layout.sideMargin, + bottom: layout.verticalMargin, + left: layout.sideMargin, + }, + accessibilityLayer: true, + }) +} + +function responsiveLayout(width: number, height: number) { + return { + sideMargin: clamp(width * 0.14, 48, 82), + verticalMargin: clamp(height * 0.1, 18, 32), + nodeWidth: clamp(width * 0.025, 10, 18), + nodePadding: clamp(height * 0.12, 18, 38), + labelFontSize: clamp(width * 0.018, 8, 12), + labelOffset: clamp(width * 0.012, 4, 8), + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} + +function requiredNodeIndex(indexes: ReadonlyMap, id: string) { + const index = indexes.get(id) + if (index === undefined) { + throw new TypeError(`Unknown Sankey node "${id}"`) + } + return index +} + +export const mount = rechartsMount(chart, 'Basic Sankey') diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts new file mode 100644 index 00000000..8ceeb06a --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts @@ -0,0 +1,65 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { basicFlowLinks, basicFlowNodes } from './data' +import { basicSankeyDefinition } from './tanstack' +import type { BasicFlowNode } from './data' +import type { SceneNode } from '@tanstack/charts' + +describe('basic Sankey composition', () => { + it.each([ + { width: 320, height: 240 }, + { width: 768, height: 500 }, + ])('lays out a minimal flow inside $width×$height', (size) => { + const runtime = createChartRuntime() + const scene = runtime.render(basicSankeyDefinition(), size) + const nodes = flatten(scene.nodes) + const links = nodes.filter((node) => node.kind === 'polyline' && node.path) + const rectangles = nodes.filter((node) => node.kind === 'rect') + const labels = nodes.filter((node) => node.kind === 'label') + + expect(links).toHaveLength(basicFlowLinks.length) + expect(links.map((link) => link.style?.lineCap)).toEqual( + Array.from({ length: basicFlowLinks.length }, () => 'butt'), + ) + expect(new Set(links.map((link) => link.style?.stroke))).toEqual( + new Set(['currentColor']), + ) + expect(rectangles).toHaveLength(basicFlowNodes.length) + expect(labels).toHaveLength(basicFlowNodes.length) + expect( + labels.map((label) => (label.kind === 'label' ? label.text : '')), + ).toEqual(basicFlowNodes.map((node) => node.label)) + expect(scene.points.map((point) => point.datum.id)).toEqual( + basicFlowNodes.map((node) => node.id), + ) + + for (const rectangle of rectangles) { + if (rectangle.kind !== 'rect') continue + expect(rectangle.x).toBeGreaterThanOrEqual(0) + expect(rectangle.y).toBeGreaterThanOrEqual(0) + expect(rectangle.x + rectangle.width).toBeLessThanOrEqual(scene.width) + expect(rectangle.y + rectangle.height).toBeLessThanOrEqual(scene.height) + } + }) + + it('conserves value through both paths', () => { + for (const node of basicFlowNodes) { + const incoming = basicFlowLinks + .filter((link) => link.target === node.id) + .reduce((total, link) => total + link.value, 0) + const outgoing = basicFlowLinks + .filter((link) => link.source === node.id) + .reduce((total, link) => total + link.value, 0) + + if (incoming > 0 && outgoing > 0) { + expect(incoming).toBe(outgoing) + } + } + }) +}) + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => + node.kind === 'group' ? [node, ...flatten(node.children)] : [node], + ) +} diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts new file mode 100644 index 00000000..adb7aa09 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts @@ -0,0 +1,194 @@ +import { defineChart } from '@tanstack/charts' +import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' +import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' +import { basicFlowLinks, basicFlowNodes } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ChartPoint, SceneNode } from '@tanstack/charts' +import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' +import type { BasicFlowLink, BasicFlowNode } from './data' + +export const basicSankeyDefinition = () => + defineChart({ + marks: [basicSankey(basicFlowNodes, basicFlowLinks)], + margin: 0, + }) + +function basicSankey( + nodes: readonly BasicFlowNode[], + links: readonly BasicFlowLink[], +) { + return createMarkWithScaleValues( + ({ markIndex }) => { + const id = `basic-sankey-${markIndex}` + const sourceNodes = new Map(nodes.map((node) => [node.id, node])) + + return { + id, + channels: {}, + render: ({ chart, theme }) => { + const layout = responsiveLayout(chart.width, chart.height) + const graph = sankey() + .nodeId((node) => node.id) + .nodeAlign(sankeyLeft) + .nodeWidth(layout.nodeWidth) + .nodePadding(layout.nodePadding) + .extent([ + [chart.x + layout.sideMargin, chart.y + layout.verticalMargin], + [ + chart.x + chart.width - layout.sideMargin, + chart.y + chart.height - layout.verticalMargin, + ], + ]) + .iterations(16)(cloneGraph(nodes, links)) + const linkPath = sankeyLinkHorizontal() + const linkNodes: SceneNode[] = [] + const rectNodes: SceneNode[] = [] + const labelNodes: SceneNode[] = [] + const points: ChartPoint[] = [] + + for (const link of graph.links) { + const source = resolvedLinkNode(link.source, 'source') + const target = resolvedLinkNode(link.target, 'target') + const path = linkPath(link) + if (path === null) continue + linkNodes.push({ + kind: 'polyline', + key: `${id}:link:${source.id}:${target.id}`, + points: [], + path, + style: { + fill: 'none', + stroke: theme.muted, + strokeOpacity: 0.35, + strokeWidth: Math.max(1, link.width ?? 1), + lineCap: 'butt', + }, + }) + } + + for (const node of graph.nodes) { + const bounds = resolvedNodeBounds(node) + const datum = sourceNodes.get(node.id) + if (!datum) { + throw new TypeError(`Unknown Sankey node "${node.id}"`) + } + const key = `${id}:node:${node.id}` + const labelOnRight = node.depth !== 0 + const centerX = (bounds.x0 + bounds.x1) / 2 + const centerY = (bounds.y0 + bounds.y1) / 2 + + rectNodes.push({ + kind: 'rect', + key, + x: bounds.x0, + y: bounds.y0, + width: bounds.x1 - bounds.x0, + height: Math.max(1, bounds.y1 - bounds.y0), + style: { fill: theme.foreground, fillOpacity: 0.72 }, + }) + labelNodes.push({ + kind: 'label', + key: `${key}:label`, + x: labelOnRight + ? bounds.x1 + layout.labelOffset + : bounds.x0 - layout.labelOffset, + y: centerY, + text: node.label, + anchor: labelOnRight ? 'start' : 'end', + baseline: 'middle', + fontSize: layout.labelFontSize, + fontWeight: 650, + style: { fill: theme.foreground }, + }) + points.push({ + key, + markId: id, + group: 'Flow', + groupLabel: 'Flow', + datum, + datumIndex: points.length, + xValue: node.id, + yValue: node.value ?? 0, + x: centerX, + y: centerY, + color: theme.foreground, + }) + } + + return { + nodes: [ + sceneGroup(`${id}:links`, 'ts-chart__link', linkNodes), + sceneGroup(`${id}:nodes`, 'ts-chart__rect', rectNodes), + sceneGroup(`${id}:labels`, 'ts-chart__text', labelNodes), + ], + points, + } + }, + } + }, + ) +} + +function responsiveLayout(width: number, height: number) { + return { + sideMargin: clamp(width * 0.14, 48, 82), + verticalMargin: clamp(height * 0.1, 18, 32), + nodeWidth: clamp(width * 0.025, 10, 18), + nodePadding: clamp(height * 0.12, 18, 38), + labelFontSize: clamp(width * 0.018, 8, 12), + labelOffset: clamp(width * 0.012, 4, 8), + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} + +function cloneGraph( + nodes: readonly BasicFlowNode[], + links: readonly BasicFlowLink[], +): SankeyGraph { + return { + nodes: nodes.map((node) => ({ ...node })), + links: links.map((link) => ({ ...link })), + } +} + +function resolvedLinkNode( + node: SankeyLink['source'], + endpoint: 'source' | 'target', +): SankeyNode { + if (typeof node === 'object') return node + throw new TypeError(`Unresolved Sankey link ${endpoint}`) +} + +function resolvedNodeBounds(node: SankeyNode) { + const { x0, x1, y0, y1 } = node + if ( + x0 === undefined || + x1 === undefined || + y0 === undefined || + y1 === undefined + ) { + throw new TypeError(`Sankey node "${node.id}" has no layout bounds`) + } + return { x0, x1, y0, y1 } +} + +function sceneGroup( + key: string, + className: string, + children: readonly SceneNode[], +): SceneNode { + return { + kind: 'group', + key, + className, + ariaHidden: true, + children, + } +} + +export const mount = tanstackMount(basicSankeyDefinition, 'Basic Sankey', { + format: ({ datum }) => datum.label, +}) diff --git a/benchmarks/conformance/cases/111-sankey-flow/case.json b/benchmarks/conformance/cases/111-sankey-flow/case.json index bf476b3b..de9a5153 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/case.json +++ b/benchmarks/conformance/cases/111-sankey-flow/case.json @@ -1,9 +1,9 @@ { "schemaVersion": 1, "referenceRenderer": "recharts", - "order": 1110, + "order": 1120, "id": "111-sankey-flow", - "title": "Apple FY22 income statement Sankey", + "title": "Sankey", "family": "network", "intent": "Trace Apple FY22 product and service revenue through gross profit, operating costs, operating profit, and net profit with flow width proportional to reported value.", "support": "composed", diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index 0592c8b8..6f5d2bb9 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,7 @@ become less legible than a matrix, grouped summary, or searchable table. | What is the parent-child structure and depth? | Tidy hierarchy tree | | Which positioned observations are spatial neighbors? | Delaunay adjacency network | | Which dependency clusters emerge without fixed positions? | Force-directed network | +| How does quantity split and recombine? | Basic Sankey | | How does value move through staged subtotals? | Sankey flow diagram | | How large are branches within a strict hierarchy? | Packed or rectangular hierarchy | | Must many entities be compared by attributes, not connections? | A table, facets, or quantitative chart | @@ -27,7 +28,26 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. -## Trace value through staged totals +## Start with a basic Sankey + +The smallest useful Sankey shows a single input splitting into two paths and +recombining into one output. Link width is the only quantitative encoding in +this example; nodes and links use the chart theme, and every node gets one +short name. + + + +Use this version as the starting point when the structure matters more than +styling. Its four explicit links preserve a 60/40 split through both paths. + +## Customize a Sankey A Sankey diagram makes conservation and decomposition visible at the same time: link width carries quantity, while each node marks a meaningful subtotal diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index 0592c8b8..6f5d2bb9 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,7 @@ become less legible than a matrix, grouped summary, or searchable table. | What is the parent-child structure and depth? | Tidy hierarchy tree | | Which positioned observations are spatial neighbors? | Delaunay adjacency network | | Which dependency clusters emerge without fixed positions? | Force-directed network | +| How does quantity split and recombine? | Basic Sankey | | How does value move through staged subtotals? | Sankey flow diagram | | How large are branches within a strict hierarchy? | Packed or rectangular hierarchy | | Must many entities be compared by attributes, not connections? | A table, facets, or quantitative chart | @@ -27,7 +28,26 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. -## Trace value through staged totals +## Start with a basic Sankey + +The smallest useful Sankey shows a single input splitting into two paths and +recombining into one output. Link width is the only quantitative encoding in +this example; nodes and links use the chart theme, and every node gets one +short name. + + + +Use this version as the starting point when the structure matters more than +styling. Its four explicit links preserve a 60/40 split through both paths. + +## Customize a Sankey A Sankey diagram makes conservation and decomposition visible at the same time: link width carries quantity, while each node marks a meaningful subtotal diff --git a/scripts/catalog-artifact.mjs b/scripts/catalog-artifact.mjs index 1d3aef5e..3120c9f7 100644 --- a/scripts/catalog-artifact.mjs +++ b/scripts/catalog-artifact.mjs @@ -15,9 +15,9 @@ export const catalogArtifactTotalSizeLimit = 6 * 1024 * 1024 export const catalogBuildGraphPath = '.vite/catalog-graph.json' export const catalogBuildGraphSchemaVersion = 1 export const expectedCatalogImplementationCounts = Object.freeze({ - tanstack: 101, + tanstack: 102, 'observable-plot': 68, - recharts: 22, + recharts: 23, echarts: 11, }) From e68ef49d4cadd795596e8b7046bbede4b96aa898 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:40:48 -0600 Subject: [PATCH 3/9] feat(catalog): update Sankey data --- API-FRICTION.md | 12 +- .../cases/111-sankey-flow/case.json | 3 +- .../conformance/cases/111-sankey-flow/data.ts | 264 ------------ .../cases/111-sankey-flow/model.ts | 388 ++++++++++++++++++ .../cases/111-sankey-flow/recharts.ts | 16 +- .../cases/111-sankey-flow/tanstack.test.ts | 91 +++- .../cases/111-sankey-flow/tanstack.ts | 17 +- 7 files changed, 494 insertions(+), 297 deletions(-) delete mode 100644 benchmarks/conformance/cases/111-sankey-flow/data.ts create mode 100644 benchmarks/conformance/cases/111-sankey-flow/model.ts diff --git a/API-FRICTION.md b/API-FRICTION.md index cdaf7ce8..35376f88 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-07-30 +Last updated: 2026-07-31 ## Triage rule @@ -3263,6 +3263,12 @@ Each entry records: normalization helpers accept the imported source rows instead of reaching through a hidden fixture. Cases 85 and 92 retain authored interaction state as explicitly named `scenario.ts`, not observation data. +- Sankey-update evidence: the Apple income-statement case was initially added + with chart-shaped nodes and links in a case-local `data.ts`. Adding bounded + revision updates made the leaf ranges, seeded variation, and conserved + subtotal derivation application logic rather than a raw observation fixture. + That logic now lives in open-by-default `model.ts`, and both TanStack Charts + and Recharts consume the same deterministic result. - Framework-example evidence: the React and Octane showcases no longer import the synthetic Stats parity fixture. They import pinned industries, penguins, cars, and downloads subpaths directly; their time-window selection, D3 stack @@ -3288,6 +3294,10 @@ Each entry records: assertions, and mean frame-relative geometry similarity is 96.7%. Root unit tests, typecheck, docs sync, production builds, packed consumers, bundle budgets, and all seven framework adapter package gates pass. + The Sankey regression additionally checks exact initial values, declared + leaf bounds, deterministic repeated revisions, and conservation across four + revisions. Its full 320/640/960 light/dark initial-and-update conformance + matrix passes with clean strict types and 95.8% mean geometry similarity. ### F-135 — The published release had no repository baseline marker diff --git a/benchmarks/conformance/cases/111-sankey-flow/case.json b/benchmarks/conformance/cases/111-sankey-flow/case.json index de9a5153..3174d22b 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/case.json +++ b/benchmarks/conformance/cases/111-sankey-flow/case.json @@ -12,6 +12,7 @@ "responsive node positioning", "proportional link width", "direct value labels", + "deterministic data updates", "profit and cost color", "custom mark" ], @@ -26,6 +27,6 @@ }, "ai": { "create": "Recreate the Apple FY22 income statement as a responsive Sankey diagram using explicit node and link records, the official d3-sankey layout, a centered title, direct two-line value labels, neutral revenue paths, green profit paths, and red cost paths.", - "maintain": "Change statement values while preserving unique IDs, flow conservation at every intermediate subtotal, left-aligned sink depths, responsive bounds, semantic profit and cost colors, flat link caps, direct labels, and the direct d3-sankey dependency." + "maintain": "Change statement values through the bounded revision model while preserving unique IDs, flow conservation at every intermediate subtotal, left-aligned sink depths, responsive bounds, semantic profit and cost colors, flat link caps, direct labels, and the direct d3-sankey dependency." } } diff --git a/benchmarks/conformance/cases/111-sankey-flow/data.ts b/benchmarks/conformance/cases/111-sankey-flow/data.ts deleted file mode 100644 index 992e0046..00000000 --- a/benchmarks/conformance/cases/111-sankey-flow/data.ts +++ /dev/null @@ -1,264 +0,0 @@ -export type FlowTone = 'Neutral' | 'Profit' | 'Cost' - -export interface FlowNode { - readonly id: string - readonly label: string - readonly compactLabel?: string - readonly displayValue: string - readonly tone: FlowTone - readonly order: number - readonly labelSide: 'left' | 'right' - readonly labelBackdrop?: boolean -} - -export interface FlowLink { - readonly source: string - readonly target: string - readonly value: number - readonly tone: FlowTone -} - -export const incomeStatementTitle = 'Apple FY22 Income Statement' - -export const toneColors = { - Neutral: '#666666', - Profit: '#00b51a', - Cost: '#b50905', -} as const satisfies Record - -export const linkColors = { - Neutral: '#8a8a8a', - Profit: '#50c955', - Cost: '#c96363', -} as const satisfies Record - -export const flowNodes: readonly FlowNode[] = [ - { - id: 'iphone', - label: 'iPhone', - displayValue: '$205.5B', - tone: 'Neutral', - order: 0, - labelSide: 'left', - }, - { - id: 'macbook', - label: 'MacBook', - displayValue: '$40.2B', - tone: 'Neutral', - order: 1, - labelSide: 'left', - }, - { - id: 'ipad', - label: 'iPad', - displayValue: '$29.3B', - tone: 'Neutral', - order: 2, - labelSide: 'left', - }, - { - id: 'wearables', - label: 'Watch and AirPods', - compactLabel: 'Watch + Pods', - displayValue: '$41.2B', - tone: 'Neutral', - order: 3, - labelSide: 'left', - }, - { - id: 'products', - label: 'Products', - displayValue: '$316.2B', - tone: 'Neutral', - order: 0, - labelSide: 'left', - labelBackdrop: true, - }, - { - id: 'services', - label: 'Services', - displayValue: '$78.2B', - tone: 'Neutral', - order: 1, - labelSide: 'left', - labelBackdrop: true, - }, - { - id: 'revenue', - label: 'Revenue', - displayValue: '$394.3B', - tone: 'Neutral', - order: 0, - labelSide: 'left', - labelBackdrop: true, - }, - { - id: 'gross-profit', - label: 'Gross profit', - displayValue: '$170.9B', - tone: 'Profit', - order: 0, - labelSide: 'right', - labelBackdrop: true, - }, - { - id: 'cost-of-revenue', - label: 'Cost of revenue', - compactLabel: 'Cost of rev.', - displayValue: '$223.5B', - tone: 'Cost', - order: 1, - labelSide: 'right', - labelBackdrop: true, - }, - { - id: 'operating-profit', - label: 'Operating profit', - compactLabel: 'Op. profit', - displayValue: '$119.5B', - tone: 'Profit', - order: 0, - labelSide: 'right', - labelBackdrop: true, - }, - { - id: 'operating-expenses', - label: 'Operating expenses', - compactLabel: 'Op. expenses', - displayValue: '$51.4B', - tone: 'Cost', - order: 1, - labelSide: 'right', - labelBackdrop: true, - }, - { - id: 'product-costs', - label: 'Product costs', - displayValue: '$201.4B', - tone: 'Cost', - order: 2, - labelSide: 'right', - labelBackdrop: true, - }, - { - id: 'service-costs', - label: 'Service costs', - displayValue: '$22.1B', - tone: 'Cost', - order: 3, - labelSide: 'right', - }, - { - id: 'net-profit', - label: 'Net profit', - displayValue: '$99.8B', - tone: 'Profit', - order: 0, - labelSide: 'right', - }, - { - id: 'tax', - label: 'Tax', - displayValue: '$19.3B', - tone: 'Cost', - order: 1, - labelSide: 'right', - }, - { - id: 'other', - label: 'Other', - displayValue: '$0.3B', - tone: 'Cost', - order: 2, - labelSide: 'right', - }, - { - id: 'research-development', - label: 'R&D', - displayValue: '$26.3B', - tone: 'Cost', - order: 3, - labelSide: 'right', - }, - { - id: 'selling-general-administrative', - label: 'SG&A', - displayValue: '$25.1B', - tone: 'Cost', - order: 4, - labelSide: 'right', - }, -] - -// Apple-reported values in billions retain enough precision for every -// intermediate node to conserve flow; display labels mirror the supplied chart. -export const flowLinks: readonly FlowLink[] = [ - { source: 'iphone', target: 'products', value: 205.489, tone: 'Neutral' }, - { source: 'macbook', target: 'products', value: 40.177, tone: 'Neutral' }, - { source: 'ipad', target: 'products', value: 29.292, tone: 'Neutral' }, - { source: 'wearables', target: 'products', value: 41.241, tone: 'Neutral' }, - { source: 'products', target: 'revenue', value: 316.199, tone: 'Neutral' }, - { source: 'services', target: 'revenue', value: 78.129, tone: 'Neutral' }, - { source: 'revenue', target: 'gross-profit', value: 170.782, tone: 'Profit' }, - { - source: 'revenue', - target: 'cost-of-revenue', - value: 223.546, - tone: 'Cost', - }, - { - source: 'gross-profit', - target: 'operating-profit', - value: 119.437, - tone: 'Profit', - }, - { - source: 'gross-profit', - target: 'operating-expenses', - value: 51.345, - tone: 'Cost', - }, - { - source: 'cost-of-revenue', - target: 'product-costs', - value: 201.471, - tone: 'Cost', - }, - { - source: 'cost-of-revenue', - target: 'service-costs', - value: 22.075, - tone: 'Cost', - }, - { - source: 'operating-profit', - target: 'net-profit', - value: 99.803, - tone: 'Profit', - }, - { - source: 'operating-profit', - target: 'tax', - value: 19.3, - tone: 'Cost', - }, - { - source: 'operating-profit', - target: 'other', - value: 0.334, - tone: 'Cost', - }, - { - source: 'operating-expenses', - target: 'research-development', - value: 26.251, - tone: 'Cost', - }, - { - source: 'operating-expenses', - target: 'selling-general-administrative', - value: 25.094, - tone: 'Cost', - }, -] diff --git a/benchmarks/conformance/cases/111-sankey-flow/model.ts b/benchmarks/conformance/cases/111-sankey-flow/model.ts new file mode 100644 index 00000000..0fd8789f --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/model.ts @@ -0,0 +1,388 @@ +export type FlowTone = 'Neutral' | 'Profit' | 'Cost' + +export type FlowNodeId = + | 'iphone' + | 'macbook' + | 'ipad' + | 'wearables' + | 'products' + | 'services' + | 'revenue' + | 'gross-profit' + | 'cost-of-revenue' + | 'operating-profit' + | 'operating-expenses' + | 'product-costs' + | 'service-costs' + | 'net-profit' + | 'tax' + | 'other' + | 'research-development' + | 'selling-general-administrative' + +export const leafFlowNodeIds = [ + 'iphone', + 'macbook', + 'ipad', + 'wearables', + 'services', + 'product-costs', + 'service-costs', + 'tax', + 'other', + 'research-development', + 'selling-general-administrative', +] as const satisfies readonly FlowNodeId[] + +export type LeafFlowNodeId = (typeof leafFlowNodeIds)[number] + +export interface FlowNode { + readonly id: FlowNodeId + readonly label: string + readonly compactLabel?: string + readonly value: number + readonly displayValue: string + readonly tone: FlowTone + readonly order: number + readonly labelSide: 'left' | 'right' + readonly labelBackdrop?: boolean +} + +export interface FlowLink { + readonly source: FlowNodeId + readonly target: FlowNodeId + readonly value: number + readonly tone: FlowTone +} + +export interface IncomeStatementData { + readonly nodes: readonly FlowNode[] + readonly links: readonly FlowLink[] +} + +interface ValueRange { + readonly initial: number + readonly min: number + readonly max: number +} + +type FlowNodeTemplate = Omit + +export const incomeStatementTitle = 'Apple FY22 Income Statement' + +export const toneColors = { + Neutral: '#666666', + Profit: '#00b51a', + Cost: '#b50905', +} as const satisfies Record + +export const linkColors = { + Neutral: '#8a8a8a', + Profit: '#50c955', + Cost: '#c96363', +} as const satisfies Record + +// Values are billions of dollars. The revision ranges vary leaf accounts by +// hundreds to a few thousand million dollars, then every subtotal is derived. +export const incomeStatementValueRanges = { + iphone: { initial: 205.489, min: 202.489, max: 208.489 }, + macbook: { initial: 40.177, min: 38.177, max: 42.177 }, + ipad: { initial: 29.292, min: 27.792, max: 30.792 }, + wearables: { initial: 41.241, min: 39.241, max: 43.241 }, + services: { initial: 78.129, min: 75.129, max: 81.129 }, + 'product-costs': { initial: 201.471, min: 197.471, max: 205.471 }, + 'service-costs': { initial: 22.075, min: 20.575, max: 23.575 }, + tax: { initial: 19.3, min: 17.8, max: 20.8 }, + other: { initial: 0.334, min: 0.134, max: 0.534 }, + 'research-development': { initial: 26.251, min: 24.251, max: 28.251 }, + 'selling-general-administrative': { + initial: 25.094, + min: 23.094, + max: 27.094, + }, +} as const satisfies Record + +const nodeTemplates = [ + { + id: 'iphone', + label: 'iPhone', + tone: 'Neutral', + order: 0, + labelSide: 'left', + }, + { + id: 'macbook', + label: 'MacBook', + tone: 'Neutral', + order: 1, + labelSide: 'left', + }, + { + id: 'ipad', + label: 'iPad', + tone: 'Neutral', + order: 2, + labelSide: 'left', + }, + { + id: 'wearables', + label: 'Watch and AirPods', + compactLabel: 'Watch + Pods', + tone: 'Neutral', + order: 3, + labelSide: 'left', + }, + { + id: 'products', + label: 'Products', + tone: 'Neutral', + order: 0, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'services', + label: 'Services', + tone: 'Neutral', + order: 1, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'revenue', + label: 'Revenue', + tone: 'Neutral', + order: 0, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'gross-profit', + label: 'Gross profit', + tone: 'Profit', + order: 0, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'cost-of-revenue', + label: 'Cost of revenue', + compactLabel: 'Cost of rev.', + tone: 'Cost', + order: 1, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'operating-profit', + label: 'Operating profit', + compactLabel: 'Op. profit', + tone: 'Profit', + order: 0, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'operating-expenses', + label: 'Operating expenses', + compactLabel: 'Op. expenses', + tone: 'Cost', + order: 1, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'product-costs', + label: 'Product costs', + tone: 'Cost', + order: 2, + labelSide: 'right', + labelBackdrop: true, + }, + { + id: 'service-costs', + label: 'Service costs', + tone: 'Cost', + order: 3, + labelSide: 'right', + }, + { + id: 'net-profit', + label: 'Net profit', + tone: 'Profit', + order: 0, + labelSide: 'right', + }, + { + id: 'tax', + label: 'Tax', + tone: 'Cost', + order: 1, + labelSide: 'right', + }, + { + id: 'other', + label: 'Other', + tone: 'Cost', + order: 2, + labelSide: 'right', + }, + { + id: 'research-development', + label: 'R&D', + tone: 'Cost', + order: 3, + labelSide: 'right', + }, + { + id: 'selling-general-administrative', + label: 'SG&A', + tone: 'Cost', + order: 4, + labelSide: 'right', + }, +] as const satisfies readonly FlowNodeTemplate[] + +// These labels intentionally mirror the supplied reference graphic, whose +// one-decimal presentation is not uniformly derived from the precise links. +const initialDisplayValues = { + iphone: '$205.5B', + macbook: '$40.2B', + ipad: '$29.3B', + wearables: '$41.2B', + products: '$316.2B', + services: '$78.2B', + revenue: '$394.3B', + 'gross-profit': '$170.9B', + 'cost-of-revenue': '$223.5B', + 'operating-profit': '$119.5B', + 'operating-expenses': '$51.4B', + 'product-costs': '$201.4B', + 'service-costs': '$22.1B', + 'net-profit': '$99.8B', + tax: '$19.3B', + other: '$0.3B', + 'research-development': '$26.3B', + 'selling-general-administrative': '$25.1B', +} as const satisfies Record + +export function incomeStatementData(revision: number): IncomeStatementData { + const iphone = revisedLeafValue('iphone', revision) + const macbook = revisedLeafValue('macbook', revision) + const ipad = revisedLeafValue('ipad', revision) + const wearables = revisedLeafValue('wearables', revision) + const services = revisedLeafValue('services', revision) + const productCosts = revisedLeafValue('product-costs', revision) + const serviceCosts = revisedLeafValue('service-costs', revision) + const tax = revisedLeafValue('tax', revision) + const other = revisedLeafValue('other', revision) + const researchDevelopment = revisedLeafValue('research-development', revision) + const sellingGeneralAdministrative = revisedLeafValue( + 'selling-general-administrative', + revision, + ) + + const products = roundBillions(iphone + macbook + ipad + wearables) + const revenue = roundBillions(products + services) + const costOfRevenue = roundBillions(productCosts + serviceCosts) + const grossProfit = roundBillions(revenue - costOfRevenue) + const operatingExpenses = roundBillions( + researchDevelopment + sellingGeneralAdministrative, + ) + const operatingProfit = roundBillions(grossProfit - operatingExpenses) + const netProfit = roundBillions(operatingProfit - tax - other) + + const values = { + iphone, + macbook, + ipad, + wearables, + products, + services, + revenue, + 'gross-profit': grossProfit, + 'cost-of-revenue': costOfRevenue, + 'operating-profit': operatingProfit, + 'operating-expenses': operatingExpenses, + 'product-costs': productCosts, + 'service-costs': serviceCosts, + 'net-profit': netProfit, + tax, + other, + 'research-development': researchDevelopment, + 'selling-general-administrative': sellingGeneralAdministrative, + } as const satisfies Record + + return { + nodes: nodeTemplates.map((node) => ({ + ...node, + value: values[node.id], + displayValue: + revision === 0 + ? initialDisplayValues[node.id] + : formatBillions(values[node.id]), + })), + links: [ + flowLink('iphone', 'products', iphone, 'Neutral'), + flowLink('macbook', 'products', macbook, 'Neutral'), + flowLink('ipad', 'products', ipad, 'Neutral'), + flowLink('wearables', 'products', wearables, 'Neutral'), + flowLink('products', 'revenue', products, 'Neutral'), + flowLink('services', 'revenue', services, 'Neutral'), + flowLink('revenue', 'gross-profit', grossProfit, 'Profit'), + flowLink('revenue', 'cost-of-revenue', costOfRevenue, 'Cost'), + flowLink('gross-profit', 'operating-profit', operatingProfit, 'Profit'), + flowLink('gross-profit', 'operating-expenses', operatingExpenses, 'Cost'), + flowLink('cost-of-revenue', 'product-costs', productCosts, 'Cost'), + flowLink('cost-of-revenue', 'service-costs', serviceCosts, 'Cost'), + flowLink('operating-profit', 'net-profit', netProfit, 'Profit'), + flowLink('operating-profit', 'tax', tax, 'Cost'), + flowLink('operating-profit', 'other', other, 'Cost'), + flowLink( + 'operating-expenses', + 'research-development', + researchDevelopment, + 'Cost', + ), + flowLink( + 'operating-expenses', + 'selling-general-administrative', + sellingGeneralAdministrative, + 'Cost', + ), + ], + } +} + +function revisedLeafValue(id: LeafFlowNodeId, revision: number) { + const range = incomeStatementValueRanges[id] + if (revision === 0) return range.initial + const unit = seededUnitInterval(`${Math.trunc(revision)}:${id}`) + return roundBillions(range.min + unit * (range.max - range.min)) +} + +function seededUnitInterval(seed: string) { + let hash = 2166136261 + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) / 0xffffffff +} + +function roundBillions(value: number) { + return Math.round(value * 1000) / 1000 +} + +function formatBillions(value: number) { + return `$${value.toFixed(1)}B` +} + +function flowLink( + source: FlowNodeId, + target: FlowNodeId, + value: number, + tone: FlowTone, +): FlowLink { + return { source, target, value, tone } +} diff --git a/benchmarks/conformance/cases/111-sankey-flow/recharts.ts b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts index 98125d8b..8da4a331 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/recharts.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts @@ -1,19 +1,19 @@ import { createElement } from 'react' import { Sankey } from 'recharts' import { - flowLinks, - flowNodes, + incomeStatementData, incomeStatementTitle, linkColors, toneColors, -} from './data' +} from './model' import { rechartsMount } from '../../shared/recharts-mount' import type { ConformanceInput } from '../../types' import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' function chart(input: ConformanceInput) { + const { nodes, links } = incomeStatementData(input.revision) const layout = responsiveLayout(input.width, input.height) - const nodeIndexes = new Map(flowNodes.map((node, index) => [node.id, index])) + const nodeIndexes = new Map(nodes.map((node, index) => [node.id, index])) const renderLink = ({ sourceX, sourceY, @@ -24,7 +24,7 @@ function chart(input: ConformanceInput) { linkWidth, index, }: LinkProps) => { - const link = flowLinks[index] + const link = links[index] if (!link) return createElement('path') return createElement('path', { className: 'recharts-sankey-link', @@ -42,7 +42,7 @@ function chart(input: ConformanceInput) { }) } const renderNode = ({ x, y, width, height, index }: NodeProps) => { - const node = flowNodes[index] + const node = nodes[index] if (!node) return createElement('g') const labelOnRight = node.labelSide === 'right' const labelX = labelOnRight @@ -139,8 +139,8 @@ function chart(input: ConformanceInput) { width: input.width, height: input.height, data: { - nodes: flowNodes.map((node) => ({ ...node })), - links: flowLinks.map((link) => ({ + nodes: nodes.map((node) => ({ ...node })), + links: links.map((link) => ({ source: requiredNodeIndex(nodeIndexes, link.source), target: requiredNodeIndex(nodeIndexes, link.target), value: link.value, diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts index 0f1a61ad..8438fe79 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts @@ -1,17 +1,26 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' -import { flowLinks, flowNodes, incomeStatementTitle, linkColors } from './data' +import { + incomeStatementData, + incomeStatementTitle, + incomeStatementValueRanges, + leafFlowNodeIds, + linkColors, +} from './model' import { sankeyDefinition } from './tanstack' -import type { FlowNode } from './data' +import type { FlowNode } from './model' import type { SceneNode } from '@tanstack/charts' describe('Apple income statement Sankey composition', () => { it.each([ - { width: 320, height: 240 }, - { width: 768, height: 500 }, - ])('lays out every node and link inside $width×$height', (size) => { + { width: 320, height: 240, revision: 0 }, + { width: 768, height: 500, revision: 1 }, + ])('lays out every node and link inside $width×$height', (input) => { + const { nodes: flowNodes, links: flowLinks } = incomeStatementData( + input.revision, + ) const runtime = createChartRuntime() - const scene = runtime.render(sankeyDefinition(), size) + const scene = runtime.render(sankeyDefinition(input), input) const nodes = flatten(scene.nodes) const links = nodes.filter((node) => node.kind === 'polyline' && node.path) const rectangles = nodes.filter((node) => node.kind === 'rect') @@ -48,18 +57,68 @@ describe('Apple income statement Sankey composition', () => { } }) - it('conserves value through every intermediate subtotal', () => { - for (const node of flowNodes) { - const incoming = flowLinks - .filter((link) => link.target === node.id) - .reduce((total, link) => total + link.value, 0) - const outgoing = flowLinks - .filter((link) => link.source === node.id) - .reduce((total, link) => total + link.value, 0) + it.each([0, 1, 2, 7])( + 'conserves every intermediate subtotal at revision %s', + (revision) => { + const { nodes, links } = incomeStatementData(revision) + + for (const node of nodes) { + const incoming = links + .filter((link) => link.target === node.id) + .reduce((total, link) => total + link.value, 0) + const outgoing = links + .filter((link) => link.source === node.id) + .reduce((total, link) => total + link.value, 0) - if (incoming > 0 && outgoing > 0) { - expect(incoming).toBeCloseTo(outgoing, 6) + if (incoming > 0 && outgoing > 0) { + expect(incoming).toBeCloseTo(outgoing, 6) + } } + }, + ) + + it('retains the supplied FY22 values at revision zero', () => { + expect( + incomeStatementData(0).nodes.map((node) => [node.id, node.displayValue]), + ).toEqual([ + ['iphone', '$205.5B'], + ['macbook', '$40.2B'], + ['ipad', '$29.3B'], + ['wearables', '$41.2B'], + ['products', '$316.2B'], + ['services', '$78.2B'], + ['revenue', '$394.3B'], + ['gross-profit', '$170.9B'], + ['cost-of-revenue', '$223.5B'], + ['operating-profit', '$119.5B'], + ['operating-expenses', '$51.4B'], + ['product-costs', '$201.4B'], + ['service-costs', '$22.1B'], + ['net-profit', '$99.8B'], + ['tax', '$19.3B'], + ['other', '$0.3B'], + ['research-development', '$26.3B'], + ['selling-general-administrative', '$25.1B'], + ]) + }) + + it('updates every leaf inside its declared range', () => { + const initial = incomeStatementData(0) + const updated = incomeStatementData(1) + const repeated = incomeStatementData(1) + const initialValues = new Map( + initial.nodes.map((node) => [node.id, node.value]), + ) + const updatedValues = new Map( + updated.nodes.map((node) => [node.id, node.value]), + ) + + expect(repeated).toEqual(updated) + for (const id of leafFlowNodeIds) { + const range = incomeStatementValueRanges[id] + expect(updatedValues.get(id)).toBeGreaterThanOrEqual(range.min) + expect(updatedValues.get(id)).toBeLessThanOrEqual(range.max) + expect(updatedValues.get(id)).not.toBe(initialValues.get(id)) } }) }) diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts index 1c4c9734..96bb9312 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts @@ -2,16 +2,16 @@ import { defineChart } from '@tanstack/charts' import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' import { - flowLinks, - flowNodes, + incomeStatementData, incomeStatementTitle, linkColors, toneColors, -} from './data' +} from './model' import { tanstackMount } from '../../shared/mount' import type { ChartPoint, SceneNode } from '@tanstack/charts' import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' -import type { FlowLink, FlowNode, FlowTone } from './data' +import type { FlowLink, FlowNode, FlowTone } from './model' +import type { ConformanceInput } from '../../types' const toneDomain = [ 'Neutral', @@ -19,15 +19,18 @@ const toneDomain = [ 'Cost', ] as const satisfies readonly FlowTone[] -export const sankeyDefinition = () => - defineChart({ - marks: [sankeyFlow(flowNodes, flowLinks)], +export const sankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = incomeStatementData(input.revision) + + return defineChart({ + marks: [sankeyFlow(nodes, links)], color: { domain: toneDomain, range: toneDomain.map((tone) => toneColors[tone]), }, margin: 0, }) +} function sankeyFlow(nodes: readonly FlowNode[], links: readonly FlowLink[]) { return createMarkWithScaleValues( From 7f48e3caf923d37cd4065c16c29828cef09c57be Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:47:56 -0600 Subject: [PATCH 4/9] Add Basic Sankey data updates --- API-FRICTION.md | 21 ++++---- .../cases/111-basic-sankey/case.json | 3 +- .../cases/111-basic-sankey/data.ts | 24 --------- .../cases/111-basic-sankey/model.ts | 41 +++++++++++++++ .../cases/111-basic-sankey/recharts.ts | 13 +++-- .../cases/111-basic-sankey/tanstack.test.ts | 50 +++++++++++++------ .../cases/111-basic-sankey/tanstack.ts | 14 ++++-- docs/examples/networks-and-hierarchies.md | 3 +- .../docs/examples/networks-and-hierarchies.md | 3 +- 9 files changed, 108 insertions(+), 64 deletions(-) delete mode 100644 benchmarks/conformance/cases/111-basic-sankey/data.ts create mode 100644 benchmarks/conformance/cases/111-basic-sankey/model.ts diff --git a/API-FRICTION.md b/API-FRICTION.md index 35376f88..8750dbd1 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -3263,12 +3263,13 @@ Each entry records: normalization helpers accept the imported source rows instead of reaching through a hidden fixture. Cases 85 and 92 retain authored interaction state as explicitly named `scenario.ts`, not observation data. -- Sankey-update evidence: the Apple income-statement case was initially added - with chart-shaped nodes and links in a case-local `data.ts`. Adding bounded - revision updates made the leaf ranges, seeded variation, and conserved - subtotal derivation application logic rather than a raw observation fixture. - That logic now lives in open-by-default `model.ts`, and both TanStack Charts - and Recharts consume the same deterministic result. +- Sankey-update evidence: both new Sankey cases were initially added with + chart-shaped nodes and links in case-local `data.ts` modules. Adding revision + updates made the bounded split and the Apple leaf ranges, seeded variation, + and conserved subtotal derivation application logic rather than raw + observation fixtures. That logic now lives in open-by-default `model.ts` + modules, and both TanStack Charts and Recharts consume the same deterministic + results. - Framework-example evidence: the React and Octane showcases no longer import the synthetic Stats parity fixture. They import pinned industries, penguins, cars, and downloads subpaths directly; their time-window selection, D3 stack @@ -3294,10 +3295,12 @@ Each entry records: assertions, and mean frame-relative geometry similarity is 96.7%. Root unit tests, typecheck, docs sync, production builds, packed consumers, bundle budgets, and all seven framework adapter package gates pass. - The Sankey regression additionally checks exact initial values, declared + The Sankey regressions additionally check the Basic example's total of 10 + across five splits plus the Apple example's exact initial values, declared leaf bounds, deterministic repeated revisions, and conservation across four - revisions. Its full 320/640/960 light/dark initial-and-update conformance - matrix passes with clean strict types and 95.8% mean geometry similarity. + revisions. Both cases' full 320/640/960 light/dark initial-and-update + conformance matrices pass with clean strict types; the Basic case has 99.9% + mean geometry similarity and the Apple case has 95.8%. ### F-135 — The published release had no repository baseline marker diff --git a/benchmarks/conformance/cases/111-basic-sankey/case.json b/benchmarks/conformance/cases/111-basic-sankey/case.json index 240438e2..81d672e1 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/case.json +++ b/benchmarks/conformance/cases/111-basic-sankey/case.json @@ -11,6 +11,7 @@ "d3-sankey layout", "responsive node positioning", "proportional link width", + "deterministic data updates", "theme-default styling", "custom mark" ], @@ -25,6 +26,6 @@ }, "ai": { "create": "Create a minimal responsive Sankey diagram with four named nodes, four links, the official d3-sankey layout, one label per node, and the chart theme's default foreground and muted colors.", - "maintain": "Keep this example intentionally minimal: preserve the simple split-and-recombine dataset, flow conservation, responsive bounds, flat link caps, one label per node, and the direct d3-sankey dependency without adding semantic color overrides or decorative labels." + "maintain": "Keep this example intentionally minimal: preserve the simple split-and-recombine dataset, a revision-varying split with a total of 10, flow conservation, responsive bounds, flat link caps, one label per node, and the direct d3-sankey dependency without adding semantic color overrides or decorative labels." } } diff --git a/benchmarks/conformance/cases/111-basic-sankey/data.ts b/benchmarks/conformance/cases/111-basic-sankey/data.ts deleted file mode 100644 index 3c842388..00000000 --- a/benchmarks/conformance/cases/111-basic-sankey/data.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface BasicFlowNode { - readonly id: string - readonly label: string -} - -export interface BasicFlowLink { - readonly source: string - readonly target: string - readonly value: number -} - -export const basicFlowNodes = [ - { id: 'input', label: 'Input' }, - { id: 'path-a', label: 'Path A' }, - { id: 'path-b', label: 'Path B' }, - { id: 'output', label: 'Output' }, -] as const satisfies readonly BasicFlowNode[] - -export const basicFlowLinks = [ - { source: 'input', target: 'path-a', value: 6 }, - { source: 'input', target: 'path-b', value: 4 }, - { source: 'path-a', target: 'output', value: 6 }, - { source: 'path-b', target: 'output', value: 4 }, -] as const satisfies readonly BasicFlowLink[] diff --git a/benchmarks/conformance/cases/111-basic-sankey/model.ts b/benchmarks/conformance/cases/111-basic-sankey/model.ts new file mode 100644 index 00000000..4da12072 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/model.ts @@ -0,0 +1,41 @@ +export interface BasicFlowNode { + readonly id: string + readonly label: string +} + +export interface BasicFlowLink { + readonly source: string + readonly target: string + readonly value: number +} + +export interface BasicSankeyData { + readonly nodes: readonly BasicFlowNode[] + readonly links: readonly BasicFlowLink[] +} + +export const basicFlowNodes = [ + { id: 'input', label: 'Input' }, + { id: 'path-a', label: 'Path A' }, + { id: 'path-b', label: 'Path B' }, + { id: 'output', label: 'Output' }, +] as const satisfies readonly BasicFlowNode[] + +const pathAValues = [6, 7, 5, 3, 4] as const + +export function basicSankeyData(revision: number): BasicSankeyData { + const pathA = + pathAValues[Math.abs(Math.trunc(revision)) % pathAValues.length] ?? + pathAValues[0] + const pathB = 10 - pathA + + return { + nodes: basicFlowNodes, + links: [ + { source: 'input', target: 'path-a', value: pathA }, + { source: 'input', target: 'path-b', value: pathB }, + { source: 'path-a', target: 'output', value: pathA }, + { source: 'path-b', target: 'output', value: pathB }, + ], + } +} diff --git a/benchmarks/conformance/cases/111-basic-sankey/recharts.ts b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts index 10840063..016dc266 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/recharts.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts @@ -1,15 +1,14 @@ import { createElement } from 'react' import { Sankey } from 'recharts' -import { basicFlowLinks, basicFlowNodes } from './data' +import { basicSankeyData } from './model' import { rechartsMount } from '../../shared/recharts-mount' import type { ConformanceInput } from '../../types' import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' function chart(input: ConformanceInput) { + const { nodes, links } = basicSankeyData(input.revision) const layout = responsiveLayout(input.width, input.height) - const nodeIndexes = new Map( - basicFlowNodes.map((node, index) => [node.id, index]), - ) + const nodeIndexes = new Map(nodes.map((node, index) => [node.id, index])) const renderLink = ({ sourceX, sourceY, @@ -34,7 +33,7 @@ function chart(input: ConformanceInput) { strokeLinecap: 'butt', }) const renderNode = ({ x, y, width, height, index }: NodeProps) => { - const node = basicFlowNodes[index] + const node = nodes[index] if (!node) return createElement('g') const labelOnRight = index !== 0 @@ -73,8 +72,8 @@ function chart(input: ConformanceInput) { width: input.width, height: input.height, data: { - nodes: basicFlowNodes.map((node) => ({ ...node })), - links: basicFlowLinks.map((link) => ({ + nodes: nodes.map((node) => ({ ...node })), + links: links.map((link) => ({ source: requiredNodeIndex(nodeIndexes, link.source), target: requiredNodeIndex(nodeIndexes, link.target), value: link.value, diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts index 8ceeb06a..f6dc6eff 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts @@ -1,8 +1,8 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' -import { basicFlowLinks, basicFlowNodes } from './data' +import { basicFlowNodes, basicSankeyData } from './model' import { basicSankeyDefinition } from './tanstack' -import type { BasicFlowNode } from './data' +import type { BasicFlowNode } from './model' import type { SceneNode } from '@tanstack/charts' describe('basic Sankey composition', () => { @@ -10,16 +10,18 @@ describe('basic Sankey composition', () => { { width: 320, height: 240 }, { width: 768, height: 500 }, ])('lays out a minimal flow inside $width×$height', (size) => { + const input = { ...size, revision: 0 } + const { links: flowLinks } = basicSankeyData(input.revision) const runtime = createChartRuntime() - const scene = runtime.render(basicSankeyDefinition(), size) + const scene = runtime.render(basicSankeyDefinition(input), size) const nodes = flatten(scene.nodes) const links = nodes.filter((node) => node.kind === 'polyline' && node.path) const rectangles = nodes.filter((node) => node.kind === 'rect') const labels = nodes.filter((node) => node.kind === 'label') - expect(links).toHaveLength(basicFlowLinks.length) + expect(links).toHaveLength(flowLinks.length) expect(links.map((link) => link.style?.lineCap)).toEqual( - Array.from({ length: basicFlowLinks.length }, () => 'butt'), + Array.from({ length: flowLinks.length }, () => 'butt'), ) expect(new Set(links.map((link) => link.style?.stroke))).toEqual( new Set(['currentColor']), @@ -42,19 +44,35 @@ describe('basic Sankey composition', () => { } }) - it('conserves value through both paths', () => { - for (const node of basicFlowNodes) { - const incoming = basicFlowLinks - .filter((link) => link.target === node.id) - .reduce((total, link) => total + link.value, 0) - const outgoing = basicFlowLinks - .filter((link) => link.source === node.id) - .reduce((total, link) => total + link.value, 0) + it('updates the split while conserving a total of 10', () => { + const pathAValues = [0, 1, 2, 3, 4].map((revision) => { + const { links } = basicSankeyData(revision) - if (incoming > 0 && outgoing > 0) { - expect(incoming).toBe(outgoing) + for (const node of basicFlowNodes) { + const incoming = links + .filter((link) => link.target === node.id) + .reduce((total, link) => total + link.value, 0) + const outgoing = links + .filter((link) => link.source === node.id) + .reduce((total, link) => total + link.value, 0) + + if (incoming > 0 && outgoing > 0) { + expect(incoming).toBe(outgoing) + } } - } + + expect( + links + .filter((link) => link.source === 'input') + .reduce((total, link) => total + link.value, 0), + ).toBe(10) + + return links.find( + (link) => link.source === 'input' && link.target === 'path-a', + )?.value + }) + + expect(pathAValues).toEqual([6, 7, 5, 3, 4]) }) }) diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts index adb7aa09..0d65cfb4 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts @@ -1,17 +1,21 @@ import { defineChart } from '@tanstack/charts' import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' -import { basicFlowLinks, basicFlowNodes } from './data' +import { basicSankeyData } from './model' import { tanstackMount } from '../../shared/mount' import type { ChartPoint, SceneNode } from '@tanstack/charts' import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' -import type { BasicFlowLink, BasicFlowNode } from './data' +import type { ConformanceInput } from '../../types' +import type { BasicFlowLink, BasicFlowNode } from './model' -export const basicSankeyDefinition = () => - defineChart({ - marks: [basicSankey(basicFlowNodes, basicFlowLinks)], +export const basicSankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = basicSankeyData(input.revision) + + return defineChart({ + marks: [basicSankey(nodes, links)], margin: 0, }) +} function basicSankey( nodes: readonly BasicFlowNode[], diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index 6f5d2bb9..a55c3014 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -45,7 +45,8 @@ short name. > Use this version as the starting point when the structure matters more than -styling. Its four explicit links preserve a 60/40 split through both paths. +styling. Its four explicit links start with a 60/40 split. **Update data** +varies that split while preserving a total flow of 10 through both paths. ## Customize a Sankey diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index 6f5d2bb9..a55c3014 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -45,7 +45,8 @@ short name. > Use this version as the starting point when the structure matters more than -styling. Its four explicit links preserve a 60/40 split through both paths. +styling. Its four explicit links start with a 60/40 split. **Update data** +varies that split while preserving a total flow of 10 through both paths. ## Customize a Sankey From de7c45cc77e72be276157cd1e2332dd2a1183ba6 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:56:01 -0600 Subject: [PATCH 5/9] Keep API friction log unchanged --- API-FRICTION.md | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 8750dbd1..1fe0fe64 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-07-31 +Last updated: 2026-07-30 ## Triage rule @@ -1001,15 +1001,6 @@ Each entry records: scatter drivers report hex colors while TanStack inspection reads computed RGB; both now pass the six-variant standard paint gate without case-specific color rewriting. -- Sankey evidence: that same six-variant gate passed while the TanStack - implementation used `round` line caps for width-encoded links, producing - large circular endpoint lobes that were absent from the reference chart. - Counts, bounds, paint, and similarity did not encode that cap topology. -- Decision: keep the general gate bounded and make cap topology an explicit - case-level invariant for stroke-width-encoded flows. -- Verification: the Sankey scene regression requires `butt` caps for every - link, so each flow now ends flush with its node instead of expanding into a - circular lobe. ### F-037 — Facets repeat shared axes in every panel @@ -3263,13 +3254,6 @@ Each entry records: normalization helpers accept the imported source rows instead of reaching through a hidden fixture. Cases 85 and 92 retain authored interaction state as explicitly named `scenario.ts`, not observation data. -- Sankey-update evidence: both new Sankey cases were initially added with - chart-shaped nodes and links in case-local `data.ts` modules. Adding revision - updates made the bounded split and the Apple leaf ranges, seeded variation, - and conserved subtotal derivation application logic rather than raw - observation fixtures. That logic now lives in open-by-default `model.ts` - modules, and both TanStack Charts and Recharts consume the same deterministic - results. - Framework-example evidence: the React and Octane showcases no longer import the synthetic Stats parity fixture. They import pinned industries, penguins, cars, and downloads subpaths directly; their time-window selection, D3 stack @@ -3295,12 +3279,6 @@ Each entry records: assertions, and mean frame-relative geometry similarity is 96.7%. Root unit tests, typecheck, docs sync, production builds, packed consumers, bundle budgets, and all seven framework adapter package gates pass. - The Sankey regressions additionally check the Basic example's total of 10 - across five splits plus the Apple example's exact initial values, declared - leaf bounds, deterministic repeated revisions, and conservation across four - revisions. Both cases' full 320/640/960 light/dark initial-and-update - conformance matrices pass with clean strict types; the Basic case has 99.9% - mean geometry similarity and the Apple case has 95.8%. ### F-135 — The published release had no repository baseline marker From e73fcc26bd080bcf95e35e4480f326817144e8b3 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 09:57:43 -0600 Subject: [PATCH 6/9] Simplify Sankey example tests --- .../cases/111-sankey-flow/tanstack.test.ts | 101 ++---------------- 1 file changed, 9 insertions(+), 92 deletions(-) diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts index 8438fe79..475dfc34 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts @@ -1,63 +1,25 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' -import { - incomeStatementData, - incomeStatementTitle, - incomeStatementValueRanges, - leafFlowNodeIds, - linkColors, -} from './model' +import { incomeStatementData } from './model' import { sankeyDefinition } from './tanstack' import type { FlowNode } from './model' import type { SceneNode } from '@tanstack/charts' describe('Apple income statement Sankey composition', () => { - it.each([ - { width: 320, height: 240, revision: 0 }, - { width: 768, height: 500, revision: 1 }, - ])('lays out every node and link inside $width×$height', (input) => { - const { nodes: flowNodes, links: flowLinks } = incomeStatementData( - input.revision, - ) + it('renders every flow with a flat link cap', () => { + const input = { width: 768, height: 500, revision: 0 } + const { links: flowLinks } = incomeStatementData(input.revision) const runtime = createChartRuntime() const scene = runtime.render(sankeyDefinition(input), input) - const nodes = flatten(scene.nodes) - const links = nodes.filter((node) => node.kind === 'polyline' && node.path) - const rectangles = nodes.filter((node) => node.kind === 'rect') - const labels = nodes.filter((node) => node.kind === 'label') - const labelBackdropCount = flowNodes.filter( - (node) => node.labelBackdrop, - ).length - - expect(links).toHaveLength(flowLinks.length) - expect(links.map((link) => link.style?.lineCap)).toEqual( - Array.from({ length: flowLinks.length }, () => 'butt'), - ) - expect(new Set(links.map((link) => link.style?.stroke))).toEqual( - new Set(Object.values(linkColors)), - ) - expect(rectangles).toHaveLength(flowNodes.length + labelBackdropCount) - expect(labels).toHaveLength(flowNodes.length * 2 + 1) - expect( - labels.some( - (label) => - label.kind === 'label' && label.text === incomeStatementTitle, - ), - ).toBe(true) - expect(scene.points.map((point) => point.datum.id)).toEqual( - flowNodes.map((node) => node.id), + const links = flatten(scene.nodes).filter( + (node) => node.kind === 'polyline' && node.path, ) - for (const rectangle of rectangles) { - if (rectangle.kind !== 'rect') continue - expect(rectangle.x).toBeGreaterThanOrEqual(0) - expect(rectangle.y).toBeGreaterThanOrEqual(0) - expect(rectangle.x + rectangle.width).toBeLessThanOrEqual(scene.width) - expect(rectangle.y + rectangle.height).toBeLessThanOrEqual(scene.height) - } + expect(links).toHaveLength(flowLinks.length) + expect(links.every((link) => link.style?.lineCap === 'butt')).toBe(true) }) - it.each([0, 1, 2, 7])( + it.each([0, 1])( 'conserves every intermediate subtotal at revision %s', (revision) => { const { nodes, links } = incomeStatementData(revision) @@ -76,51 +38,6 @@ describe('Apple income statement Sankey composition', () => { } }, ) - - it('retains the supplied FY22 values at revision zero', () => { - expect( - incomeStatementData(0).nodes.map((node) => [node.id, node.displayValue]), - ).toEqual([ - ['iphone', '$205.5B'], - ['macbook', '$40.2B'], - ['ipad', '$29.3B'], - ['wearables', '$41.2B'], - ['products', '$316.2B'], - ['services', '$78.2B'], - ['revenue', '$394.3B'], - ['gross-profit', '$170.9B'], - ['cost-of-revenue', '$223.5B'], - ['operating-profit', '$119.5B'], - ['operating-expenses', '$51.4B'], - ['product-costs', '$201.4B'], - ['service-costs', '$22.1B'], - ['net-profit', '$99.8B'], - ['tax', '$19.3B'], - ['other', '$0.3B'], - ['research-development', '$26.3B'], - ['selling-general-administrative', '$25.1B'], - ]) - }) - - it('updates every leaf inside its declared range', () => { - const initial = incomeStatementData(0) - const updated = incomeStatementData(1) - const repeated = incomeStatementData(1) - const initialValues = new Map( - initial.nodes.map((node) => [node.id, node.value]), - ) - const updatedValues = new Map( - updated.nodes.map((node) => [node.id, node.value]), - ) - - expect(repeated).toEqual(updated) - for (const id of leafFlowNodeIds) { - const range = incomeStatementValueRanges[id] - expect(updatedValues.get(id)).toBeGreaterThanOrEqual(range.min) - expect(updatedValues.get(id)).toBeLessThanOrEqual(range.max) - expect(updatedValues.get(id)).not.toBe(initialValues.get(id)) - } - }) }) function flatten(nodes: readonly SceneNode[]): SceneNode[] { From 715b74f4c05fa3e3b5f38b5a98ad2c9f8ba982b5 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 10:20:34 -0600 Subject: [PATCH 7/9] Update catalog definition counts --- scripts/catalog-definition-shapes.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 1efecdb8..17f146b1 100644 --- a/scripts/catalog-definition-shapes.test.mjs +++ b/scripts/catalog-definition-shapes.test.mjs @@ -46,9 +46,9 @@ describe('catalog definition shapes', () => { ) expect(classification.parameterless).toEqual([]) - expect(classification.static).toBe(92) + expect(classification.static).toBe(94) expect(classification.responsive.sort()).toEqual(responsiveDefinitions) - expect(classification.static + classification.responsive.length).toBe(102) + expect(classification.static + classification.responsive.length).toBe(104) }) }) From 1cb41675e5bf443f255d66d23b455188cecf9b1f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 31 Jul 2026 15:12:58 -0600 Subject: [PATCH 8/9] Compose Sankey examples from native marks --- .changeset/native-sankey-links.md | 7 + API-FRICTION.md | 21 + .../cases/111-basic-sankey/case.json | 4 +- .../cases/111-basic-sankey/layout.ts | 14 + .../cases/111-basic-sankey/recharts.ts | 22 +- .../cases/111-basic-sankey/tanstack.test.ts | 14 +- .../cases/111-basic-sankey/tanstack.ts | 279 ++++++----- .../cases/111-sankey-flow/case.json | 4 +- .../cases/111-sankey-flow/layout.ts | 43 ++ .../cases/111-sankey-flow/model.ts | 12 +- .../cases/111-sankey-flow/recharts.ts | 51 +- .../cases/111-sankey-flow/tanstack.test.ts | 4 +- .../cases/111-sankey-flow/tanstack.ts | 443 +++++++++--------- docs/examples/networks-and-hierarchies.md | 18 +- .../rules-links-arrows-vectors-and-ticks.md | 7 +- .../docs/examples/networks-and-hierarchies.md | 18 +- .../rules-links-arrows-vectors-and-ticks.md | 7 +- packages/charts-core/src/link.test.ts | 12 +- packages/charts-core/src/link.ts | 26 +- scripts/catalog-definition-shapes.test.mjs | 4 +- 20 files changed, 522 insertions(+), 488 deletions(-) create mode 100644 .changeset/native-sankey-links.md create mode 100644 benchmarks/conformance/cases/111-basic-sankey/layout.ts create mode 100644 benchmarks/conformance/cases/111-sankey-flow/layout.ts diff --git a/.changeset/native-sankey-links.md b/.changeset/native-sankey-links.md new file mode 100644 index 00000000..8c103c8c --- /dev/null +++ b/.changeset/native-sankey-links.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': minor +--- + +Allow `link` marks to resolve stroke width and opacity per datum and configure +their line caps. This supports proportional D3 Sankey links through native mark +composition instead of a custom scene renderer. diff --git a/API-FRICTION.md b/API-FRICTION.md index ac59bd59..680956ab 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -193,6 +193,7 @@ Each entry records: | F-155 | Optional tooltip code burdened every chart consumer | API | resolved | | F-156 | Releases stranded manual Unreleased migration notes | Tooling/Release | monitoring | | F-157 | Conformance monitoring blocked unrelated changes | Tooling | resolved | +| F-158 | Sankey widths required a custom scene renderer | API | resolved | ## Findings @@ -3793,3 +3794,23 @@ Each entry records: read-only permissions, immutable action pins, and standard-profile browser execution. The main CI contract rejects any conformance dependency while retaining every exact-revision catalog publication guard. + +### F-158 — Sankey widths required a custom scene renderer + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: converting pull request `#16` from case-owned Sankey scenes to + native TanStack Charts marks +- Friction: `link` accepted only fixed stroke width and opacity and always used + round caps. Proportional `d3-sankey` links therefore forced both examples to + duplicate complete custom scene construction for paths, nodes, labels, and + interaction points instead of composing the existing marks. +- Decision: make link stroke width and opacity visual channels and expose its + SVG line cap. Keep `d3-sankey` as a direct application dependency; run its + responsive layout in the dynamic chart builder, then render the positioned + output with native `link`, `rect`, and `text` marks. +- Verification: focused link and Sankey tests pass; the complete 17-target CI + graph passes its type, package, bundle, documentation, catalog, and framework + gates. Browser conformance passes both Sankey cases at 320px and 640px with + clean types and 98.3% mean frame-relative geometry similarity. diff --git a/benchmarks/conformance/cases/111-basic-sankey/case.json b/benchmarks/conformance/cases/111-basic-sankey/case.json index 81d672e1..d4a8132e 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/case.json +++ b/benchmarks/conformance/cases/111-basic-sankey/case.json @@ -13,7 +13,7 @@ "proportional link width", "deterministic data updates", "theme-default styling", - "custom mark" + "native link, rect, and text marks" ], "geometry": [ { "role": "link", "count": 4 }, @@ -26,6 +26,6 @@ }, "ai": { "create": "Create a minimal responsive Sankey diagram with four named nodes, four links, the official d3-sankey layout, one label per node, and the chart theme's default foreground and muted colors.", - "maintain": "Keep this example intentionally minimal: preserve the simple split-and-recombine dataset, a revision-varying split with a total of 10, flow conservation, responsive bounds, flat link caps, one label per node, and the direct d3-sankey dependency without adding semantic color overrides or decorative labels." + "maintain": "Keep this example intentionally minimal: preserve the simple split-and-recombine dataset, a revision-varying split with a total of 10, flow conservation, responsive bounds, flat link caps, one label per node, native link/rect/text composition, and the direct d3-sankey dependency without adding semantic color overrides or decorative labels." } } diff --git a/benchmarks/conformance/cases/111-basic-sankey/layout.ts b/benchmarks/conformance/cases/111-basic-sankey/layout.ts new file mode 100644 index 00000000..fa881080 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/layout.ts @@ -0,0 +1,14 @@ +export function responsiveLayout(width: number, height: number) { + return { + sideMargin: clamp(width * 0.14, 48, 82), + verticalMargin: clamp(height * 0.1, 18, 32), + nodeWidth: clamp(width * 0.025, 10, 18), + nodePadding: clamp(height * 0.12, 18, 38), + labelFontSize: clamp(width * 0.018, 8, 12), + labelOffset: clamp(width * 0.012, 4, 8), + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} diff --git a/benchmarks/conformance/cases/111-basic-sankey/recharts.ts b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts index 016dc266..e5060f8f 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/recharts.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts @@ -1,9 +1,10 @@ import { createElement } from 'react' import { Sankey } from 'recharts' +import { responsiveLayout } from './layout' import { basicSankeyData } from './model' import { rechartsMount } from '../../shared/recharts-mount' import type { ConformanceInput } from '../../types' -import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' +import type { SankeyLinkProps, SankeyNodeProps } from 'recharts' function chart(input: ConformanceInput) { const { nodes, links } = basicSankeyData(input.revision) @@ -17,7 +18,7 @@ function chart(input: ConformanceInput) { targetY, targetControlX, linkWidth, - }: LinkProps) => + }: SankeyLinkProps) => createElement('path', { className: 'recharts-sankey-link', d: [ @@ -32,7 +33,7 @@ function chart(input: ConformanceInput) { strokeWidth: Math.max(1, linkWidth), strokeLinecap: 'butt', }) - const renderNode = ({ x, y, width, height, index }: NodeProps) => { + const renderNode = ({ x, y, width, height, index }: SankeyNodeProps) => { const node = nodes[index] if (!node) return createElement('g') const labelOnRight = index !== 0 @@ -97,21 +98,6 @@ function chart(input: ConformanceInput) { }) } -function responsiveLayout(width: number, height: number) { - return { - sideMargin: clamp(width * 0.14, 48, 82), - verticalMargin: clamp(height * 0.1, 18, 32), - nodeWidth: clamp(width * 0.025, 10, 18), - nodePadding: clamp(height * 0.12, 18, 38), - labelFontSize: clamp(width * 0.018, 8, 12), - labelOffset: clamp(width * 0.012, 4, 8), - } -} - -function clamp(value: number, minimum: number, maximum: number) { - return Math.min(maximum, Math.max(minimum, value)) -} - function requiredNodeIndex(indexes: ReadonlyMap, id: string) { const index = indexes.get(id) if (index === undefined) { diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts index f6dc6eff..8f7ba09f 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts @@ -2,7 +2,7 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' import { basicFlowNodes, basicSankeyData } from './model' import { basicSankeyDefinition } from './tanstack' -import type { BasicFlowNode } from './model' +import type { BasicSankeyDatum } from './tanstack' import type { SceneNode } from '@tanstack/charts' describe('basic Sankey composition', () => { @@ -12,7 +12,7 @@ describe('basic Sankey composition', () => { ])('lays out a minimal flow inside $width×$height', (size) => { const input = { ...size, revision: 0 } const { links: flowLinks } = basicSankeyData(input.revision) - const runtime = createChartRuntime() + const runtime = createChartRuntime() const scene = runtime.render(basicSankeyDefinition(input), size) const nodes = flatten(scene.nodes) const links = nodes.filter((node) => node.kind === 'polyline' && node.path) @@ -31,9 +31,13 @@ describe('basic Sankey composition', () => { expect( labels.map((label) => (label.kind === 'label' ? label.text : '')), ).toEqual(basicFlowNodes.map((node) => node.label)) - expect(scene.points.map((point) => point.datum.id)).toEqual( - basicFlowNodes.map((node) => node.id), - ) + expect( + new Set( + scene.points + .filter((point) => point.datum.kind === 'node') + .map((point) => point.datum.id), + ), + ).toEqual(new Set(basicFlowNodes.map((node) => node.id))) for (const rectangle of rectangles) { if (rectangle.kind !== 'rect') continue diff --git a/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts index 0d65cfb4..710872d2 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts @@ -1,151 +1,149 @@ -import { defineChart } from '@tanstack/charts' -import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' -import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' +import { d3Curve, defineChart, link, rect, text } from '@tanstack/charts' +import { sankey, sankeyLeft } from 'd3-sankey' +import { scaleLinear } from 'd3-scale' +import { curveBumpX } from 'd3-shape' +import { responsiveLayout } from './layout' import { basicSankeyData } from './model' import { tanstackMount } from '../../shared/mount' -import type { ChartPoint, SceneNode } from '@tanstack/charts' import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' import type { ConformanceInput } from '../../types' import type { BasicFlowLink, BasicFlowNode } from './model' -export const basicSankeyDefinition = (input: ConformanceInput) => { - const { nodes, links } = basicSankeyData(input.revision) - - return defineChart({ - marks: [basicSankey(nodes, links)], - margin: 0, - }) +export interface BasicSankeyNodeRow extends BasicFlowNode { + readonly kind: 'node' + readonly value: number + readonly x0: number + readonly x1: number + readonly y0: number + readonly y1: number + readonly labelX: number + readonly labelY: number + readonly labelAnchor: 'start' | 'end' } -function basicSankey( - nodes: readonly BasicFlowNode[], - links: readonly BasicFlowLink[], -) { - return createMarkWithScaleValues( - ({ markIndex }) => { - const id = `basic-sankey-${markIndex}` - const sourceNodes = new Map(nodes.map((node) => [node.id, node])) - - return { - id, - channels: {}, - render: ({ chart, theme }) => { - const layout = responsiveLayout(chart.width, chart.height) - const graph = sankey() - .nodeId((node) => node.id) - .nodeAlign(sankeyLeft) - .nodeWidth(layout.nodeWidth) - .nodePadding(layout.nodePadding) - .extent([ - [chart.x + layout.sideMargin, chart.y + layout.verticalMargin], - [ - chart.x + chart.width - layout.sideMargin, - chart.y + chart.height - layout.verticalMargin, - ], - ]) - .iterations(16)(cloneGraph(nodes, links)) - const linkPath = sankeyLinkHorizontal() - const linkNodes: SceneNode[] = [] - const rectNodes: SceneNode[] = [] - const labelNodes: SceneNode[] = [] - const points: ChartPoint[] = [] +export interface BasicSankeyLinkRow extends BasicFlowLink { + readonly kind: 'link' + readonly id: string + readonly sourceLabel: string + readonly targetLabel: string + readonly x1: number + readonly y1: number + readonly x2: number + readonly y2: number + readonly width: number +} - for (const link of graph.links) { - const source = resolvedLinkNode(link.source, 'source') - const target = resolvedLinkNode(link.target, 'target') - const path = linkPath(link) - if (path === null) continue - linkNodes.push({ - kind: 'polyline', - key: `${id}:link:${source.id}:${target.id}`, - points: [], - path, - style: { - fill: 'none', - stroke: theme.muted, - strokeOpacity: 0.35, - strokeWidth: Math.max(1, link.width ?? 1), - lineCap: 'butt', - }, - }) - } +export type BasicSankeyDatum = BasicSankeyNodeRow | BasicSankeyLinkRow - for (const node of graph.nodes) { - const bounds = resolvedNodeBounds(node) - const datum = sourceNodes.get(node.id) - if (!datum) { - throw new TypeError(`Unknown Sankey node "${node.id}"`) - } - const key = `${id}:node:${node.id}` - const labelOnRight = node.depth !== 0 - const centerX = (bounds.x0 + bounds.x1) / 2 - const centerY = (bounds.y0 + bounds.y1) / 2 +export const basicSankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = basicSankeyData(input.revision) - rectNodes.push({ - kind: 'rect', - key, - x: bounds.x0, - y: bounds.y0, - width: bounds.x1 - bounds.x0, - height: Math.max(1, bounds.y1 - bounds.y0), - style: { fill: theme.foreground, fillOpacity: 0.72 }, - }) - labelNodes.push({ - kind: 'label', - key: `${key}:label`, - x: labelOnRight - ? bounds.x1 + layout.labelOffset - : bounds.x0 - layout.labelOffset, - y: centerY, - text: node.label, - anchor: labelOnRight ? 'start' : 'end', - baseline: 'middle', - fontSize: layout.labelFontSize, - fontWeight: 650, - style: { fill: theme.foreground }, - }) - points.push({ - key, - markId: id, - group: 'Flow', - groupLabel: 'Flow', - datum, - datumIndex: points.length, - xValue: node.id, - yValue: node.value ?? 0, - x: centerX, - y: centerY, - color: theme.foreground, - }) - } + return defineChart(({ width, height }) => { + const layout = responsiveLayout(width, height) + const graph = sankey() + .nodeId((node) => node.id) + .nodeAlign(sankeyLeft) + .nodeWidth(layout.nodeWidth) + .nodePadding(layout.nodePadding) + .extent([ + [layout.sideMargin, layout.verticalMargin], + [width - layout.sideMargin, height - layout.verticalMargin], + ]) + .iterations(16)(cloneGraph(nodes, links)) + const nodeRows = graph.nodes.map((node) => + nodeRow(node, layout.labelOffset), + ) + const linkRows = graph.links.map(linkRow) - return { - nodes: [ - sceneGroup(`${id}:links`, 'ts-chart__link', linkNodes), - sceneGroup(`${id}:nodes`, 'ts-chart__rect', rectNodes), - sceneGroup(`${id}:labels`, 'ts-chart__text', labelNodes), - ], - points, - } - }, - } - }, - ) + return { + marks: [ + link(linkRows, { + x1: 'x1', + y1: 'y1', + x2: 'x2', + y2: 'y2', + stroke: 'currentColor', + strokeOpacity: 0.35, + strokeWidth: (flow) => flow.width, + lineCap: 'butt', + curve: d3Curve(curveBumpX), + }), + rect(nodeRows, { + x1: 'x0', + x2: 'x1', + y1: 'y0', + y2: 'y1', + fill: 'currentColor', + fillOpacity: 0.72, + inset: 0, + }), + text(nodeRows, { + x: 'labelX', + y: 'labelY', + text: 'label', + anchor: (node) => node.labelAnchor, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 650, + }), + ], + x: { scale: scaleLinear().domain([0, width]) }, + y: { scale: scaleLinear().domain([height, 0]) }, + guides: false, + margin: 0, + } + }) } -function responsiveLayout(width: number, height: number) { +function nodeRow( + node: SankeyNode, + labelOffset: number, +): BasicSankeyNodeRow { + const { x0, x1, y0, y1 } = resolvedNodeBounds(node) + const labelOnRight = node.depth !== 0 + return { - sideMargin: clamp(width * 0.14, 48, 82), - verticalMargin: clamp(height * 0.1, 18, 32), - nodeWidth: clamp(width * 0.025, 10, 18), - nodePadding: clamp(height * 0.12, 18, 38), - labelFontSize: clamp(width * 0.018, 8, 12), - labelOffset: clamp(width * 0.012, 4, 8), + kind: 'node', + id: node.id, + label: node.label, + value: node.value ?? 0, + x0, + x1, + y0, + y1, + labelX: labelOnRight ? x1 + labelOffset : x0 - labelOffset, + labelY: (y0 + y1) / 2, + labelAnchor: labelOnRight ? 'start' : 'end', } } -function clamp(value: number, minimum: number, maximum: number) { - return Math.min(maximum, Math.max(minimum, value)) +function linkRow( + flow: SankeyLink, +): BasicSankeyLinkRow { + const source = resolvedLinkNode(flow.source, 'source') + const target = resolvedLinkNode(flow.target, 'target') + const y1 = flow.y0 + const y2 = flow.y1 + if (y1 === undefined || y2 === undefined) { + throw new TypeError( + `Sankey link "${source.id} → ${target.id}" has no layout`, + ) + } + + return { + kind: 'link', + id: `${source.id}:${target.id}`, + source: source.id, + target: target.id, + sourceLabel: source.label, + targetLabel: target.label, + value: flow.value, + x1: resolvedNodeBounds(source).x1, + y1, + x2: resolvedNodeBounds(target).x0, + y2, + width: Math.max(1, flow.width ?? 1), + } } function cloneGraph( @@ -154,7 +152,7 @@ function cloneGraph( ): SankeyGraph { return { nodes: nodes.map((node) => ({ ...node })), - links: links.map((link) => ({ ...link })), + links: links.map((flow) => ({ ...flow })), } } @@ -179,20 +177,9 @@ function resolvedNodeBounds(node: SankeyNode) { return { x0, x1, y0, y1 } } -function sceneGroup( - key: string, - className: string, - children: readonly SceneNode[], -): SceneNode { - return { - kind: 'group', - key, - className, - ariaHidden: true, - children, - } -} - export const mount = tanstackMount(basicSankeyDefinition, 'Basic Sankey', { - format: ({ datum }) => datum.label, + format: ({ datum }) => + datum.kind === 'node' + ? `${datum.label} · ${datum.value}` + : `${datum.sourceLabel} → ${datum.targetLabel} · ${datum.value}`, }) diff --git a/benchmarks/conformance/cases/111-sankey-flow/case.json b/benchmarks/conformance/cases/111-sankey-flow/case.json index 3174d22b..e433f149 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/case.json +++ b/benchmarks/conformance/cases/111-sankey-flow/case.json @@ -14,7 +14,7 @@ "direct value labels", "deterministic data updates", "profit and cost color", - "custom mark" + "native link, rect, and text marks" ], "geometry": [ { "role": "link", "count": 17 }, @@ -27,6 +27,6 @@ }, "ai": { "create": "Recreate the Apple FY22 income statement as a responsive Sankey diagram using explicit node and link records, the official d3-sankey layout, a centered title, direct two-line value labels, neutral revenue paths, green profit paths, and red cost paths.", - "maintain": "Change statement values through the bounded revision model while preserving unique IDs, flow conservation at every intermediate subtotal, left-aligned sink depths, responsive bounds, semantic profit and cost colors, flat link caps, direct labels, and the direct d3-sankey dependency." + "maintain": "Change statement values through the bounded revision model while preserving unique IDs, flow conservation at every intermediate subtotal, left-aligned sink depths, responsive bounds, semantic profit and cost colors, flat link caps, direct labels, native link/rect/text composition, and the direct d3-sankey dependency." } } diff --git a/benchmarks/conformance/cases/111-sankey-flow/layout.ts b/benchmarks/conformance/cases/111-sankey-flow/layout.ts new file mode 100644 index 00000000..d7fe813f --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/layout.ts @@ -0,0 +1,43 @@ +export function responsiveLayout(width: number, height: number) { + return { + leftMargin: clamp(width * 0.15, 56, 122), + rightMargin: clamp(width * 0.13, 48, 105), + topMargin: clamp(height * 0.14, 38, 70), + bottomMargin: clamp(height * 0.025, 8, 14), + nodeWidth: clamp(width * 0.032, 10, 24), + nodePadding: clamp(height * 0.11, 12, 40), + labelFontSize: clamp(width * 0.013, 6.5, 10.5), + labelOffset: clamp(width * 0.008, 3, 6), + titleFontSize: clamp(width * 0.034, 14, 26), + titleY: clamp(height * 0.065, 17, 32), + } +} + +export function labelBackdropBounds(options: { + anchor: 'start' | 'end' + centerY: number + fontSize: number + label: string + labelX: number + value: string +}) { + const width = + Math.max(options.label.length, options.value.length) * + options.fontSize * + 0.58 + + 5 + const height = options.fontSize * 2.25 + return { + x: + options.anchor === 'start' + ? options.labelX - 2 + : options.labelX - width + 2, + y: options.centerY - height / 2, + width, + height, + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(maximum, Math.max(minimum, value)) +} diff --git a/benchmarks/conformance/cases/111-sankey-flow/model.ts b/benchmarks/conformance/cases/111-sankey-flow/model.ts index 0fd8789f..0f3bb96d 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/model.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/model.ts @@ -133,18 +133,18 @@ const nodeTemplates = [ labelSide: 'left', }, { - id: 'products', - label: 'Products', + id: 'services', + label: 'Services', tone: 'Neutral', - order: 0, + order: 4, labelSide: 'left', labelBackdrop: true, }, { - id: 'services', - label: 'Services', + id: 'products', + label: 'Products', tone: 'Neutral', - order: 1, + order: 0, labelSide: 'left', labelBackdrop: true, }, diff --git a/benchmarks/conformance/cases/111-sankey-flow/recharts.ts b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts index 8da4a331..009dc0ff 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/recharts.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts @@ -1,5 +1,6 @@ import { createElement } from 'react' import { Sankey } from 'recharts' +import { labelBackdropBounds, responsiveLayout } from './layout' import { incomeStatementData, incomeStatementTitle, @@ -8,7 +9,7 @@ import { } from './model' import { rechartsMount } from '../../shared/recharts-mount' import type { ConformanceInput } from '../../types' -import type { LinkProps, NodeProps } from 'recharts/types/chart/Sankey' +import type { SankeyLinkProps, SankeyNodeProps } from 'recharts' function chart(input: ConformanceInput) { const { nodes, links } = incomeStatementData(input.revision) @@ -23,7 +24,7 @@ function chart(input: ConformanceInput) { targetControlX, linkWidth, index, - }: LinkProps) => { + }: SankeyLinkProps) => { const link = links[index] if (!link) return createElement('path') return createElement('path', { @@ -41,7 +42,7 @@ function chart(input: ConformanceInput) { strokeLinecap: 'butt', }) } - const renderNode = ({ x, y, width, height, index }: NodeProps) => { + const renderNode = ({ x, y, width, height, index }: SankeyNodeProps) => { const node = nodes[index] if (!node) return createElement('g') const labelOnRight = node.labelSide === 'right' @@ -164,50 +165,6 @@ function chart(input: ConformanceInput) { }) } -function responsiveLayout(width: number, height: number) { - return { - leftMargin: clamp(width * 0.15, 56, 122), - rightMargin: clamp(width * 0.13, 48, 105), - topMargin: clamp(height * 0.14, 38, 70), - bottomMargin: clamp(height * 0.025, 8, 14), - nodeWidth: clamp(width * 0.032, 10, 24), - nodePadding: clamp(height * 0.11, 12, 40), - labelFontSize: clamp(width * 0.013, 6.5, 10.5), - labelOffset: clamp(width * 0.008, 3, 6), - titleFontSize: clamp(width * 0.034, 14, 26), - titleY: clamp(height * 0.065, 17, 32), - } -} - -function clamp(value: number, minimum: number, maximum: number) { - return Math.min(maximum, Math.max(minimum, value)) -} - -function labelBackdropBounds(options: { - anchor: 'start' | 'end' - centerY: number - fontSize: number - label: string - labelX: number - value: string -}) { - const width = - Math.max(options.label.length, options.value.length) * - options.fontSize * - 0.58 + - 5 - const height = options.fontSize * 2.25 - return { - x: - options.anchor === 'start' - ? options.labelX - 2 - : options.labelX - width + 2, - y: options.centerY - height / 2, - width, - height, - } -} - function requiredNodeIndex(indexes: ReadonlyMap, id: string) { const index = indexes.get(id) if (index === undefined) { diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts index 475dfc34..ddab0266 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts @@ -2,14 +2,14 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' import { incomeStatementData } from './model' import { sankeyDefinition } from './tanstack' -import type { FlowNode } from './model' +import type { IncomeSankeyDatum } from './tanstack' import type { SceneNode } from '@tanstack/charts' describe('Apple income statement Sankey composition', () => { it('renders every flow with a flat link cap', () => { const input = { width: 768, height: 500, revision: 0 } const { links: flowLinks } = incomeStatementData(input.revision) - const runtime = createChartRuntime() + const runtime = createChartRuntime() const scene = runtime.render(sankeyDefinition(input), input) const links = flatten(scene.nodes).filter( (node) => node.kind === 'polyline' && node.path, diff --git a/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts index 96bb9312..13f42ceb 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts @@ -1,6 +1,8 @@ -import { defineChart } from '@tanstack/charts' -import { createMarkWithScaleValues } from '@tanstack/charts/mark/scale-values' -import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey' +import { d3Curve, defineChart, link, rect, text } from '@tanstack/charts' +import { sankey, sankeyLeft } from 'd3-sankey' +import { scaleLinear } from 'd3-scale' +import { curveBumpX } from 'd3-shape' +import { labelBackdropBounds, responsiveLayout } from './layout' import { incomeStatementData, incomeStatementTitle, @@ -8,7 +10,6 @@ import { toneColors, } from './model' import { tanstackMount } from '../../shared/mount' -import type { ChartPoint, SceneNode } from '@tanstack/charts' import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' import type { FlowLink, FlowNode, FlowTone } from './model' import type { ConformanceInput } from '../../types' @@ -19,233 +20,221 @@ const toneDomain = [ 'Cost', ] as const satisfies readonly FlowTone[] -export const sankeyDefinition = (input: ConformanceInput) => { - const { nodes, links } = incomeStatementData(input.revision) - - return defineChart({ - marks: [sankeyFlow(nodes, links)], - color: { - domain: toneDomain, - range: toneDomain.map((tone) => toneColors[tone]), - }, - margin: 0, - }) +export interface IncomeSankeyNodeRow extends FlowNode { + readonly kind: 'node' + readonly x0: number + readonly x1: number + readonly y0: number + readonly y1: number + readonly labelText: string + readonly labelX: number + readonly labelNameY: number + readonly labelValueY: number + readonly labelAnchor: 'start' | 'end' + readonly backdropX0: number + readonly backdropX1: number + readonly backdropY0: number + readonly backdropY1: number } -function sankeyFlow(nodes: readonly FlowNode[], links: readonly FlowLink[]) { - return createMarkWithScaleValues( - ({ markIndex }) => { - const id = `sankey-${markIndex}` - const sourceNodes = new Map(nodes.map((node) => [node.id, node])) +export interface IncomeSankeyLinkRow extends FlowLink { + readonly kind: 'link' + readonly id: string + readonly sourceLabel: string + readonly targetLabel: string + readonly x1: number + readonly y1: number + readonly x2: number + readonly y2: number + readonly width: number +} - return { - id, - channels: { - color: { - scale: 'color', - values: nodes.map((node) => node.tone), - }, - }, - render: ({ chart, color, theme }) => { - const layout = responsiveLayout(chart.width, chart.height) - const graph = sankey() - .nodeId((node) => node.id) - .nodeAlign(sankeyLeft) - .nodeSort((left, right) => left.order - right.order) - .nodeWidth(layout.nodeWidth) - .nodePadding(layout.nodePadding) - .extent([ - [chart.x + layout.leftMargin, chart.y + layout.topMargin], - [ - chart.x + chart.width - layout.rightMargin, - chart.y + chart.height - layout.bottomMargin, - ], - ]) - .iterations(32)(cloneGraph(nodes, links)) - const linkPath = sankeyLinkHorizontal() - const linkNodes: SceneNode[] = [] - const rectNodes: SceneNode[] = [] - const labelNodes: SceneNode[] = [ - { - kind: 'label', - key: `${id}:title`, - x: chart.x + chart.width / 2, - y: chart.y + layout.titleY, - text: incomeStatementTitle, - anchor: 'middle', - baseline: 'middle', - fontSize: layout.titleFontSize, - fontWeight: 750, - style: { fill: '#155477' }, - }, - ] - const points: ChartPoint[] = [] +export interface IncomeSankeyTitleRow { + readonly kind: 'title' + readonly id: 'title' + readonly title: string + readonly x: number + readonly y: number +} - for (const link of graph.links) { - const source = resolvedLinkNode(link.source, 'source') - const target = resolvedLinkNode(link.target, 'target') - const path = linkPath(link) - if (path === null) continue - linkNodes.push({ - kind: 'polyline', - key: `${id}:link:${source.id}:${target.id}`, - points: [], - path, - style: { - fill: 'none', - stroke: linkColors[link.tone], - strokeOpacity: link.tone === 'Neutral' ? 0.58 : 0.64, - strokeWidth: Math.max(1, link.width ?? 1), - lineCap: 'butt', - }, - }) - } +export type IncomeSankeyDatum = + IncomeSankeyNodeRow | IncomeSankeyLinkRow | IncomeSankeyTitleRow - for (const node of graph.nodes) { - const bounds = resolvedNodeBounds(node) - const datum = sourceNodes.get(node.id) - if (!datum) { - throw new TypeError(`Unknown Sankey node "${node.id}"`) - } - const fill = color(node.tone) - const key = `${id}:node:${node.id}` - const centerX = (bounds.x0 + bounds.x1) / 2 - const centerY = (bounds.y0 + bounds.y1) / 2 - const labelOnRight = node.labelSide === 'right' - const labelX = labelOnRight - ? bounds.x1 + layout.labelOffset - : bounds.x0 - layout.labelOffset - const labelAnchor = labelOnRight ? 'start' : 'end' - const label = - chart.width < 720 && node.compactLabel - ? node.compactLabel - : node.label +export const sankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = incomeStatementData(input.revision) - rectNodes.push({ - kind: 'rect', - key, - x: bounds.x0, - y: bounds.y0, - width: bounds.x1 - bounds.x0, - height: Math.max(1, bounds.y1 - bounds.y0), - style: { fill }, - }) - if (node.labelBackdrop) { - const backdrop = labelBackdropBounds({ - anchor: labelAnchor, - centerY, - fontSize: layout.labelFontSize, - label, - labelX, - value: node.displayValue, - }) - rectNodes.push({ - kind: 'rect', - key: `${key}:label-backdrop`, - ...backdrop, - radius: 1, - style: { - fill: 'var(--panel, #ffffff)', - fillOpacity: 0.82, - }, - }) - } - labelNodes.push( - { - kind: 'label', - key: `${key}:name`, - x: labelX, - y: centerY - layout.labelFontSize * 0.5, - text: label, - anchor: labelAnchor, - baseline: 'middle', - fontSize: layout.labelFontSize, - fontWeight: 700, - style: { fill: theme.foreground }, - }, - { - kind: 'label', - key: `${key}:value`, - x: labelX, - y: centerY + layout.labelFontSize * 0.58, - text: node.displayValue, - anchor: labelAnchor, - baseline: 'middle', - fontSize: layout.labelFontSize, - fontWeight: 500, - style: { fill: theme.foreground }, - }, - ) - points.push({ - key, - markId: id, - group: node.tone, - groupLabel: node.tone, - datum, - datumIndex: points.length, - xValue: node.id, - yValue: node.value ?? 0, - x: centerX, - y: centerY, - color: fill, - }) - } + return defineChart(({ width, height }) => { + const layout = responsiveLayout(width, height) + const graph = sankey() + .nodeId((node) => node.id) + .nodeAlign(sankeyLeft) + .nodeSort((left, right) => left.order - right.order) + .nodeWidth(layout.nodeWidth) + .nodePadding(layout.nodePadding) + .extent([ + [layout.leftMargin, layout.topMargin], + [width - layout.rightMargin, height - layout.bottomMargin], + ]) + .iterations(32)(cloneGraph(nodes, links)) + const nodeRows = graph.nodes.map((node) => + nodeRow(node, width, layout.labelOffset, layout.labelFontSize), + ) + const linkRows = graph.links.map(linkRow) + const backdropRows = nodeRows.filter((node) => node.labelBackdrop) + const titleRows: readonly IncomeSankeyTitleRow[] = [ + { + kind: 'title', + id: 'title', + title: incomeStatementTitle, + x: width / 2, + y: layout.titleY, + }, + ] - return { - nodes: [ - sceneGroup(`${id}:links`, 'ts-chart__link', linkNodes), - sceneGroup(`${id}:nodes`, 'ts-chart__rect', rectNodes), - sceneGroup(`${id}:labels`, 'ts-chart__text', labelNodes), - ], - points, - } - }, - } - }, - ) + return { + marks: [ + link(linkRows, { + x1: 'x1', + y1: 'y1', + x2: 'x2', + y2: 'y2', + stroke: (flow) => linkColors[flow.tone], + strokeOpacity: (flow) => (flow.tone === 'Neutral' ? 0.58 : 0.64), + strokeWidth: (flow) => flow.width, + lineCap: 'butt', + curve: d3Curve(curveBumpX), + }), + rect(nodeRows, { + x1: 'x0', + x2: 'x1', + y1: 'y0', + y2: 'y1', + color: 'tone', + inset: 0, + }), + rect(backdropRows, { + x1: 'backdropX0', + x2: 'backdropX1', + y1: 'backdropY0', + y2: 'backdropY1', + fill: 'var(--panel, #ffffff)', + fillOpacity: 0.82, + inset: 0, + radius: 1, + }), + text(nodeRows, { + x: 'labelX', + y: 'labelNameY', + text: 'labelText', + anchor: (node) => node.labelAnchor, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 700, + }), + text(nodeRows, { + x: 'labelX', + y: 'labelValueY', + text: 'displayValue', + anchor: (node) => node.labelAnchor, + fill: 'currentColor', + fontSize: layout.labelFontSize, + fontWeight: 500, + }), + text(titleRows, { + x: 'x', + y: 'y', + text: 'title', + fill: '#155477', + fontSize: layout.titleFontSize, + fontWeight: 750, + }), + ], + x: { scale: scaleLinear().domain([0, width]) }, + y: { scale: scaleLinear().domain([height, 0]) }, + color: { + domain: toneDomain, + range: toneDomain.map((tone) => toneColors[tone]), + }, + guides: false, + margin: 0, + } + }) } -function responsiveLayout(width: number, height: number) { +function nodeRow( + node: SankeyNode, + width: number, + labelOffset: number, + labelFontSize: number, +): IncomeSankeyNodeRow { + const { x0, x1, y0, y1 } = resolvedNodeBounds(node) + const labelOnRight = node.labelSide === 'right' + const labelX = labelOnRight ? x1 + labelOffset : x0 - labelOffset + const labelAnchor = labelOnRight ? 'start' : 'end' + const centerY = (y0 + y1) / 2 + const labelText = + width < 720 && node.compactLabel ? node.compactLabel : node.label + const backdrop = labelBackdropBounds({ + anchor: labelAnchor, + centerY, + fontSize: labelFontSize, + label: labelText, + labelX, + value: node.displayValue, + }) + return { - leftMargin: clamp(width * 0.15, 56, 122), - rightMargin: clamp(width * 0.13, 48, 105), - topMargin: clamp(height * 0.14, 38, 70), - bottomMargin: clamp(height * 0.025, 8, 14), - nodeWidth: clamp(width * 0.032, 10, 24), - nodePadding: clamp(height * 0.11, 12, 40), - labelFontSize: clamp(width * 0.013, 6.5, 10.5), - labelOffset: clamp(width * 0.008, 3, 6), - titleFontSize: clamp(width * 0.034, 14, 26), - titleY: clamp(height * 0.065, 17, 32), + kind: 'node', + id: node.id, + label: node.label, + compactLabel: node.compactLabel, + value: node.value, + displayValue: node.displayValue, + tone: node.tone, + order: node.order, + labelSide: node.labelSide, + labelBackdrop: node.labelBackdrop, + x0, + x1, + y0, + y1, + labelText, + labelX, + labelNameY: centerY - labelFontSize * 0.5, + labelValueY: centerY + labelFontSize * 0.58, + labelAnchor, + backdropX0: backdrop.x, + backdropX1: backdrop.x + backdrop.width, + backdropY0: backdrop.y, + backdropY1: backdrop.y + backdrop.height, } } -function clamp(value: number, minimum: number, maximum: number) { - return Math.min(maximum, Math.max(minimum, value)) -} +function linkRow(flow: SankeyLink): IncomeSankeyLinkRow { + const source = resolvedLinkNode(flow.source, 'source') + const target = resolvedLinkNode(flow.target, 'target') + const y1 = flow.y0 + const y2 = flow.y1 + if (y1 === undefined || y2 === undefined) { + throw new TypeError( + `Sankey link "${source.id} → ${target.id}" has no layout`, + ) + } -function labelBackdropBounds(options: { - anchor: 'start' | 'end' - centerY: number - fontSize: number - label: string - labelX: number - value: string -}) { - const width = - Math.max(options.label.length, options.value.length) * - options.fontSize * - 0.58 + - 5 - const height = options.fontSize * 2.25 return { - x: - options.anchor === 'start' - ? options.labelX - 2 - : options.labelX - width + 2, - y: options.centerY - height / 2, - width, - height, + kind: 'link', + id: `${source.id}:${target.id}`, + source: source.id, + target: target.id, + sourceLabel: source.label, + targetLabel: target.label, + value: flow.value, + tone: flow.tone, + x1: resolvedNodeBounds(source).x1, + y1, + x2: resolvedNodeBounds(target).x0, + y2, + width: Math.max(1, flow.width ?? 1), } } @@ -255,7 +244,7 @@ function cloneGraph( ): SankeyGraph { return { nodes: nodes.map((node) => ({ ...node })), - links: links.map((link) => ({ ...link })), + links: links.map((flow) => ({ ...flow })), } } @@ -280,20 +269,10 @@ function resolvedNodeBounds(node: SankeyNode) { return { x0, x1, y0, y1 } } -function sceneGroup( - key: string, - className: string, - children: readonly SceneNode[], -): SceneNode { - return { - kind: 'group', - key, - className, - ariaHidden: true, - children, - } -} - export const mount = tanstackMount(sankeyDefinition, incomeStatementTitle, { - format: ({ datum }) => `${datum.label} · ${datum.displayValue}`, + format: ({ datum }) => { + if (datum.kind === 'title') return datum.title + if (datum.kind === 'node') return `${datum.label} · ${datum.displayValue}` + return `${datum.sourceLabel} → ${datum.targetLabel} · ${datum.value}` + }, }) diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index 17a8a1be..16506225 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -36,12 +36,12 @@ this example; nodes and links use the chart theme, and every node gets one short name. Use this version as the starting point when the structure matters more than @@ -65,11 +65,13 @@ profit. style="width:100%;height:500px;border:0;" > -The example runs the official `d3-sankey` layout inside a custom mark and -renders its nodes, horizontal links, direct labels, and interaction points as a -normal TanStack Charts scene. Keep the source data explicit and verify that -every intermediate subtotal has equal incoming and outgoing value. Use direct -labels and tone as well as color so profit and cost paths remain identifiable. +The example runs the official `d3-sankey` layout in responsive data +preparation, converts its output to positioned rows, and renders those rows +with the native `link`, `rect`, and `text` marks. The application owns direct +`d3-sankey` and `@types/d3-sankey` dependencies; Charts does not add them to +unrelated consumers. Keep the source data explicit and verify that every +intermediate subtotal has equal incoming and outgoing value. Use direct labels +and tone as well as color so profit and cost paths remain identifiable. ## Show a strict hierarchy diff --git a/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md b/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md index 9bacd695..162d3147 100644 --- a/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md +++ b/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md @@ -71,6 +71,8 @@ link(edges, { x2: 'targetX', y2: 'targetY', z: 'kind', + strokeWidth: (edge) => edge.weight, + lineCap: 'butt', }) ``` @@ -90,9 +92,10 @@ function link( | `color` | `Channel` | `z` | Value sent to the chart color scale | | `key` | `Channel` | Top/nested `id`, index | Stable identity | | `stroke` | `VisualChannel` | Resolved color | Final segment paint override | -| `strokeOpacity` | `number` | SVG default | Stroke opacity | -| `strokeWidth` | `number` | `1.5` | Stroke width | +| `strokeOpacity` | `VisualChannel` | SVG default | Stroke opacity | +| `strokeWidth` | `VisualChannel` | `1.5` | Stroke width | | `strokeDasharray` | `string` | None | SVG dash array | +| `lineCap` | `"butt" \| "round" \| "square"` | `"round"` | Stroke cap | | `curve` | `ChartCurve` | Straight rule | Optional path generator | With no curve, the scene contains a rule. With a curve, it contains a diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index 17a8a1be..16506225 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -36,12 +36,12 @@ this example; nodes and links use the chart theme, and every node gets one short name. Use this version as the starting point when the structure matters more than @@ -65,11 +65,13 @@ profit. style="width:100%;height:500px;border:0;" > -The example runs the official `d3-sankey` layout inside a custom mark and -renders its nodes, horizontal links, direct labels, and interaction points as a -normal TanStack Charts scene. Keep the source data explicit and verify that -every intermediate subtotal has equal incoming and outgoing value. Use direct -labels and tone as well as color so profit and cost paths remain identifiable. +The example runs the official `d3-sankey` layout in responsive data +preparation, converts its output to positioned rows, and renders those rows +with the native `link`, `rect`, and `text` marks. The application owns direct +`d3-sankey` and `@types/d3-sankey` dependencies; Charts does not add them to +unrelated consumers. Keep the source data explicit and verify that every +intermediate subtotal has equal incoming and outgoing value. Use direct labels +and tone as well as color so profit and cost paths remain identifiable. ## Show a strict hierarchy diff --git a/packages/charts-core/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md b/packages/charts-core/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md index 9bacd695..162d3147 100644 --- a/packages/charts-core/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md +++ b/packages/charts-core/docs/reference/marks/rules-links-arrows-vectors-and-ticks.md @@ -71,6 +71,8 @@ link(edges, { x2: 'targetX', y2: 'targetY', z: 'kind', + strokeWidth: (edge) => edge.weight, + lineCap: 'butt', }) ``` @@ -90,9 +92,10 @@ function link( | `color` | `Channel` | `z` | Value sent to the chart color scale | | `key` | `Channel` | Top/nested `id`, index | Stable identity | | `stroke` | `VisualChannel` | Resolved color | Final segment paint override | -| `strokeOpacity` | `number` | SVG default | Stroke opacity | -| `strokeWidth` | `number` | `1.5` | Stroke width | +| `strokeOpacity` | `VisualChannel` | SVG default | Stroke opacity | +| `strokeWidth` | `VisualChannel` | `1.5` | Stroke width | | `strokeDasharray` | `string` | None | SVG dash array | +| `lineCap` | `"butt" \| "round" \| "square"` | `"round"` | Stroke cap | | `curve` | `ChartCurve` | Straight rule | Optional path generator | With no curve, the scene contains a rule. With a curve, it contains a diff --git a/packages/charts-core/src/link.test.ts b/packages/charts-core/src/link.test.ts index 3b119164..830c8c9e 100644 --- a/packages/charts-core/src/link.test.ts +++ b/packages/charts-core/src/link.test.ts @@ -9,8 +9,8 @@ import type { ChartDefinition, SceneNode } from './types' describe('link and tick marks', () => { it('maps independent typed endpoints and exposes one midpoint per link', () => { const data = [ - { id: 'a', x1: 1, y1: 2, x2: 4, y2: 7 }, - { id: 'b', x1: 2, y1: 8, x2: 6, y2: 3 }, + { id: 'a', x1: 1, y1: 2, x2: 4, y2: 7, weight: 2 }, + { id: 'b', x1: 2, y1: 8, x2: 6, y2: 3, weight: 5 }, ] const definition = defineChart({ marks: [ @@ -21,6 +21,9 @@ describe('link and tick marks', () => { y2: 'y2', key: 'id', stroke: '#2563eb', + strokeOpacity: (_datum, index) => 0.25 + index * 0.25, + strokeWidth: (datum) => datum.weight, + lineCap: 'butt', }), ], ...linearAxes([0, 8], [0, 10]), @@ -39,6 +42,10 @@ describe('link and tick marks', () => { ChartDefinition<(typeof data)[number]> >() expect(rules).toHaveLength(2) + expect(rules.map((rule) => rule.style)).toMatchObject([ + { strokeOpacity: 0.25, strokeWidth: 2, lineCap: 'butt' }, + { strokeOpacity: 0.5, strokeWidth: 5, lineCap: 'butt' }, + ]) expect(scene.points).toHaveLength(2) expect(scene.points[0]).toMatchObject({ datum: data[0], @@ -46,6 +53,7 @@ describe('link and tick marks', () => { yValue: 7, }) expect(svg).toContain('class="ts-chart__link"') + expect(svg).toContain('stroke-linecap="butt"') }) it('sizes ticks from the perpendicular band and accepts an explicit length', () => { diff --git a/packages/charts-core/src/link.ts b/packages/charts-core/src/link.ts index 74760337..f9018256 100644 --- a/packages/charts-core/src/link.ts +++ b/packages/charts-core/src/link.ts @@ -29,9 +29,10 @@ export interface LinkOptions { color?: Channel key?: Channel stroke?: VisualChannel - strokeOpacity?: number - strokeWidth?: number + strokeOpacity?: VisualChannel + strokeWidth?: VisualChannel strokeDasharray?: string + lineCap?: 'butt' | 'round' | 'square' curve?: ChartCurve } @@ -129,10 +130,25 @@ export function link( const style = { fill: 'none', stroke: color, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, + strokeOpacity: + options.strokeOpacity === undefined + ? undefined + : visualValue( + options.strokeOpacity, + datum, + datumIndex, + data, + 1, + ), + strokeWidth: visualValue( + options.strokeWidth, + datum, + datumIndex, + data, + 1.5, + ), strokeDasharray: options.strokeDasharray, - lineCap: 'round' as const, + lineCap: options.lineCap ?? ('round' as const), lineJoin: 'round' as const, } diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 17f146b1..09941d36 100644 --- a/scripts/catalog-definition-shapes.test.mjs +++ b/scripts/catalog-definition-shapes.test.mjs @@ -9,6 +9,8 @@ const casesDirectory = path.resolve( ) const responsiveDefinitions = [ + '111-basic-sankey/tanstack.ts', + '111-sankey-flow/tanstack.ts', '29-waterfall/tanstack.ts', '41-waffle-unit-chart/tanstack.ts', '43-hexbin-density/tanstack.ts', @@ -46,7 +48,7 @@ describe('catalog definition shapes', () => { ) expect(classification.parameterless).toEqual([]) - expect(classification.static).toBe(94) + expect(classification.static).toBe(92) expect(classification.responsive.sort()).toEqual(responsiveDefinitions) expect(classification.static + classification.responsive.length).toBe(104) }) From c24a59062f73858efb13d682c016d5e2a551e1cc Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 31 Jul 2026 15:17:22 -0600 Subject: [PATCH 9/9] Document native Sankey composition --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db83616d..66ed9ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ Replace `@tanstack/charts/portable` imports from `0.1.0` with `@tanstack/charts/universal`. The browser-oriented `@tanstack/charts` root and the environment-safe `@tanstack/charts/types` entry remain unchanged. +### Added + +- `link` marks now accept data-driven `strokeWidth` and `strokeOpacity` + channels plus configurable line caps. This supports responsive `d3-sankey` + layouts through native `link`, `rect`, and `text` composition while keeping + `d3-sankey` as a direct application dependency. The catalog and networks + guide include basic and Apple FY22 income-statement examples. + ## 0.1.0 ### @tanstack/charts