TanStack
Catalog

Sankey

network

1,358 lines · 6 files · 36.0 kB

cases/111-sankey-flow/layout.ts43 lines · dependency
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 · dependency
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 }
}
cases/111-sankey-flow/tanstack.ts228 lines · entry
cases/111-sankey-flow/tanstack.ts
import { d3Curve, defineChart, link, rect, text } from '@tanstack/charts'
import { sankeyDiagram } from '@tanstack/charts/network/sankey'
import { curveBumpX } from 'd3-shape'
import { labelBackdropBounds } from './layout'
import {
  incomeStatementData,
  incomeStatementTitle,
  linkColors,
  toneColors,
} from './model'
import { tanstackMount } from '../../shared/mount'
import type { SankeyLink, SankeyNode } from '@tanstack/charts/network/sankey'
import type { FlowLink, FlowNode, FlowNodeId, FlowTone } from './model'
import type { ConformanceInput } from '../../types'

const toneDomain = [
  'Neutral',
  'Profit',
  'Cost',
] as const satisfies readonly FlowTone[]

export type IncomeSankeyNodeRow = SankeyNode<FlowNode, FlowLink, FlowNodeId>
export type IncomeSankeyLinkRow = SankeyLink<FlowNode, FlowLink, FlowNodeId>

export interface IncomeSankeyTitleRow {
  readonly kind: 'title'
  readonly id: 'title'
  readonly title: string
  readonly x: number
  readonly y: number
}

