Source
240 chart lines · 3 files · 6.8 kBBenchmark harness excluded · shared/mount.ts
cases/111-basic-sankey/tanstack.ts185 lines · entry
cases/111-basic-sankey/tanstack.ts
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<BasicFlowNode, BasicFlowLink>()
.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<BasicFlowNode, BasicFlowLink>,
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<BasicFlowNode, BasicFlowLink>,
): 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<BasicFlowNode, BasicFlowLink> {
return {
nodes: nodes.map((node) => ({ ...node })),
links: links.map((flow) => ({ ...flow })),
}
}
function resolvedLinkNode(
node: SankeyLink<BasicFlowNode, BasicFlowLink>['source'],
endpoint: 'source' | 'target',
): SankeyNode<BasicFlowNode, BasicFlowLink> {
if (typeof node === 'object') return node
throw new TypeError(`Unresolved Sankey link ${endpoint}`)
}
function resolvedNodeBounds(node: SankeyNode<BasicFlowNode, BasicFlowLink>) {
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}`,
})cases/111-basic-sankey/layout.ts14 lines · support
cases/111-basic-sankey/layout.ts
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))
}cases/111-basic-sankey/model.ts41 lines · support
cases/111-basic-sankey/model.ts
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 },
],
}
}