1,318 lines · 6 files · 35.0 kB
cases/91-timeline-playback-scrubber/model.ts36 lines · dependency
cases/91-timeline-playback-scrubber/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'
export const initialPlaybackIndex = 2
export function selectPlaybackRows(
rows: readonly AaplRow[],
): readonly AaplRow[] {
const start = Date.UTC(2018, 0, 2)
const end = Date.UTC(2018, 0, 11)
return rows.filter((row) => {
const timestamp = row.Date.getTime()
return timestamp >= start && timestamp <= end
})
}
export function playbackDomain(
rows: readonly AaplRow[],
): readonly [Date, Date] {
const first = rows[0]
const last = rows.at(-1)
if (!first || !last) throw new Error('Playback requires observed AAPL rows.')
return [first.Date, last.Date]
}
export function playbackDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function playbackIndexFromAnchor(
rows: readonly AaplRow[],
anchor: string,
) {
const key = anchor.startsWith('frame:') ? anchor.slice(6) : ''
const index = rows.findIndex((row) => playbackDateKey(row.Date) === key)
return index < 0 ? null : index
}cases/91-timeline-playback-scrubber/tanstack.ts513 lines · entry
cases/91-timeline-playback-scrubber/tanstack.ts
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
import { aapl } from '@charts-poc/demo-data/aapl'
import { handleX } from '@tanstack/charts/interaction/handle'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
clientPointBounds,
scenePointToClient,
} from '../../shared/driver-geometry'
import {
initialPlaybackIndex,
playbackDateKey,
playbackIndexFromAnchor,
selectPlaybackRows,
} from './model'
import { tanstackCase } from '../../shared/mount'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceJsonObject,
ConformanceMount,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
interface PlaybackState {
frame: Date
dragging: boolean
scrubCount: number
playing: boolean
}
const linePaint = '#2563eb'
const playheadPaint = '#f97316'
const margin = { top: 64, right: 24, bottom: 68, left: 56 }
const playbackRows = selectPlaybackRows(aapl)
const playbackDates = playbackRows.map((row) => row.Date)
const initialFrame = playbackRows[initialPlaybackIndex]?.Date
if (!initialFrame) throw new Error('Playback requires an initial frame.')
export function playbackDefinition(
frame: Date,
onChange: (value: Date, reason: HandleXChange<Date>) => void,
preview = false,
) {
return defineChart({
marks: [
decorative(
lineY(playbackRows, {
id: 'playback-line',
x: 'Date',
y: 'Close',
stroke: linePaint,
strokeWidth: 2.5,
}),
),
dot(playbackRows, {
id: 'playback-points',
x: 'Date',
y: 'Close',
fill: linePaint,
r: 3.5,
stroke: '#ffffff',
strokeWidth: 1,
}),
],
x: {
scale: scaleUtc,
axis: {
ticks: {
format: (value) =>
value.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}),
},
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
},
controls: [
handleX({
id: 'playback-frame',
value: controlledSignal<Date, HandleXChange<Date>>(
frame,
(next, { reason }) => onChange(next, reason),
),
values: playbackDates,
cross: { edge: 'bottom', offset: preview ? -18 : 34 },
trackStyle: {
fill: 'color-mix(in srgb, currentColor 52%, transparent)',
},
ruleStyle: { fill: playheadPaint },
handleStyle: {
fill: playheadPaint,
stroke: 'Canvas',
strokeWidth: 2,
},
hitSize: 44,
ariaLabel: 'Timeline frame',
format: (value) => playbackValueText(rowForDate(value)),
}),
],
svgAnimation: false,
keyboard: false,
focusRing: false,
margin: preview ? 0 : margin,
})
}
export const catalogCase = tanstackCase(
() => playbackDefinition(initialFrame, () => {}, true),
'AAPL closes with a draggable timeline playback scrubber',
)
export const mount: ConformanceMount = (container, input) => {
let currentInput = input
let accepted = cloneDate(initialFrame)
let state: PlaybackState = {
frame: cloneDate(accepted),
dragging: false,
scrubCount: 0,
playing: false,
}
let playbackTimer: ReturnType<typeof setInterval> | undefined
let host: ChartHost<AaplRow, Date, number> | undefined
const document = container.ownerDocument
const view = document.createElement('div')
const chartSurface = document.createElement('div')
const controls = createPlaybackControls(document)
view.dataset.conformanceView = 'main'
view.style.position = 'relative'
view.style.touchAction = 'pan-y'
view.append(chartSurface, controls.toolbar, controls.status)
container.append(view)
sizeView(view, input)
const frameText = () => playbackValueText(rowForDate(state.frame))
const paint = () => controls.paint(frameText(), state.playing)
const stopPlayback = (message?: string) => {
if (playbackTimer !== undefined) clearInterval(playbackTimer)
playbackTimer = undefined
state = { ...state, playing: false }
paint()
if (message) controls.announce(`${message}. ${frameText()}`)
}
const stopForScrub = () => {
if (state.playing) stopPlayback()
}
const handleFrameChange = (next: Date, reason: HandleXChange<Date>) => {
stopForScrub()
if (reason.type === 'preview') {
state = { ...state, frame: cloneDate(next), dragging: true }
paint()
return
}
if (reason.type === 'cancel') {
state = {
...state,
frame: cloneDate(reason.origin),
dragging: false,
}
paint()
controls.announce(`Scrub canceled. ${frameText()}`)
return
}
accepted = cloneDate(next)
state = {
...state,
frame: cloneDate(next),
dragging: false,
scrubCount: state.scrubCount + 1,
}
host?.update(options())
paint()
controls.announce(`Frame selected. ${frameText()}`)
}
const options = (): ChartHostOptions<AaplRow, Date, number> => ({
definition: playbackDefinition(accepted, handleFrameChange),
width: currentInput.width,
height: currentInput.height,
ariaLabel: 'AAPL closes with a draggable timeline playback scrubber',
})
const applyFrame = (next: Date) => {
accepted = cloneDate(next)
state = { ...state, frame: cloneDate(next) }
host!.update(options())
paint()
}
const togglePlayback = () => {
if (state.playing) {
stopPlayback('Playback paused')
return
}
const lastIndex = playbackRows.length - 1
const restarting = indexForDate(state.frame) >= lastIndex
if (restarting) applyFrame(playbackRows[0]!.Date)
state = { ...state, playing: true, dragging: false }
playbackTimer = setInterval(() => {
const index = indexForDate(state.frame)
if (index >= playbackRows.length - 1) {
stopPlayback('Playback ended')
return
}
applyFrame(playbackRows[index + 1]!.Date)
}, 700)
paint()
controls.announce(
`${restarting ? 'Playback restarted' : 'Playback started'}. ${frameText()}`,
)
}
controls.playButton.addEventListener('click', togglePlayback)
host = mountChart(chartSurface, options())
paint()
const driver = createDriver(
view,
chartSurface,
controls.playButton,
() => host!.getScene(),
() => state,
)
return {
driver,
update(nextInput) {
currentInput = nextInput
sizeView(view, nextInput)
host!.update(options())
paint()
},
destroy() {
if (playbackTimer !== undefined) clearInterval(playbackTimer)
controls.playButton.removeEventListener('click', togglePlayback)
host!.destroy()
view.remove()
},
}
}
function createDriver(
view: HTMLElement,
surface: HTMLElement,
playButton: HTMLButtonElement,
getScene: () => ChartScene<AaplRow, Date, number>,
getState: () => PlaybackState,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(surface, playButton, getScene(), target)
},
readState() {
return interactionState(getState())
},
geometry(query) {
return playbackGeometry(surface, getScene(), query)
},
viewBounds(viewName) {
if (viewName !== undefined && viewName !== 'main') return null
const bounds = view.getBoundingClientRect()
return {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
}
},
}
}
function resolveTarget(
surface: HTMLElement,
playButton: HTMLButtonElement,
scene: ChartScene<AaplRow, Date, number>,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') return null
if (target.anchor === 'control:play') return center(playButton)
const index = playbackIndexFromAnchor(playbackRows, target.anchor)
const row = index === null ? undefined : playbackRows[index]
if (!row) return null
const point = scenePointToClient(
surface,
scene,
scene.scales.x.map(row.Date),
scene.chart.y + scene.chart.height + 34,
)
if (!point) return null
return {
...point,
focusElement:
surface.querySelector<SVGElement>('[data-chart-handle-surface]') ??
point.focusElement,
}
}
function interactionState(state: PlaybackState): ConformanceJsonObject {
const index = indexForDate(state.frame)
const row = playbackRows[index]
return {
playhead: {
index,
date: row ? playbackDateKey(row.Date) : null,
value: row?.Close ?? null,
progress: playbackRows.length > 1 ? index / (playbackRows.length - 1) : 0,
},
frames: {
count: playbackRows.length,
ids: playbackRows.map((datum) => playbackDateKey(datum.Date)),
jan5Close: playbackRows[3]?.Close ?? null,
},
interaction: {
dragging: state.dragging,
scrubCount: state.scrubCount,
playing: state.playing,
},
}
}
function playbackGeometry(
surface: HTMLElement,
scene: ChartScene<AaplRow, Date, number>,
query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
if (query.view !== undefined && query.view !== 'main') return []
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!svg) return []
const bounds = svg.getBoundingClientRect()
const scaleX = bounds.width / scene.width
const scaleY = bounds.height / scene.height
const points = playbackRows.map(
(row) =>
[scene.scales.x.map(row.Date), scene.scales.y.map(row.Close)] as const,
)
if (query.role === 'dot') {
return points.map(([x, y]) => ({
x: bounds.left + (x - 3.5) * scaleX,
y: bounds.top + (y - 3.5) * scaleY,
width: 7 * scaleX,
height: 7 * scaleY,
paint: linePaint,
}))
}
if (query.role === 'line') {
const sample = clientPointBounds(points, bounds, {
scaleX,
scaleY,
paint: linePaint,
})
return sample ? [sample] : []
}
if (query.role !== 'rule') return []
return ['track', 'rule'].flatMap((part) => {
const element = surface.querySelector<SVGElement>(
`[data-chart-handle-${part}]`,
)
return element ? [elementGeometry(element)] : []
})
}
function createPlaybackControls(document: Document) {
const toolbar = document.createElement('div')
toolbar.className = 'ts-conformance-playback-toolbar'
toolbar.setAttribute('role', 'group')
toolbar.setAttribute('aria-label', 'Timeline playback controls')
Object.assign(toolbar.style, {
position: 'absolute',
top: '4px',
left: '56px',
right: '20px',
zIndex: '3',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '8px',
pointerEvents: 'none',
})
const current = document.createElement('div')
current.className = 'ts-conformance-playback-current'
Object.assign(current.style, {
boxSizing: 'border-box',
minWidth: '0',
minHeight: '32px',
padding: '7px 9px',
border: '1px solid color-mix(in srgb, currentColor 32%, transparent)',
borderRadius: '999px',
overflow: 'hidden',
background: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
color: 'inherit',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
font: '600 12px/1.2 system-ui, sans-serif',
})
const playButton = document.createElement('button')
playButton.className = 'ts-conformance-playback-button'
playButton.type = 'button'
Object.assign(playButton.style, {
flex: '0 0 auto',
width: '44px',
height: '44px',
border: '1px solid color-mix(in srgb, currentColor 32%, transparent)',
borderRadius: '10px',
background: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
color: 'inherit',
cursor: 'pointer',
font: '700 16px/1 system-ui, sans-serif',
pointerEvents: 'auto',
})
const status = document.createElement('output')
status.className = 'ts-conformance-playback-announcement'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
status.setAttribute('aria-atomic', 'true')
Object.assign(status.style, {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
})
toolbar.append(current, playButton)
return {
toolbar,
status,
playButton,
paint(valueText: string, playing: boolean) {
current.textContent = valueText
playButton.textContent = playing ? '❚❚' : '▶'
playButton.setAttribute('aria-pressed', String(playing))
playButton.setAttribute(
'aria-label',
playing ? 'Pause timeline' : 'Play timeline',
)
playButton.title = playing ? 'Pause timeline' : 'Play timeline'
},
announce(message: string) {
status.value = message
status.textContent = message
},
}
}
function elementGeometry(element: SVGElement): ConformanceGeometrySample {
const bounds = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
paint: style.fill || style.stroke,
}
}
function rowForDate(date: Date) {
const row = playbackRows.find(
(candidate) => candidate.Date.getTime() === date.getTime(),
)
if (!row) throw new Error('Playback frame must be an observed date.')
return row
}
function indexForDate(date: Date) {
const index = playbackRows.findIndex(
(row) => row.Date.getTime() === date.getTime(),
)
if (index < 0) throw new Error('Playback frame must be an observed date.')
return index
}
function playbackValueText(row: AaplRow) {
return `${playbackDateKey(row.Date)} · AAPL close $${row.Close.toFixed(2)}`
}
function cloneDate(date: Date) {
return new Date(date.getTime())
}
function center(element: HTMLElement | SVGElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: element,
}
}
function sizeView(view: HTMLDivElement, input: ConformanceInput) {
view.style.width = `${input.width}px`
view.style.height = `${input.height}px`
}shared/driver-geometry.ts70 lines · dependency
shared/driver-geometry.ts
import type {
ConformanceGeometrySample,
ConformanceResolvedTarget,
} from '../types'
export interface ClientPointBoundsOptions {
paint: string
scaleX?: number
scaleY?: number
}
/**
* Bounds local chart points in viewport-relative client coordinates.
* Degenerate point clouds retain a one-pixel geometry sample for comparison.
*/
export function clientPointBounds(
points: readonly (readonly [number, number])[],
origin: Pick<DOMRectReadOnly, 'left' | 'top'>,
options: ClientPointBoundsOptions,
): ConformanceGeometrySample | null {
if (!points.length) return null
let left = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const [x, y] of points) {
left = Math.min(left, x)
right = Math.max(right, x)
top = Math.min(top, y)
bottom = Math.max(bottom, y)
}
const scaleX = options.scaleX ?? 1
const scaleY = options.scaleY ?? 1
return {
x: origin.left + left * scaleX,
y: origin.top + top * scaleY,
width: Math.max(1, (right - left) * scaleX),
height: Math.max(1, (bottom - top) * scaleY),
paint: options.paint,
}
}
/** Maps one outer-scene coordinate through the mounted SVG viewport. */
export function scenePointToClient(
surface: ParentNode,
scene: { readonly width: number; readonly height: number },
x: number,
y: number,
): ConformanceResolvedTarget | null {
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (
!svg ||
!Number.isFinite(scene.width) ||
!Number.isFinite(scene.height) ||
scene.width <= 0 ||
scene.height <= 0 ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return null
}
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (x / scene.width) * bounds.width,
y: bounds.top + (y / scene.height) * bounds.height,
focusElement: svg,
}
}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 }
}