interface IncomeSankeyLabelRow extends IncomeSankeyNodeRow {
  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 type IncomeSankeyDatum =
  IncomeSankeyNodeRow | IncomeSankeyLinkRow | IncomeSankeyTitleRow

export const sankeyDefinition = (input: ConformanceInput) => {
  const sourceData = incomeStatementData(input.revision)

  return defineChart({
    marks: [
      sankeyDiagram({
        id: 'income-sankey',
        nodes: sourceData.nodes,
        links: sourceData.links,
        nodeKey: 'id',
        source: 'source',
        target: 'target',
        value: 'value',
        align: 'left',
        nodeSort: (left, right) => left.data.order - right.data.order,
        nodeWidth:
          input.preview === true
            ? 8
            : ({ width }) => clamp(width * 0.032, 10, 24),
        nodePadding:
          input.preview === true
            ? 3
            : ({ height }) => clamp(height * 0.11, 12, 40),
        inset:
          input.preview === true
            ? 4
            : ({ width, height }) => ({
                left: clamp(width * 0.15, 56, 122),
                right: clamp(width * 0.13, 48, 105),
                top: clamp(height * 0.14, 38, 70),
                bottom: clamp(height * 0.025, 8, 14),
              }),
        iterations: 32,
        marks: ({ chart, nodes: sankeyNodes, links: sankeyLinks }) => {
          const flowMarks = [
            link(sankeyLinks, {
              id: 'links',
              x1: 'x1',
              y1: 'y1',
              x2: 'x2',
              y2: 'y2',
              key: 'key',
              stroke: (flow) => linkColors[flow.data.tone],
              strokeOpacity: (flow) =>
                flow.data.tone === 'Neutral' ? 0.58 : 0.64,
              strokeWidth: (flow) => Math.max(1, flow.width),
              lineCap: 'butt',
              curve: d3Curve(curveBumpX),
            }),
            rect(sankeyNodes, {
              id: 'nodes',
              x1: 'x0',
              x2: 'x1',
              y1: 'y0',
              y2: 'y1',
              key: 'key',
              color: (node) => node.data.tone,
              inset: 0,
            }),
          ] as const
          if (input.preview === true) return flowMarks

          const labelFontSize = clamp(chart.width * 0.013, 6.5, 10.5)
          const labelOffset = clamp(chart.width * 0.008, 3, 6)
          const labelRows = sankeyNodes.map((node): IncomeSankeyLabelRow => {
            const labelAnchor =
              node.data.labelSide === 'right' ? 'start' : 'end'
            const labelX =
              node.data.labelSide === 'right'
                ? node.x1 + labelOffset
                : node.x0 - labelOffset
            const labelText =
              chart.width < 720 && node.data.compactLabel
                ? node.data.compactLabel
                : node.data.label
            const backdrop = labelBackdropBounds({
              anchor: labelAnchor,
              centerY: node.y,
              fontSize: labelFontSize,
              label: labelText,
              labelX,
              value: node.data.displayValue,
            })
            return {
              ...node,
              labelText,
              labelX,
              labelNameY: node.y - labelFontSize * 0.5,
              labelValueY: node.y + labelFontSize * 0.58,
              labelAnchor,
              backdropX0: backdrop.x,
              backdropX1: backdrop.x + backdrop.width,
              backdropY0: backdrop.y,
              backdropY1: backdrop.y + backdrop.height,
            }
          })
          const backdropRows = labelRows.filter(
            (node) => node.data.labelBackdrop,
          )
          const titleRows: readonly IncomeSankeyTitleRow[] = [
            {
              kind: 'title',
              id: 'title',
              title: incomeStatementTitle,
              x: chart.x + chart.width / 2,
              y: chart.y + clamp(chart.height * 0.065, 17, 32),
            },
          ]

          return [
            ...flowMarks,
            rect(backdropRows, {
              id: 'label-backdrops',
              x1: 'backdropX0',
              x2: 'backdropX1',
              y1: 'backdropY0',
              y2: 'backdropY1',
              key: 'key',
              fill: 'var(--panel, #ffffff)',
              fillOpacity: 0.82,
              inset: 0,
              radius: 1,
            }),
            text(labelRows, {
              id: 'label-names',
              x: 'labelX',
              y: 'labelNameY',
              text: 'labelText',
              key: 'key',
              anchor: (node) => node.labelAnchor,
              fill: 'currentColor',
              fontSize: labelFontSize,
              fontWeight: 700,
            }),
            text(labelRows, {
              id: 'label-values',
              x: 'labelX',
              y: 'labelValueY',
              text: (node) => node.data.displayValue,
              key: 'key',
              anchor: (node) => node.labelAnchor,
              fill: 'currentColor',
              fontSize: labelFontSize,
              fontWeight: 500,
            }),
            text(titleRows, {
              id: 'title',
              x: 'x',
              y: 'y',
              text: 'title',
              key: 'id',
              fill: '#155477',
              fontSize: clamp(chart.width * 0.034, 14, 26),
              fontWeight: 750,
            }),
          ] as const
        },
      }),
    ],
    color: {
      domain: toneDomain,
      range: toneDomain.map((tone) => toneColors[tone]),
    },
    guides: false,
    margin: 0,
  })
}

function clamp(value: number, minimum: number, maximum: number) {
  return Math.min(maximum, Math.max(minimum, value))
}

export const mount = tanstackMount(sankeyDefinition, incomeStatementTitle, {
  format: ({ datum }) => {
    if (datum.kind === 'title') return datum.title
    if (datum.kind === 'node') {
      return `${datum.data.label} · ${datum.data.displayValue}`
    }
    return `${datum.sourceNode.data.label}${datum.targetNode.data.label} · ${datum.value}`
  },
})
shared/mount.ts179 lines · dependency
shared/mount.ts
import {
  defineChart,
  isResponsiveChartDefinition,
  mountChart,
} from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import type {
  DomChartDefinition,
  ChartDefinitionOptions,
  ChartValue,
  ChartTooltipOptions,
} from '@tanstack/charts'
import type {
  ConformanceHandle,
  ConformanceInput,
  ConformanceMount,
} from '../types'
import { catalogPreviewDefinition, type CatalogPreviewOptions } from './preview'

export function mountObservablePlot(
  container: HTMLElement,
  input: ConformanceInput,
  render: (input: ConformanceInput) => HTMLElement | SVGSVGElement,
): ConformanceHandle {
  let element = render(input)
  container.append(element)

  return {
    update(nextInput) {
      const nextElement = render(nextInput)
      element.replaceWith(nextElement)
      element = nextElement
    },
    destroy() {
      element.remove()
    },
  }
}

export function tanstackMount<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  const mount: ConformanceMount = (container, input) => {
    const options = {
      definition: withConformanceBehavior(
        createDefinition(input),
        input,
        interactiveTooltip,
        previewOptions,
      ),
      width: input.width,
      height: input.height,
      ariaLabel,
    } as const
    const host = mountChart(container, options)
    applyCatalogPreviewFocus(host, input, previewOptions)

    return {
      update(nextInput) {
        host.update({
          ...options,
          definition: withConformanceBehavior(
            createDefinition(nextInput),
            nextInput,
            interactiveTooltip,
            previewOptions,
          ),
          width: nextInput.width,
          height: nextInput.height,
        })
        applyCatalogPreviewFocus(host, nextInput, previewOptions)
      },
      destroy() {
        host.destroy()
      },
    }
  }

