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 },
    ],
  }
}