Source

709 chart lines · 3 files · 19.1 kBBenchmark harness excluded · shared/mount.ts

cases/111-sankey-flow/tanstack.ts278 lines · entry
cases/111-sankey-flow/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 { 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<FlowNode, FlowLink>()
      .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<FlowNode, FlowLink>,
  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<FlowNode, FlowLink>): 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<FlowNode, FlowLink> {
  return {
    nodes: nodes.map((node) => ({ ...node })),
    links: links.map((flow) => ({ ...flow })),
  }
}

function resolvedLinkNode(
  node: SankeyLink<FlowNode, FlowLink>['source'],
  endpoint: 'source' | 'target',
): SankeyNode<FlowNode, FlowLink> {
  if (typeof node === 'object') return node
  throw new TypeError(`Unresolved Sankey link ${endpoint}`)
}

function resolvedNodeBounds(node: SankeyNode<FlowNode, FlowLink>) {
  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}`
  },
})
cases/111-sankey-flow/layout.ts43 lines · support
cases/111-sankey-flow/layout.ts
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))
}
cases/111-sankey-flow/model.ts388 lines · support
cases/111-sankey-flow/model.ts
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<FlowNode, 'value' | 'displayValue'>

export const incomeStatementTitle = 'Apple FY22 Income Statement'

export const toneColors = {
  Neutral: '#666666',
  Profit: '#00b51a',
  Cost: '#b50905',
} as const satisfies Record<FlowTone, string>

export const linkColors = {
  Neutral: '#8a8a8a',
  Profit: '#50c955',
  Cost: '#c96363',
} as const satisfies Record<FlowTone, string>

// 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<LeafFlowNodeId, ValueRange>

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<FlowNodeId, string>

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<FlowNodeId, number>

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