  const catalogCase = Object.assign(mount, {
    createDefinition,
    ariaLabel,
    interactiveTooltip,
  })

  return Object.assign(catalogCase, { mount: catalogCase })
}

export interface TanStackConformanceCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  (container: HTMLElement, input: ConformanceInput): ConformanceHandle
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>
  ariaLabel: string
  interactiveTooltip: true | ChartTooltipOptions<TDatum>
  mount: ConformanceMount
}

export function tanstackCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  return tanstackMount(
    createDefinition,
    ariaLabel,
    interactiveTooltip,
    previewOptions,
  )
}

export function withConformanceBehavior<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  input: ConformanceInput,
  interactiveTooltip: true | ChartTooltipOptions<TDatum>,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  const presentation =
    input.preview === true
      ? catalogPreviewDefinition(definition, previewOptions)
      : definition
  const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
    svgAnimation: false,
    ...(input.interactive === true ||
    (input.preview === true && previewOptions.focus)
      ? {}
      : { focus: false }),
    keyboard: input.interactive === true,
    tooltip:
      input.interactive !== true
        ? false
        : interactiveTooltip === true
          ? tooltip
          : { use: tooltip, ...interactiveTooltip },
  }

  if (isResponsiveChartDefinition(presentation)) {
    return defineChart(presentation, behavior)
  }
  return defineChart(presentation, behavior)
}

function applyCatalogPreviewFocus<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  host: ReturnType<typeof mountChart<TDatum, TXValue, TYValue>>,
  input: ConformanceInput,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue>,
) {
  if (input.preview !== true || !options.focus) return
  host.interaction.setControlledFocus(options.focus(host.getScene(), input), {
    source: 'programmatic',
  })
}
shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
  ChartPoint,
  ChartScene,
  ChartValue,
  DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'

export interface CatalogPreviewOptions<
  TDatum = unknown,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  /** Keep the source definition's Cartesian axes and grid. */
  guides?: boolean
  /** Keep the source definition's color legend. */
  legend?: boolean
  /** Keep the source definition's authored or automatic margins. */
  margin?: boolean
  /** Paint one deterministic source point through the chart's focus strategy. */
  focus?: (
    scene: ChartScene<TDatum, TXValue, TYValue>,
    input: ConformanceInput,
  ) => ChartPoint<TDatum, TXValue, TYValue> | null
}

export function catalogPreviewDefinition<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  if (isResponsiveChartDefinition(definition)) {
    return {
      ...definition,
      chart(context) {
        const spec = definition.chart(context)
        const color = previewColor(spec.color, options.legend === true)
        return {
          ...spec,
          ...(options.guides === true ? {} : { guides: false }),
          ...(options.margin === true ? {} : { margin: 0 }),
          ...(color ? { color } : {}),
        }
      },
    }
  }

  const color = previewColor(definition.color, options.legend === true)
  return {
    ...definition,
    ...(options.guides === true ? {} : { guides: false }),
    ...(options.margin === true ? {} : { margin: 0 }),
    ...(color ? { color } : {}),
  }
}

function previewColor<TColor extends { legend?: unknown }>(
  color: TColor | undefined,
  keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
  if (!color || keepLegend) return color
  const { legend: _legend, ...withoutLegend } = color
  return withoutLegend
}

export function samplePreviewData<TDatum>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limit: number,
  accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
  if (input.preview !== true || data.length <= limit) return data

  const selected = new Set<number>()
  const slots = Math.max(2, limit - accessors.length * 2)
  for (let slot = 0; slot < slots; slot += 1) {
    selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
  }

  for (const accessor of accessors) {
    let minimumIndex = -1
    let minimum = Number.POSITIVE_INFINITY
    let maximumIndex = -1
    let maximum = Number.NEGATIVE_INFINITY

    data.forEach((datum, index) => {
      const value = accessor(datum)
      if (value === null || value === undefined || !Number.isFinite(value)) {
        return
      }
      if (value < minimum) {
        minimum = value
        minimumIndex = index
      }
      if (value > maximum) {
        maximum = value
        maximumIndex = index
      }
    })

    if (minimumIndex >= 0) selected.add(minimumIndex)
    if (maximumIndex >= 0) selected.add(maximumIndex)
  }

  return data.filter((_datum, index) => selected.has(index))
}

