1,434 lines · 8 files · 37.9 kB
cases/85-scrollable-resource-lanes/colors.ts7 lines · dependency
cases/85-scrollable-resource-lanes/colors.ts
import type { TimelineStatus } from './scenario'
export const timelineStatusColors: Readonly<Record<TimelineStatus, string>> = {
planned: '#64748b',
active: '#2563eb',
review: '#c2410c',
}cases/85-scrollable-resource-lanes/model.ts3 lines · dependency
cases/85-scrollable-resource-lanes/model.ts
export function timelineDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}cases/85-scrollable-resource-lanes/scenario.ts92 lines · dependency
cases/85-scrollable-resource-lanes/scenario.ts
export const resourceLanes = [
'Design',
'Infrastructure',
'API',
'Quality',
'Docs',
] as const
export type ResourceLane = (typeof resourceLanes)[number]
export const timelineStatuses = ['planned', 'active', 'review'] as const
export type TimelineStatus = (typeof timelineStatuses)[number]
export interface ResourceTask {
id: string
resource: ResourceLane
label: string
start: Date
end: Date
status: TimelineStatus
}
const day = 86_400_000
export const resourceTimelineDomain: readonly [Date, Date] = [
utcDay(1),
utcDay(74),
]
const initialTasks: readonly ResourceTask[] = [
task('design-plan', 'Design', 'Experience plan', 2, 11, 'planned'),
task('design-system', 'Design', 'Interface system', 34, 49, 'active'),
task(
'infra-foundation',
'Infrastructure',
'Runtime foundation',
4,
16,
'active',
),
task(
'infra-hardening',
'Infrastructure',
'Runtime hardening',
55,
68,
'review',
),
task('api-contract', 'API', 'Contract review', 1, 9, 'review'),
task('api-build', 'API', 'Endpoint build', 27, 44, 'active'),
task('quality-fixtures', 'Quality', 'Fixture suite', 7, 18, 'planned'),
task('quality-release', 'Quality', 'Release checks', 46, 59, 'review'),
task('docs-outline', 'Docs', 'Guide outline', 3, 14, 'planned'),
task('docs-publish', 'Docs', 'Publish guides', 60, 72, 'active'),
]
export function resourceTasks(revision = 0): readonly ResourceTask[] {
if (revision % 2 === 0) return initialTasks
return initialTasks.map((row) => {
if (row.id === 'api-build') {
return { ...row, end: new Date(row.end.getTime() + day * 3) }
}
if (row.id === 'quality-release') {
return { ...row, start: new Date(row.start.getTime() - day * 2) }
}
return row
})
}
function task(
id: string,
resource: ResourceLane,
label: string,
startDay: number,
endDay: number,
status: TimelineStatus,
): ResourceTask {
return {
id,
resource,
label,
start: utcDay(startDay),
end: utcDay(endDay),
status,
}
}
function utcDay(dayOfYear: number) {
return new Date(Date.UTC(2025, 0, dayOfYear))
}cases/85-scrollable-resource-lanes/shell.ts277 lines · dependency
cases/85-scrollable-resource-lanes/shell.ts
import { timelineStatusColors } from './colors'
import { resourceLanes, timelineStatuses } from './scenario'
import type { ConformanceInput } from '../../types'
import type { ResourceLane, ResourceTask } from './scenario'
export const timelineMargin = {
top: 18,
right: 24,
bottom: 50,
left: 12,
} as const
const headerHeight = 42
const focusScrollPadding = 32
export interface ResourceTimelineShell {
root: HTMLDivElement
viewport: HTMLDivElement
chartSurface: HTMLDivElement
laneRail: HTMLDivElement
schedule: HTMLUListElement
taskDetails: HTMLOutputElement
}
export function createResourceTimelineShell(
document: Document,
input: ConformanceInput,
rows: readonly ResourceTask[],
): ResourceTimelineShell {
const root = document.createElement('div')
root.style.display = 'grid'
root.style.gridTemplateRows = `${headerHeight}px minmax(0, 1fr)`
root.style.position = 'relative'
const { header, taskDetails } = createTimelineHeader(document)
const body = document.createElement('div')
body.style.display = 'grid'
body.style.minHeight = '0'
const laneRail = document.createElement('div')
laneRail.dataset.conformanceLaneRail = ''
laneRail.setAttribute('aria-label', 'Resource lanes')
Object.assign(laneRail.style, {
position: 'relative',
zIndex: '2',
overflow: 'hidden',
borderRight: '1px solid color-mix(in srgb, CanvasText 18%, transparent)',
background: 'Canvas',
color: 'CanvasText',
font: '600 11px/1.15 system-ui, sans-serif',
})
const viewport = document.createElement('div')
viewport.dataset.conformanceView = 'main'
viewport.dataset.conformanceScrollViewport = ''
viewport.setAttribute('role', 'region')
viewport.setAttribute(
'aria-label',
'Scrollable resource schedule. Use horizontal scrolling to move through dates.',
)
viewport.tabIndex = 0
Object.assign(viewport.style, {
overflowX: 'auto',
overflowY: 'hidden',
overscrollBehaviorX: 'contain',
position: 'relative',
scrollbarGutter: 'stable',
})
const chartSurface = document.createElement('div')
viewport.append(chartSurface)
body.append(laneRail, viewport)
const schedule = document.createElement('ul')
schedule.setAttribute('aria-label', 'Task schedule details')
Object.assign(schedule.style, {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: '0',
})
root.append(header, body, schedule)
const shell = {
root,
viewport,
chartSurface,
laneRail,
schedule,
taskDetails,
}
sizeResourceTimelineShell(shell, input, rows)
return shell
}
export function sizeResourceTimelineShell(
shell: ResourceTimelineShell,
input: ConformanceInput,
rows: readonly ResourceTask[],
) {
const railWidth = timelineLaneRailWidth(input.width)
const bodyHeight = timelineBodyHeight(input.height)
const viewportWidth = Math.max(1, input.width - railWidth)
const body = shell.laneRail.parentElement
if (body) body.style.gridTemplateColumns = `${railWidth}px minmax(0, 1fr)`
shell.root.style.width = `${input.width}px`
shell.root.style.height = `${input.height}px`
shell.laneRail.style.width = `${railWidth}px`
shell.laneRail.style.height = `${bodyHeight}px`
shell.viewport.style.width = `${viewportWidth}px`
shell.viewport.style.height = `${bodyHeight}px`
shell.chartSurface.style.width = `${timelineContentWidth(viewportWidth)}px`
shell.chartSurface.style.height = `${timelineChartHeight(bodyHeight)}px`
renderSchedule(shell.schedule, rows)
}
export function timelineBodyHeight(height: number) {
return Math.max(220, height - headerHeight)
}
export function timelineContentWidth(viewportWidth: number) {
return Math.max(960, viewportWidth * 2)
}
export function timelineChartHeight(viewportHeight: number) {
return Math.max(240, viewportHeight - 16)
}
export function timelineLaneRailWidth(width: number) {
return Math.round(Math.max(96, Math.min(128, width * 0.28)))
}
export function updateTimelineTaskDetails(
shell: ResourceTimelineShell,
task: ResourceTask | null,
) {
shell.taskDetails.textContent = task
? `${task.resource} · ${task.label} · ${task.status} · ${formatDate(
task.start,
)}–${formatDate(task.end)}`
: 'Scroll dates →'
shell.taskDetails.title = task
? shell.taskDetails.textContent
: 'Scroll horizontally through the schedule'
}
export function renderTimelineLaneRail(
rail: HTMLDivElement,
position: (lane: ResourceLane) => number | null,
) {
const document = rail.ownerDocument
rail.replaceChildren(
...resourceLanes.flatMap((lane) => {
const centerY = position(lane)
if (centerY === null || !Number.isFinite(centerY)) return []
const label = document.createElement('span')
label.dataset.conformanceLane = lane
label.textContent = lane
Object.assign(label.style, {
position: 'absolute',
top: `${centerY}px`,
left: '8px',
right: '6px',
overflow: 'hidden',
textOverflow: 'ellipsis',
transform: 'translateY(-50%)',
whiteSpace: 'nowrap',
})
label.title = lane
return [label]
}),
)
}
export function ensureTimelineFocusVisible(
viewport: HTMLDivElement,
centerX: number,
) {
const previous = viewport.scrollLeft
const visibleStart = previous + focusScrollPadding
const visibleEnd = previous + viewport.clientWidth - focusScrollPadding
let next = previous
if (centerX < visibleStart) {
next = centerX - focusScrollPadding
} else if (centerX > visibleEnd) {
next = centerX - viewport.clientWidth + focusScrollPadding
}
viewport.scrollLeft = Math.max(
0,
Math.min(next, viewport.scrollWidth - viewport.clientWidth),
)
return Math.abs(viewport.scrollLeft - previous) > 1
}
function createTimelineHeader(document: Document) {
const header = document.createElement('div')
header.dataset.conformanceTimelineLegend = ''
header.setAttribute('aria-label', 'Task status legend')
Object.assign(header.style, {
display: 'flex',
alignItems: 'center',
gap: '8px 12px',
padding: '5px 10px',
boxSizing: 'border-box',
overflow: 'hidden',
borderBottom: '1px solid color-mix(in srgb, CanvasText 14%, transparent)',
background: 'color-mix(in srgb, Canvas 94%, CanvasText 6%)',
color: 'CanvasText',
font: '600 11px/1.2 system-ui, sans-serif',
whiteSpace: 'nowrap',
})
for (const status of timelineStatuses) {
const item = document.createElement('span')
item.dataset.conformanceTimelineStatus = status
item.style.display = 'inline-flex'
item.style.alignItems = 'center'
item.style.gap = '5px'
const swatch = document.createElement('span')
Object.assign(swatch.style, {
width: '9px',
height: '9px',
borderRadius: '3px',
background: timelineStatusColors[status],
})
const label = document.createElement('span')
label.textContent = status[0]?.toUpperCase() + status.slice(1)
item.append(swatch, label)
header.append(item)
}
const taskDetails = document.createElement('output')
taskDetails.dataset.conformanceOverflowCue = ''
taskDetails.dataset.conformanceTimelineDetails = ''
taskDetails.setAttribute('aria-live', 'polite')
taskDetails.setAttribute('aria-atomic', 'true')
taskDetails.textContent = 'Scroll dates →'
Object.assign(taskDetails.style, {
marginLeft: 'auto',
overflow: 'hidden',
opacity: '0.76',
textOverflow: 'ellipsis',
})
header.append(taskDetails)
return { header, taskDetails }
}
function renderSchedule(
schedule: HTMLUListElement,
rows: readonly ResourceTask[],
) {
const document = schedule.ownerDocument
schedule.replaceChildren(
...rows.map((row) => {
const item = document.createElement('li')
item.textContent = `${row.resource}: ${row.label}, ${row.status}, ${formatDate(
row.start,
)} through ${formatDate(row.end)}`
return item
}),
)
}
function formatDate(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}cases/85-scrollable-resource-lanes/tanstack.ts356 lines · entry
cases/85-scrollable-resource-lanes/tanstack.ts
import { defineChart, mountChart, rect } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleBand, scaleUtc } from 'd3-scale'
import { timelineStatusColors } from './colors'
import {
createResourceTimelineShell,
ensureTimelineFocusVisible,
renderTimelineLaneRail,
sizeResourceTimelineShell,
timelineBodyHeight,
timelineChartHeight,
timelineContentWidth,
timelineLaneRailWidth,
timelineMargin,
updateTimelineTaskDetails,
} from './shell'
import {
resourceLanes,
resourceTasks,
resourceTimelineDomain,
timelineStatuses,
} from './scenario'
import { timelineDateKey } from './model'
import { tanstackCase } from '../../shared/mount'
import type {
ChartHost,
ChartPoint,
ChartScene,
ChartHostOptions,
} from '@tanstack/charts'
import type { ResourceTask } from './scenario'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceMount,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
const taskInset = 5
interface TimelineFocusState {
taskId: string | null
centerX: number | null
scrolled: boolean
}
export const resourceTimelineDefinition = (input: ConformanceInput) => {
const rows = resourceTasks(input.revision)
return defineChart(
defineChart(({ width }) => {
return {
marks: [
rect(rows, {
x1: 'start',
x2: 'end',
y: 'resource',
color: 'status',
inset: taskInset,
radius: 4,
stroke: '#ffffff',
strokeWidth: 1,
}),
],
x: {
scale: scaleUtc().domain(resourceTimelineDomain),
grid: true,
axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } },
},
y: {
scale: scaleBand<string>()
.domain(resourceLanes)
.paddingInner(0.08)
.paddingOuter(0.04),
grid: false,
axis: false,
},
color: {
domain: timelineStatuses,
range: timelineStatuses.map((status) => timelineStatusColors[status]),
},
margin: timelineMargin,
}
}),
{
svgAnimation: false,
keyboard: true,
tooltip: {
use: tooltip,
format: (point) =>
`${point.datum.resource} · ${point.datum.label} · ${
point.datum.status
} · ${formatTaskDate(point.datum.start)}–${formatTaskDate(
point.datum.end,
)}`,
},
},
)
}
export const catalogCase = tanstackCase(
resourceTimelineDefinition,
'Tasks scheduled across five resource lanes',
{
format: (point) =>
`${point.datum.resource} · ${point.datum.label} · ${
point.datum.status
} · ${formatTaskDate(point.datum.start)}–${formatTaskDate(
point.datum.end,
)}`,
},
)
export const mount: ConformanceMount = (container, input) => {
let currentInput = input
const shell = createResourceTimelineShell(
container.ownerDocument,
input,
resourceTasks(input.revision),
)
container.append(shell.root)
const { viewport, chartSurface } = shell
const focusState: TimelineFocusState = {
taskId: null,
centerX: null,
scrolled: false,
}
const updateFocusedTask = (point: ChartPoint<ResourceTask> | null) => {
focusState.taskId = point?.datum.id ?? null
focusState.centerX = point?.x ?? null
focusState.scrolled = point
? ensureTimelineFocusVisible(viewport, point.x)
: false
updateTimelineTaskDetails(shell, point?.datum ?? null)
}
const chartOptions = (
nextInput: ConformanceInput,
): ChartHostOptions<ResourceTask, number, string> => ({
definition: resourceTimelineDefinition(nextInput),
width: timelineContentWidth(
nextInput.width - timelineLaneRailWidth(nextInput.width),
),
height: timelineChartHeight(timelineBodyHeight(nextInput.height)),
ariaLabel: 'Tasks scheduled across five resource lanes',
ariaDescription:
'Focus the chart and use the arrow, Home, and End keys to inspect tasks. Offscreen tasks scroll into view.',
onFocusChange: updateFocusedTask,
onRender: ({ scene }) => {
renderTimelineLaneRail(shell.laneRail, (lane) => scene.scales.y.map(lane))
},
})
const host = mountChart(chartSurface, chartOptions(input))
const driver = createDriver(
viewport,
chartSurface,
() => currentInput,
host,
focusState,
)
return {
driver,
update(nextInput) {
const scrollLeft = viewport.scrollLeft
currentInput = nextInput
sizeResourceTimelineShell(
shell,
nextInput,
resourceTasks(nextInput.revision),
)
host.update(chartOptions(nextInput))
viewport.scrollLeft = Math.min(
scrollLeft,
Math.max(0, viewport.scrollWidth - viewport.clientWidth),
)
},
destroy() {
host.destroy()
shell.root.remove()
},
}
}
function createDriver(
viewport: HTMLDivElement,
chartSurface: HTMLDivElement,
getInput: () => ConformanceInput,
host: ChartHost<ResourceTask, number, string>,
focusState: TimelineFocusState,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return timelineTarget(viewport, chartSurface, host, target)
},
readState() {
return timelineState(viewport, getInput(), focusState)
},
geometry(query) {
return timelineGeometry(
viewport,
chartSurface,
getInput(),
host.getScene(),
query,
)
},
}
}
function timelineTarget(
viewport: HTMLDivElement,
chartSurface: HTMLDivElement,
host: ChartHost<ResourceTask, number, string>,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') {
return null
}
if (target.anchor.startsWith('task:')) {
const taskId = target.anchor.slice('task:'.length)
const scene = host.getScene()
const point = scene.points.find(
(candidate) => candidate.datum.id === taskId,
)
const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!point || !svg) return null
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (point.x / scene.width) * bounds.width,
y: bounds.top + (point.y / scene.height) * bounds.height,
focusElement: svg,
}
}
if (target.anchor !== 'viewport') return null
const bounds = viewport.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: viewport,
}
}
function timelineState(
viewport: HTMLDivElement,
input: ConformanceInput,
focusState: TimelineFocusState,
) {
const rows = resourceTasks(input.revision)
const apiBuild = rows.find((row) => row.id === 'api-build')
const qualityRelease = rows.find((row) => row.id === 'quality-release')
return {
viewport: {
scrollLeft: viewport.scrollLeft,
clientWidth: viewport.clientWidth,
scrollWidth: viewport.scrollWidth,
},
lanes: {
count: resourceLanes.length,
names: resourceLanes,
},
tasks: {
count: rows.length,
ids: rows.map((row) => row.id),
apiBuildEnd: apiBuild ? timelineDateKey(apiBuild.end) : null,
qualityReleaseStart: qualityRelease
? timelineDateKey(qualityRelease.start)
: null,
},
domain: {
start: timelineDateKey(resourceTimelineDomain[0]),
end: timelineDateKey(resourceTimelineDomain[1]),
},
focus: {
taskId: focusState.taskId,
visible:
focusState.centerX !== null &&
focusState.centerX >= viewport.scrollLeft &&
focusState.centerX <= viewport.scrollLeft + viewport.clientWidth,
scrolled: focusState.scrolled,
},
}
}
function timelineGeometry(
viewport: HTMLDivElement,
chartSurface: HTMLDivElement,
input: ConformanceInput,
scene: ChartScene<ResourceTask>,
query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
if (
(query.view !== undefined && query.view !== 'main') ||
query.role !== 'rect'
) {
return []
}
const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!svg) return []
const svgBounds = svg.getBoundingClientRect()
const viewportBounds = viewport.getBoundingClientRect()
const scaleX = svgBounds.width / scene.width
const scaleY = svgBounds.height / scene.height
const height = Math.max(
0,
(scene.scales.y.bandwidth - taskInset * 2) * scaleY,
)
return resourceTasks(input.revision).flatMap((row) => {
const x1 = scene.scales.x.map(row.start)
const x2 = scene.scales.x.map(row.end)
const centerY = scene.scales.y.map(row.resource)
const sample = clipClientSample(
{
x: svgBounds.left + Math.min(x1, x2) * scaleX,
y:
svgBounds.top +
(centerY - scene.scales.y.bandwidth / 2 + taskInset) * scaleY,
width: Math.abs(x2 - x1) * scaleX,
height,
paint: timelineStatusColors[row.status],
},
viewportBounds,
)
return sample ? [sample] : []
})
}
function clipClientSample(
sample: ConformanceGeometrySample,
viewport: DOMRect,
): ConformanceGeometrySample | null {
const left = Math.max(sample.x, viewport.left)
const top = Math.max(sample.y, viewport.top)
const right = Math.min(sample.x + sample.width, viewport.right)
const bottom = Math.min(sample.y + sample.height, viewport.bottom)
if (right <= left || bottom <= top) return null
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
paint: sample.paint,
}
}
function formatTaskDate(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}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 }
}