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/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 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..d4a8132e --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/case.json @@ -0,0 +1,31 @@ +{ + "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", + "deterministic data updates", + "theme-default styling", + "native link, rect, and text marks" + ], + "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, 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/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 new file mode 100644 index 00000000..e5060f8f --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/recharts.ts @@ -0,0 +1,109 @@ +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 { SankeyLinkProps, SankeyNodeProps } from 'recharts' + +function chart(input: ConformanceInput) { + const { nodes, links } = basicSankeyData(input.revision) + const layout = responsiveLayout(input.width, input.height) + const nodeIndexes = new Map(nodes.map((node, index) => [node.id, index])) + const renderLink = ({ + sourceX, + sourceY, + sourceControlX, + targetX, + targetY, + targetControlX, + linkWidth, + }: SankeyLinkProps) => + 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 }: SankeyNodeProps) => { + const node = nodes[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: nodes.map((node) => ({ ...node })), + links: links.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 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..8f7ba09f --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts @@ -0,0 +1,87 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { basicFlowNodes, basicSankeyData } from './model' +import { basicSankeyDefinition } from './tanstack' +import type { BasicSankeyDatum } from './tanstack' +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 input = { ...size, revision: 0 } + const { links: flowLinks } = basicSankeyData(input.revision) + 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) + const rectangles = nodes.filter((node) => node.kind === 'rect') + const labels = nodes.filter((node) => node.kind === 'label') + + 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(['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( + 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 + 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('updates the split while conserving a total of 10', () => { + const pathAValues = [0, 1, 2, 3, 4].map((revision) => { + const { links } = basicSankeyData(revision) + + 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]) + }) +}) + +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..710872d2 --- /dev/null +++ b/benchmarks/conformance/cases/111-basic-sankey/tanstack.ts @@ -0,0 +1,185 @@ +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 { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' +import type { ConformanceInput } from '../../types' +import type { BasicFlowLink, BasicFlowNode } from './model' + +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' +} + +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 +} + +export type BasicSankeyDatum = BasicSankeyNodeRow | BasicSankeyLinkRow + +export const basicSankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = basicSankeyData(input.revision) + + 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 { + 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 nodeRow( + node: SankeyNode, + labelOffset: number, +): BasicSankeyNodeRow { + const { x0, x1, y0, y1 } = resolvedNodeBounds(node) + const labelOnRight = node.depth !== 0 + + return { + 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 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( + nodes: readonly BasicFlowNode[], + links: readonly BasicFlowLink[], +): SankeyGraph { + return { + nodes: nodes.map((node) => ({ ...node })), + links: links.map((flow) => ({ ...flow })), + } +} + +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 } +} + +export const mount = tanstackMount(basicSankeyDefinition, 'Basic Sankey', { + 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 new file mode 100644 index 00000000..e433f149 --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/case.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1120, + "id": "111-sankey-flow", + "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", + "features": [ + "d3-sankey layout", + "responsive node positioning", + "proportional link width", + "direct value labels", + "deterministic data updates", + "profit and cost color", + "native link, rect, and text marks" + ], + "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 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 new file mode 100644 index 00000000..0f3bb96d --- /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: 'services', + label: 'Services', + tone: 'Neutral', + order: 4, + labelSide: 'left', + labelBackdrop: true, + }, + { + id: 'products', + label: 'Products', + tone: 'Neutral', + order: 0, + 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 new file mode 100644 index 00000000..009dc0ff --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/recharts.ts @@ -0,0 +1,176 @@ +import { createElement } from 'react' +import { Sankey } from 'recharts' +import { labelBackdropBounds, responsiveLayout } from './layout' +import { + incomeStatementData, + incomeStatementTitle, + linkColors, + toneColors, +} from './model' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' +import type { SankeyLinkProps, SankeyNodeProps } from 'recharts' + +function chart(input: ConformanceInput) { + const { nodes, links } = incomeStatementData(input.revision) + const layout = responsiveLayout(input.width, input.height) + const nodeIndexes = new Map(nodes.map((node, index) => [node.id, index])) + const renderLink = ({ + sourceX, + sourceY, + sourceControlX, + targetX, + targetY, + targetControlX, + linkWidth, + index, + }: SankeyLinkProps) => { + const link = links[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 }: SankeyNodeProps) => { + const node = nodes[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: nodes.map((node) => ({ ...node })), + links: links.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 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..ddab0266 --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.test.ts @@ -0,0 +1,47 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { incomeStatementData } from './model' +import { sankeyDefinition } from './tanstack' +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 scene = runtime.render(sankeyDefinition(input), input) + const links = flatten(scene.nodes).filter( + (node) => node.kind === 'polyline' && node.path, + ) + + expect(links).toHaveLength(flowLinks.length) + expect(links.every((link) => link.style?.lineCap === 'butt')).toBe(true) + }) + + it.each([0, 1])( + '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) + } + } + }, + ) +}) + +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..13f42ceb --- /dev/null +++ b/benchmarks/conformance/cases/111-sankey-flow/tanstack.ts @@ -0,0 +1,278 @@ +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, + linkColors, + toneColors, +} from './model' +import { tanstackMount } from '../../shared/mount' +import type { SankeyGraph, SankeyLink, SankeyNode } from 'd3-sankey' +import type { FlowLink, FlowNode, FlowTone } from './model' +import type { ConformanceInput } from '../../types' + +const toneDomain = [ + 'Neutral', + 'Profit', + 'Cost', +] as const satisfies readonly FlowTone[] + +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 +} + +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 +} + +export interface IncomeSankeyTitleRow { + readonly kind: 'title' + readonly id: 'title' + readonly title: string + readonly x: number + readonly y: number +} + +export type IncomeSankeyDatum = + IncomeSankeyNodeRow | IncomeSankeyLinkRow | IncomeSankeyTitleRow + +export const sankeyDefinition = (input: ConformanceInput) => { + const { nodes, links } = incomeStatementData(input.revision) + + 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 { + 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 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 { + 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 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`, + ) + } + + return { + 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), + } +} + +function cloneGraph( + nodes: readonly FlowNode[], + links: readonly FlowLink[], +): SankeyGraph { + return { + nodes: nodes.map((node) => ({ ...node })), + links: links.map((flow) => ({ ...flow })), + } +} + +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 } +} + +export const mount = tanstackMount(sankeyDefinition, incomeStatementTitle, { + 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 0df72583..16506225 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,8 @@ 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 | @@ -26,6 +28,51 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. +## 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 start with a 60/40 split. **Update data** +varies that split while preserving a total flow of 10 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 +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 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 A tidy tree assigns one position per node and one link per parent-child 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/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 486b41ee..d2f1c8f8 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,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", @@ -113,6 +114,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 0df72583..16506225 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -18,6 +18,8 @@ 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 | @@ -26,6 +28,51 @@ preparation. [Scales and D3](../concepts/scales-and-d3.md) routes those algorithms to the official D3 documentation while TanStack Charts renders the typed result. +## 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 start with a 60/40 split. **Update data** +varies that split while preserving a total flow of 10 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 +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 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 A tidy tree assigns one position per node and one link per parent-child 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/pnpm-lock.yaml b/pnpm-lock.yaml index 35665e3a..4fb39baf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,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 @@ -172,6 +175,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 @@ -345,6 +351,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 @@ -355,6 +364,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 @@ -3117,6 +3129,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: { @@ -3135,6 +3153,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: { @@ -3147,6 +3171,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: { @@ -3825,6 +3855,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: { @@ -3951,6 +3987,12 @@ packages: } engines: { node: '>=12' } + d3-path@1.0.9: + resolution: + { + integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==, + } + d3-path@3.1.0: resolution: { @@ -3979,6 +4021,12 @@ packages: } engines: { node: '>=12' } + d3-sankey@0.12.3: + resolution: + { + integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==, + } + d3-scale-chromatic@3.1.0: resolution: { @@ -4000,6 +4048,12 @@ packages: } engines: { node: '>=12' } + d3-shape@1.3.7: + resolution: + { + integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==, + } + d3-shape@3.2.0: resolution: { @@ -4752,6 +4806,12 @@ packages: integrity: sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==, } + internmap@1.0.1: + resolution: + { + integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==, + } + internmap@2.0.3: resolution: { @@ -7948,18 +8008,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 @@ -8343,6 +8413,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 @@ -8410,6 +8484,8 @@ snapshots: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} d3-polygon@3.0.1: {} @@ -8418,6 +8494,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 @@ -8433,6 +8514,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 @@ -8892,6 +8977,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..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: 100, + tanstack: 102, 'observable-plot': 68, - recharts: 21, + recharts: 23, echarts: 11, }) diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 1efecdb8..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', @@ -48,7 +50,7 @@ describe('catalog definition shapes', () => { expect(classification.parameterless).toEqual([]) expect(classification.static).toBe(92) expect(classification.responsive.sort()).toEqual(responsiveDefinitions) - expect(classification.static + classification.responsive.length).toBe(102) + expect(classification.static + classification.responsive.length).toBe(104) }) })