export function samplePreviewSeries<TDatum, TSeries>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limitPerSeries: number,
  series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
  if (input.preview !== true) return data

  const indicesBySeries = new Map<TSeries, number[]>()
  data.forEach((datum, index) => {
    const key = series(datum)
    const indices = indicesBySeries.get(key) ?? []
    indices.push(index)
    indicesBySeries.set(key, indices)
  })

  const selected = new Set<number>()
  for (const indices of indicesBySeries.values()) {
    if (indices.length <= limitPerSeries) {
      indices.forEach((index) => selected.add(index))
      continue
    }
    for (let slot = 0; slot < limitPerSeries; slot += 1) {
      const index =
        indices[
          Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
        ]
      if (index !== undefined) selected.add(index)
    }
  }

  return data.filter((_datum, index) => selected.has(index))
}
types.ts376 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
  'observable-plot' | 'recharts' | 'echarts'

export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'

export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'

export type ConformanceGeometryRole =
  | 'arc'
  | 'area'
  | 'arrow'
  | 'bar'
  | 'cell'
  | 'contour'
  | 'delaunay'
  | 'density'
  | 'dot'
  | 'frame'
  | 'geo'
  | 'hexagon'
  | 'line'
  | 'link'
  | 'rect'
  | 'radar'
  | 'regression'
  | 'rule'
  | 'text'
  | 'tick'
  | 'vector'
  | 'voronoi'
  | 'waffle'

export interface ConformanceInput {
  width: number
  height: number
  revision: number
  interactive?: boolean
  /** Use lower-detail geometry suited to compact catalog cards. */
  preview?: boolean
  /** True only for semantic browser scenarios, not catalog or visual mounts. */
  behavior?: boolean
}

export interface ConformanceHandle {
  update: (input: ConformanceInput) => void
  driver?: ConformanceTestDriver
  destroy: () => void
}

export type ConformanceMount = (
  container: HTMLElement,
  input: ConformanceInput,
) => ConformanceHandle

export interface ConformanceGeometryExpectation {
  id?: string
  view?: string
  role: ConformanceGeometryRole
  count: number
  maxCount?: number
  rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}

export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'

