1,336 lines · 7 files · 35.9 kB
cases/89-brush-range-selection/model.ts89 lines · dependency
cases/89-brush-range-selection/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'
export interface BrushRange {
start: Date
end: Date
}
export function monthlyAaplRows(rows: readonly AaplRow[]): readonly AaplRow[] {
const byMonth = new Map<number, AaplRow>()
for (const row of rows) {
if (row.Date.getUTCFullYear() === 2017) {
byMonth.set(row.Date.getUTCMonth(), row)
}
}
return [...byMonth.values()]
}
export function observedBrushDates(rows: readonly AaplRow[]) {
return rows.map((row) => row.Date)
}
export function brushDomain(dates: readonly Date[]): readonly [Date, Date] {
const first = dates[0]
const last = dates.at(-1)
if (!first || !last) throw new Error('Brush selection requires dates.')
return [first, last]
}
export function initialBrushRange(dates: readonly Date[]): BrushRange {
const start = dates[3]
const end = dates[5]
if (!start || !end) throw new Error('Brush selection requires six dates.')
return { start, end }
}
export function brushDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function brushShortDate(date: Date) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}
export function brushDateFromAnchor(dates: readonly Date[], anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
return dates.find((date) => brushDateKey(date) === key) ?? null
}
export function clampBrushDate(dates: readonly Date[], date: Date) {
const first = dates[0]
if (!first) throw new Error('Brush selection requires dates.')
return dates.reduce((nearest, candidate) =>
Math.abs(candidate.getTime() - date.getTime()) <
Math.abs(nearest.getTime() - date.getTime())
? candidate
: nearest,
)
}
export function normalizedBrushRange(a: Date, b: Date): BrushRange {
return a.getTime() <= b.getTime()
? { start: a, end: b }
: { start: b, end: a }
}
export function brushRowsInRange(rows: readonly AaplRow[], range: BrushRange) {
const start = range.start.getTime()
const end = range.end.getTime()
return rows.filter((row) => {
const timestamp = row.Date.getTime()
return timestamp >= start && timestamp <= end
})
}
export function brushRangeSummary(rows: readonly AaplRow[], range: BrushRange) {
const selected = brushRowsInRange(rows, range)
const total = selected.reduce((sum, row) => sum + row.Close, 0)
const first = selected[0]
const last = selected.at(-1)
return {
count: selected.length,
average: selected.length ? total / selected.length : 0,
change: first && last ? last.Close - first.Close : 0,
}
}cases/89-brush-range-selection/paint.ts92 lines · dependency
cases/89-brush-range-selection/paint.ts
export const brushSelectionFill = 'rgba(147, 197, 253, 0.4)'
export function normalizedElementFill(element: Element) {
const view = element.ownerDocument.defaultView
if (!view) return null
const style = view.getComputedStyle(element)
return normalizedRenderedFill(style.fill, style.fillOpacity, style.opacity)
}
export function normalizedRenderedFill(
fill: string,
fillOpacity: string | number | undefined,
opacity: string | number | undefined,
) {
const color = parseColor(fill)
if (!color) return null
const alpha =
color.alpha * numericOpacity(fillOpacity) * numericOpacity(opacity)
return `rgba(${color.red}, ${color.green}, ${color.blue}, ${formatAlpha(alpha)})`
}
interface ParsedColor {
red: number
green: number
blue: number
alpha: number
}
function parseColor(value: string): ParsedColor | null {
const normalized = value.trim().toLowerCase()
if (normalized === 'transparent') {
return { red: 0, green: 0, blue: 0, alpha: 0 }
}
const hex = /^#([\da-f]{6})([\da-f]{2})?$/.exec(normalized)
if (hex) {
const channels = hex[1]
if (!channels) return null
return {
red: Number.parseInt(channels.slice(0, 2), 16),
green: Number.parseInt(channels.slice(2, 4), 16),
blue: Number.parseInt(channels.slice(4, 6), 16),
alpha: hex[2] ? Number.parseInt(hex[2], 16) / 255 : 1,
}
}
const functional = /^rgba?\((.*)\)$/.exec(normalized)
if (!functional?.[1]) return null
const [channelText, slashAlpha] = functional[1]
.split('/')
.map((part) => part.trim())
if (!channelText) return null
const commaParts = channelText.split(',').map((part) => part.trim())
const parts =
commaParts.length > 1
? commaParts
: channelText.split(/\s+/).filter(Boolean)
if (parts.length < 3) return null
const red = colorChannel(parts[0])
const green = colorChannel(parts[1])
const blue = colorChannel(parts[2])
const inlineAlpha = commaParts.length > 3 ? commaParts[3] : undefined
const alpha = alphaChannel(slashAlpha ?? inlineAlpha ?? '1')
if (red === null || green === null || blue === null || alpha === null) {
return null
}
return { red, green, blue, alpha }
}
function colorChannel(value: string | undefined) {
if (!value) return null
const numeric = Number.parseFloat(value)
if (!Number.isFinite(numeric)) return null
return Math.round(
Math.min(255, Math.max(0, value.endsWith('%') ? numeric * 2.55 : numeric)),
)
}
function alphaChannel(value: string) {
const numeric = Number.parseFloat(value)
if (!Number.isFinite(numeric)) return null
return Math.min(1, Math.max(0, value.endsWith('%') ? numeric / 100 : numeric))
}
function numericOpacity(value: string | number | undefined) {
const numeric = Number(value ?? 1)
return Number.isFinite(numeric) ? numeric : 1
}
function formatAlpha(value: number) {
return String(Math.round(Math.min(1, Math.max(0, value)) * 10_000) / 10_000)
}cases/89-brush-range-selection/tanstack.ts386 lines · entry
cases/89-brush-range-selection/tanstack.ts
import { aapl } from '@charts-poc/demo-data/aapl'
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
import { brushX } from '@tanstack/charts/interaction/brush'
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 { tanstackCase } from '../../shared/mount'
import {
brushDateFromAnchor,
brushDateKey,
brushDomain,
brushRangeSummary,
brushShortDate,
initialBrushRange,
monthlyAaplRows,
observedBrushDates,
} from './model'
import { brushSelectionFill, normalizedElementFill } from './paint'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type {
BrushRange,
BrushXChange,
} from '@tanstack/charts/interaction/brush'
import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceJsonObject,
ConformanceMount,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
interface BrushState {
range: BrushRange<Date>
dragging: boolean
}
const color = '#2563eb'
const brushRows = monthlyAaplRows(aapl)
const brushDates = observedBrushDates(brushRows)
const fullDomain = brushDomain(brushDates)
const brushMonthFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
timeZone: 'UTC',
})
export function brushRangeDefinition(
range: BrushRange<Date>,
onChange: (range: BrushRange<Date>, reason: BrushXChange<Date>) => void,
) {
return defineChart({
marks: [
decorative(
lineY(brushRows, {
id: 'brush-series-line',
x: 'Date',
y: 'Close',
stroke: color,
strokeWidth: 2.5,
}),
),
dot(brushRows, {
id: 'brush-series-points',
x: 'Date',
y: 'Close',
fill: color,
r: 3.5,
stroke: '#ffffff',
strokeWidth: 1,
}),
],
x: {
scale: scaleUtc().domain(fullDomain),
axis: {
ticks: { format: (value) => brushMonthFormatter.format(value) },
label: 'Month',
},
},
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 4 }, label: 'AAPL close ($)' },
},
controls: [
brushX({
id: 'monthly-range',
range: controlledSignal<BrushRange<Date>, BrushXChange<Date>>(
range,
(next, { reason }) => onChange(next, reason),
),
values: brushDates,
ariaLabel:
'Monthly range brush. Drag to select; focus either handle and use arrow keys, Home, or End to adjust.',
startAriaLabel: 'Range start',
endAriaLabel: 'Range end',
format: brushDateKey,
handleSize: 16,
selectionStyle: {
fill: brushSelectionFill,
fillOpacity: 1,
stroke: color,
strokeWidth: 1,
},
handleStyle: {
fill: 'Canvas',
fillOpacity: 1,
stroke: color,
strokeWidth: 2,
},
}),
],
svgAnimation: false,
keyboard: false,
focusRing: false,
margin: { top: 52, right: 24, bottom: 44, left: 58 },
})
}
export const catalogCase = tanstackCase(
() => brushRangeDefinition(initialBrushRange(brushDates), () => {}),
'Time series with a draggable horizontal range brush',
)
export const mount: ConformanceMount = (container, input) => {
let currentInput = input
let accepted = copyRange(initialBrushRange(brushDates))
let state: BrushState = { range: copyRange(accepted), dragging: false }
let host: ChartHost<AaplRow, Date, number> | undefined
const shell = container.ownerDocument.createElement('div')
const chartFrame = container.ownerDocument.createElement('div')
const status = createRangeStatus(container.ownerDocument)
shell.dataset.conformanceView = 'main'
shell.setAttribute('role', 'application')
shell.setAttribute(
'aria-label',
'Monthly time range brush with two adjustable handles',
)
shell.style.position = 'relative'
chartFrame.style.position = 'relative'
shell.append(chartFrame, status)
container.append(shell)
sizeShell(shell, chartFrame, input)
const handleBrushChange = (
next: BrushRange<Date>,
reason: BrushXChange<Date>,
) => {
state = {
range: copyRange(next),
dragging: reason.type === 'preview',
}
updateRangeStatus(status, state.range)
if (reason.type === 'preview') return
accepted = copyRange(next)
host?.update(options())
}
const options = (): ChartHostOptions<AaplRow, Date, number> => ({
definition: brushRangeDefinition(accepted, handleBrushChange),
width: currentInput.width,
height: currentInput.height,
ariaLabel: 'Time series with a draggable horizontal range brush',
})
host = mountChart(chartFrame, options())
updateRangeStatus(status, state.range)
const driver = createDriver(
shell,
chartFrame,
() => host!.getScene(),
() => state,
)
return {
driver,
update(nextInput) {
currentInput = nextInput
sizeShell(shell, chartFrame, nextInput)
host!.update(options())
updateRangeStatus(status, state.range)
},
destroy() {
host!.destroy()
shell.remove()
},
}
}
function createDriver(
shell: HTMLElement,
surface: HTMLElement,
getScene: () => ChartScene<AaplRow, Date, number>,
getState: () => BrushState,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(surface, getScene(), target)
},
readState() {
return interactionState(getState())
},
geometry(query) {
return brushGeometry(surface, getScene(), getState().range, query)
},
viewBounds(view) {
if (view && view !== 'main') return null
const bounds = shell.getBoundingClientRect()
return {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
}
},
}
}
function resolveTarget(
surface: HTMLElement,
scene: ChartScene<AaplRow, Date, number>,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') return null
if (target.anchor === 'handle:start' || target.anchor === 'handle:end') {
const handle = surface.querySelector<SVGRectElement>(
`[data-chart-brush-handle="${target.anchor === 'handle:start' ? 'start' : 'end'}"]`,
)
return handle ? center(handle) : null
}
const date = brushDateFromAnchor(brushDates, target.anchor)
const row = date
? brushRows.find((datum) => datum.Date.getTime() === date.getTime())
: null
return date && row
? scenePointToClient(
surface,
scene,
scene.scales.x.map(date),
scene.scales.y.map(row.Close),
)
: null
}
function interactionState(state: BrushState): ConformanceJsonObject {
const summary = brushRangeSummary(brushRows, state.range)
return {
selection: {
start: brushDateKey(state.range.start),
end: brushDateKey(state.range.end),
pointCount: summary.count,
closeAverage: summary.average,
closeChange: summary.change,
dragging: state.dragging,
},
}
}
function brushGeometry(
surface: HTMLElement,
scene: ChartScene<AaplRow, Date, number>,
range: BrushRange<Date>,
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 = brushRows.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: color,
}))
}
if (query.role === 'line') {
const sample = clientPointBounds(points, bounds, {
scaleX,
scaleY,
paint: color,
})
return sample ? [sample] : []
}
if (query.role !== 'rect') return []
const band = surface.querySelector<SVGRectElement>(
'[data-chart-brush-selection]',
)
if (band) {
const bandBounds = band.getBoundingClientRect()
return [
{
x: bandBounds.x,
y: bandBounds.y,
width: bandBounds.width,
height: bandBounds.height,
paint:
normalizedElementFill(band) ?? 'invalid-tanstack-rendered-brush-fill',
},
]
}
const start = scene.scales.x.map(range.start)
const end = scene.scales.x.map(range.end)
return [
{
x: bounds.left + Math.min(start, end) * scaleX,
y: bounds.top + scene.chart.y * scaleY,
width: Math.max(1, Math.abs(end - start) * scaleX),
height: scene.chart.height * scaleY,
paint: 'missing-tanstack-rendered-brush-fill',
},
]
}
function createRangeStatus(document: Document) {
const status = document.createElement('output')
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
Object.assign(status.style, {
position: 'absolute',
right: '24px',
top: '10px',
zIndex: '4',
padding: '4px 8px',
border: '1px solid color-mix(in srgb, CanvasText 24%, transparent)',
borderRadius: '999px',
background: 'Canvas',
color: 'CanvasText',
font: '600 12px/1.2 system-ui, sans-serif',
pointerEvents: 'none',
})
return status
}
function updateRangeStatus(status: HTMLOutputElement, range: BrushRange<Date>) {
const summary = brushRangeSummary(brushRows, range)
const label = `${brushShortDate(range.start)} → ${brushShortDate(range.end)} · ${summary.count} AAPL closes · avg $${summary.average.toFixed(1)}`
status.value = label
status.textContent = label
status.setAttribute(
'aria-label',
`${brushDateKey(range.start)} through ${brushDateKey(range.end)}, ${summary.count} AAPL closing prices, average $${summary.average.toFixed(1)}`,
)
}
function copyRange(range: BrushRange<Date>): BrushRange<Date> {
return {
start: new Date(range.start.getTime()),
end: new Date(range.end.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 sizeShell(
shell: HTMLDivElement,
chartFrame: HTMLDivElement,
input: ConformanceInput,
) {
shell.style.width = `${input.width}px`
shell.style.height = `${input.height}px`
chartFrame.style.width = `${input.width}px`
chartFrame.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 }
}