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