export interface ConformanceGuideExpectation {
  id: string
  axis:
    | ConformanceAxis
    | (Record<'tanstack', ConformanceAxis> &
        Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
  sequence?: readonly string[]
  maxRepeat?: number
}

export type ConformanceJsonValue =
  | null
  | boolean
  | number
  | string
  | readonly ConformanceJsonValue[]
  | ConformanceJsonObject

export interface ConformanceJsonObject {
  readonly [key: string]: ConformanceJsonValue
}

export interface ConformanceTarget {
  view?: string
  anchor: string
}

export type ConformanceRenderedTarget =
  | {
      selector: string
      index?: number
      role?: never
      name?: never
      exact?: never
      root?: never
      page?: never
    }
  | {
      role: string
      name?: string
      exact?: boolean
      index?: number
      selector?: never
      root?: never
      page?: never
    }
  | {
      root: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      page?: never
    }
  | {
      page: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      root?: never
    }

export interface ConformanceResolvedTarget {
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  x: number
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  y: number
  /** Optional element to focus before a real Playwright keyboard action. */
  focusElement?: HTMLElement | SVGElement
}

export interface ConformanceGeometryQuery {
  view?: string
  role: ConformanceGeometryRole
}

export interface ConformanceGeometrySample {
  /** Viewport-relative client box, matching getBoundingClientRect coordinates. */
  x: number
  y: number
  width: number
  height: number
  paint?: string
}

export interface ConformanceTestDriver {
  /**
   * Benchmark-only semantic bridge. Case metadata names anchors; each renderer
   * resolves those anchors without exposing renderer-specific selectors.
   */
  resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
  readState: () => ConformanceJsonObject
  geometry?: (
    query: ConformanceGeometryQuery,
  ) => readonly ConformanceGeometrySample[]
  /**
   * Viewport-relative logical view bounds. Multi-grid renderers may expose
   * independent views without separate DOM roots.
   */
  viewBounds?: (view?: string) => ConformanceGeometrySample | null
  settle?: () => void | Promise<void>
}

export type ConformanceStateAssertion =
  | {
      path: string
      equals: ConformanceJsonValue
    }
  | {
      path: string
      includes: ConformanceJsonValue
    }
  | {
      path: string
      approx: number
      tolerance: number
    }

type ConformanceRenderedStringMatcher =
  | {
      equals: string | null
      includes?: never
    }
  | {
      includes: string
      equals?: never
    }

type ConformanceRenderedNumberMatcher =
  | {
      equals: number
      approx?: never
      tolerance?: never
      atLeast?: never
      atMost?: never
    }
  | {
      approx: number
      tolerance: number
      equals?: never
      atLeast?: never
      atMost?: never
    }
  | {
      atLeast: number
      equals?: never
      approx?: never
      tolerance?: never
      atMost?: never
    }
  | {
      atMost: number
      equals?: never
      approx?: never
      tolerance?: never
      atLeast?: never
    }

export type ConformanceRenderedAssertion =
  | ({
      target: ConformanceRenderedTarget
      property: 'count'
    } & ConformanceRenderedNumberMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'text'
    } & ConformanceRenderedStringMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'attribute'
      attribute: string
    } & ConformanceRenderedStringMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'visible' | 'focused'
      equals: boolean
    }
  | ({
      target: ConformanceRenderedTarget
      property:
        | 'scrollLeft'
        | 'scrollTop'
        | 'scrollWidth'
        | 'scrollHeight'
        | 'clientWidth'
        | 'clientHeight'
        | 'width'
        | 'height'
    } & ConformanceRenderedNumberMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'contained'
      within?: ConformanceRenderedTarget
      tolerance?: number
      equals: true
    }

export type ConformanceInteractionStep =
  | {
      type: 'pointerMove'
      target: ConformanceTarget
      steps?: number
    }
  | {
      type: 'pointerDown'
      target: ConformanceTarget
    }
  | {
      type: 'pointerUp'
      target: ConformanceTarget
    }
  | {
      type: 'pointerCancel'
    }
  | {
      type: 'pointerLeave'
      view?: string
    }
  | {
      type: 'update'
      revision: number
    }
  | {
      type: 'click'
      target: ConformanceTarget
    }
  | {
      type: 'key'
      key: string
      target?: ConformanceTarget
    }
  | {
      type: 'drag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
    }
  | {
      type: 'wheel'
      target: ConformanceTarget
      deltaX?: number
      deltaY?: number
      steps?: number
      deltaMode?: 'pixel' | 'line' | 'page'
    }
  | {
      type: 'touchTap'
      target: ConformanceTarget
    }
  | {
      type: 'touchDrag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
      cancel?: boolean
    }
  | {
      type: 'wait'
      durationMs: number
    }
  | {
      type: 'assert'
      assertions: readonly ConformanceStateAssertion[]
    }
  | {
      type: 'assertRendered'
      assertions: readonly ConformanceRenderedAssertion[]
    }
  | {
      type: 'screenshot'
      name: string
      view?: string
    }

export interface ConformanceInteractionScenario {
  id: string
  steps: readonly ConformanceInteractionStep[]
}

export interface ConformanceCaseMeta {
  schemaVersion: 1
  referenceRenderer?: ConformanceReferenceRenderer
  order: number
  id: string
  title: string
  family: string
  intent: string
  support: ConformanceSupport
  features: readonly string[]
  geometry: readonly ConformanceGeometryExpectation[]
  minimumGeometrySimilarity?: number
  guideAssertions?: readonly ConformanceGuideExpectation[]
  interactionScenarios?: readonly ConformanceInteractionScenario[]
  source: {
    title: string
    url: string
  }
  ai: {
    create: string
    maintain: string
  }
}

export interface ConformanceImplementationModule {
  mount: ConformanceMount
  /** Definition-only mount used by compact generated catalog previews. */
  catalogCase?: { mount: ConformanceMount }
}