Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/native-sankey-links.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions benchmarks/conformance/cases/111-basic-sankey/case.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
14 changes: 14 additions & 0 deletions benchmarks/conformance/cases/111-basic-sankey/layout.ts
Original file line number Diff line number Diff line change
@@ -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))
}
41 changes: 41 additions & 0 deletions benchmarks/conformance/cases/111-basic-sankey/model.ts
Original file line number Diff line number Diff line change
@@ -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 },
],
}
}
109 changes: 109 additions & 0 deletions benchmarks/conformance/cases/111-basic-sankey/recharts.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>, 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')
87 changes: 87 additions & 0 deletions benchmarks/conformance/cases/111-basic-sankey/tanstack.test.ts
Original file line number Diff line number Diff line change
@@ -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<BasicSankeyDatum, number, number>()
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],
)
}
Loading