1,603 lines · 9 files · 42.2 kB
cases/92-editable-event-range/colors.ts5 lines · dependency
cases/92-editable-event-range/colors.ts
import type { EditableEventId } from './scenario'
export function editableEventColor(id: EditableEventId) {
return id === 'release' ? '#f97316' : '#2563eb'
}cases/92-editable-event-range/controls.ts190 lines · dependency
cases/92-editable-event-range/controls.ts
export interface EditableControlsState {
date: string
minDate: string
maxDate: string
summaryText: string
eventDescriptions: readonly string[]
}
export interface EditableControlsOptions {
onDateInput: (value: string) => boolean
onDateCommit: () => void
onDateCancel: () => void
}
export function createEditableControls(
view: HTMLDivElement,
options: EditableControlsOptions,
) {
const document = view.ownerDocument
const style = document.createElement('style')
style.textContent = `
.ts-conformance-event-date:focus-visible {
outline: 3px solid var(--ts-chart-1, #2563eb);
outline-offset: 2px;
}
.ts-conformance-event-summary,
.ts-conformance-event-date {
border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
background: color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas);
color: inherit;
}
.ts-conformance-event-date {
color-scheme: light dark;
}
.ts-conformance-event-date[aria-invalid="true"] {
border-color: #dc2626;
}
`
const layer = document.createElement('div')
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
zIndex: '3',
pointerEvents: 'none',
})
const toolbar = document.createElement('div')
toolbar.className = 'ts-conformance-event-toolbar'
toolbar.setAttribute('role', 'group')
toolbar.setAttribute('aria-label', 'Release event editor')
Object.assign(toolbar.style, {
position: 'absolute',
top: '4px',
left: '12px',
right: '12px',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
justifyContent: 'flex-end',
gap: '8px',
color: 'inherit',
pointerEvents: 'none',
})
const status = document.createElement('output')
status.className = 'ts-conformance-event-summary'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
status.setAttribute('aria-atomic', 'true')
Object.assign(status.style, {
boxSizing: 'border-box',
flex: '1 1 120px',
minWidth: '120px',
minHeight: '44px',
padding: '8px 10px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
font: '600 12px/1.25 system-ui, sans-serif',
})
const dateLabel = document.createElement('label')
Object.assign(dateLabel.style, {
boxSizing: 'border-box',
flex: '0 1 140px',
minWidth: '128px',
display: 'grid',
gap: '2px',
color: 'inherit',
font: '600 11px/1.15 system-ui, sans-serif',
pointerEvents: 'auto',
})
dateLabel.append('Release end')
const dateInput = document.createElement('input')
dateInput.className = 'ts-conformance-event-date'
dateInput.type = 'date'
dateInput.required = true
dateInput.setAttribute('aria-label', 'Release end date input')
dateInput.setAttribute('aria-invalid', 'false')
Object.assign(dateInput.style, {
boxSizing: 'border-box',
width: '100%',
height: '44px',
padding: '6px 8px',
borderRadius: '8px',
font: '600 12px/1 system-ui, sans-serif',
})
dateLabel.append(dateInput)
const validation = document.createElement('span')
validation.className = 'ts-conformance-event-validation'
validation.setAttribute('aria-live', 'polite')
validation.hidden = true
Object.assign(validation.style, {
flex: '1 0 100%',
color: '#dc2626',
font: '600 11px/1.2 system-ui, sans-serif',
})
const eventList = document.createElement('ul')
eventList.className = 'ts-conformance-event-identities'
Object.assign(eventList.style, {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
})
const setDateValidity = (valid: boolean) => {
const message = valid ? '' : 'Choose a release end date within the range.'
dateInput.setAttribute('aria-invalid', String(!valid))
dateInput.setCustomValidity(message)
validation.hidden = valid
validation.textContent = message
}
const handleDateInput = () => {
setDateValidity(options.onDateInput(dateInput.value))
}
const handleDateCommit = () => {
if (dateInput.getAttribute('aria-invalid') !== 'true') {
options.onDateCommit()
}
}
const handleDateCancel = () => options.onDateCancel()
dateInput.addEventListener('input', handleDateInput)
dateInput.addEventListener('change', handleDateCommit)
dateInput.addEventListener('pointercancel', handleDateCancel)
toolbar.append(status, dateLabel, validation)
layer.append(toolbar, eventList)
view.append(style, layer)
return {
dateInput,
paint(state: EditableControlsState) {
dateInput.min = state.minDate
dateInput.max = state.maxDate
if (
document.activeElement !== dateInput ||
dateInput.getAttribute('aria-invalid') !== 'true'
) {
dateInput.value = state.date
setDateValidity(true)
}
status.value = state.summaryText
status.textContent = state.summaryText
eventList.replaceChildren(
...state.eventDescriptions.map((description) => {
const item = document.createElement('li')
item.textContent = description
return item
}),
)
},
destroy() {
dateInput.removeEventListener('input', handleDateInput)
dateInput.removeEventListener('change', handleDateCommit)
dateInput.removeEventListener('pointercancel', handleDateCancel)
style.remove()
layer.remove()
},
}
}cases/92-editable-event-range/model.ts41 lines · dependency
cases/92-editable-event-range/model.ts
import { utcDay } from 'd3-time'
import { editableDomain, editableEventStart } from './scenario'
const day = 86_400_000
export const editableEventEndValues = utcDay.range(
utcDay.offset(editableEventStart, 1),
utcDay.offset(editableDomain[1], 1),
)
export function editableDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function editableDateFromAnchor(anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return null
const date = new Date(`${key}T00:00:00.000Z`)
if (
!Number.isFinite(date.getTime()) ||
editableDateKey(date) !== key ||
date < editableDomain[0] ||
date > editableDomain[1]
) {
return null
}
return date
}
export function clampEditableEventEnd(date: Date) {
const minimum = editableEventStart.getTime() + day
const timestamp = Math.min(
editableDomain[1].getTime(),
Math.max(minimum, utcDay.round(date).getTime()),
)
return new Date(timestamp)
}
export function editableDurationDays(start: Date, end: Date) {
return (end.getTime() - start.getTime()) / day
}cases/92-editable-event-range/scenario.ts67 lines · dependency
cases/92-editable-event-range/scenario.ts
export type EditableEventId = 'discovery' | 'design' | 'campaign' | 'release'
export type EditableLane = 'Product' | 'Design' | 'Marketing' | 'Engineering'
export interface EditableEvent {
id: EditableEventId
label: string
lane: EditableLane
start: Date
end: Date
}
export const editableLanes: readonly EditableLane[] = [
'Product',
'Design',
'Marketing',
'Engineering',
]
export const editableDomain: readonly [Date, Date] = [
utcDate(2025, 0, 1),
utcDate(2025, 2, 1),
]
export const editableEventStart = utcDate(2025, 1, 3)
export const initialEditableEventEnd = utcDate(2025, 1, 12)
export function editableEvents(
revision = 0,
releaseEnd = initialEditableEventEnd,
): readonly EditableEvent[] {
const updated = revision % 2 === 1
return [
{
id: 'discovery',
label: 'Discovery',
lane: 'Product',
start: utcDate(2025, 0, 4),
end: utcDate(2025, 0, 13),
},
{
id: 'design',
label: 'Design system',
lane: 'Design',
start: utcDate(2025, 0, 10),
end: utcDate(2025, 0, updated ? 26 : 24),
},
{
id: 'campaign',
label: 'Campaign',
lane: 'Marketing',
start: utcDate(2025, 0, updated ? 19 : 20),
end: utcDate(2025, 1, 7),
},
{
id: 'release',
label: 'Release window',
lane: 'Engineering',
start: editableEventStart,
end: releaseEnd,
},
]
}
function utcDate(year: number, month: number, date: number) {
return new Date(Date.UTC(year, month, date))
}cases/92-editable-event-range/tanstack.ts531 lines · entry
cases/92-editable-event-range/tanstack.ts
import { defineChart, mountChart, rect, text } from '@tanstack/charts'
import { handleX } from '@tanstack/charts/interaction/handle'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { scaleBand, scaleUtc } from 'd3-scale'
import { editableEventColor } from './colors'
import { createEditableControls } from './controls'
import {
clampEditableEventEnd,
editableDateFromAnchor,
editableDateKey,
editableDurationDays,
editableEventEndValues,
} from './model'
import {
editableDomain,
editableEvents,
editableEventStart,
editableLanes,
initialEditableEventEnd,
} from './scenario'
import { scenePointToClient } from '../../shared/driver-geometry'
import { tanstackCase } from '../../shared/mount'
import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { EditableEvent } from './scenario'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceMount,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
interface EditableChartInput extends ConformanceInput {
end: Date
}
interface EditableState {
end: Date
editing: boolean
editCount: number
originEnd: Date | null
}
const margin = { top: 96, right: 26, bottom: 48, left: 82 }
const handleId = 'release-end'
export function editableEventDefinition(
input: EditableChartInput,
onEndChange: (value: Date, reason: HandleXChange<Date>) => void,
) {
const rows = editableEvents(input.revision, input.end)
const outsideLabels = rows
.filter((row) => row.id !== 'release')
.map((row) => ({ ...row, labelDate: row.end }))
return defineChart(({ width }) => {
const releaseLabels = rows
.filter(
(row) =>
row.id === 'release' && eventBarCanFitLabel(row, width, 'Release'),
)
.map((row) => ({
...row,
labelDate: row.start,
shortLabel: 'Release',
}))
return {
marks: [
rect(rows, {
id: 'event-ranges',
x1: 'start',
x2: 'end',
y: 'lane',
color: 'id',
radius: 5,
stroke: '#ffffff',
strokeWidth: 1,
}),
...(input.preview === true
? []
: [
text(outsideLabels, {
id: 'event-labels',
x: 'labelDate',
y: 'lane',
text: 'label',
anchor: 'start',
dx: 5,
fill: 'currentColor',
fontSize: 10,
fontWeight: 600,
}),
text(releaseLabels, {
id: 'release-label',
x: 'labelDate',
y: 'lane',
text: 'shortLabel',
anchor: 'start',
dx: 5,
fill: '#431407',
fontSize: 10,
fontWeight: 700,
}),
]),
],
x: {
scale: scaleUtc().domain(editableDomain),
grid: true,
axis: {
ticks: {
format: (value: Date) =>
value.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}),
},
},
},
y: {
scale: scaleBand<string>()
.domain(editableLanes)
.paddingInner(0.38)
.paddingOuter(0.19),
grid: false,
},
color: {
domain: ['discovery', 'design', 'campaign', 'release'],
range: [
editableEventColor('discovery'),
editableEventColor('design'),
editableEventColor('campaign'),
editableEventColor('release'),
],
},
controls: [
handleX<Date, string>({
id: handleId,
value: controlledSignal<Date, HandleXChange<Date>>(
input.end,
(next, { reason }) => onEndChange(next, reason),
),
values: editableEventEndValues,
cross: { value: 'Engineering' },
trackStyle: {
fill: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 58%, transparent)',
},
ruleStyle: false,
handleStyle: {
fill: 'var(--ts-chart-2, #f97316)',
stroke: 'Canvas',
strokeWidth: 2,
},
hitSize: 44,
ariaLabel: 'Release end handle',
format: (value) => editableHandleValueText(value),
}),
],
svgAnimation: false,
keyboard: false,
focusRing: false,
margin,
}
})
}
export const catalogCase = tanstackCase(
(input: ConformanceInput) =>
editableEventDefinition(
{ ...input, end: initialEditableEventEnd },
() => {},
),
editableAriaLabel(0, initialEditableEventEnd),
)
export const mount: ConformanceMount = (container, input) => {
let currentInput = input
let acceptedEnd = cloneDate(initialEditableEventEnd)
let host: ChartHost<EditableEvent, Date | number, string> | undefined
const state: EditableState = {
end: cloneDate(acceptedEnd),
editing: false,
editCount: 0,
originEnd: null,
}
const document = container.ownerDocument
const view = document.createElement('div')
const chartSurface = document.createElement('div')
view.dataset.conformanceView = 'main'
view.style.position = 'relative'
view.style.touchAction = 'pan-y'
view.append(chartSurface)
container.append(view)
sizeView(view, input)
const beginEdit = (origin = state.end) => {
if (state.editing) return
state.originEnd = cloneDate(origin)
state.editing = true
}
const options = (): ChartHostOptions<
EditableEvent,
Date | number,
string
> => ({
definition: editableEventDefinition(
{ ...currentInput, end: acceptedEnd },
handleEndChange,
),
width: currentInput.width,
height: currentInput.height,
ariaLabel: editableAriaLabel(currentInput.revision, state.end),
})
const applyEnd = (next: Date) => {
acceptedEnd = clampEditableEventEnd(next)
state.end = cloneDate(acceptedEnd)
host?.update(options())
}
const commitEdit = () => {
if (!state.editing) return
state.editing = false
state.originEnd = null
state.editCount += 1
paintControls()
}
const cancelEdit = (fallback?: Date) => {
if (!state.editing && !fallback) return
const origin = fallback ?? state.originEnd
state.editing = false
state.originEnd = null
if (origin) applyEnd(origin)
paintControls()
}
function handleEndChange(next: Date, reason: HandleXChange<Date>) {
if (reason.type === 'preview') {
beginEdit(reason.origin)
applyEnd(next)
paintControls()
return
}
if (reason.type === 'cancel') {
cancelEdit(reason.origin)
return
}
beginEdit(reason.origin)
applyEnd(next)
commitEdit()
}
const controls = createEditableControls(view, {
onDateInput(value) {
const next = editableDateFromAnchor(`date:${value}`)
if (!next || clampEditableEventEnd(next).getTime() !== next.getTime()) {
return false
}
beginEdit()
applyEnd(next)
paintControls()
return true
},
onDateCommit: commitEdit,
onDateCancel: () => cancelEdit(),
})
function paintControls() {
controls.paint({
date: editableDateKey(state.end),
minDate: editableDateKey(editableEventEndValues[0]!),
maxDate: editableDateKey(editableEventEndValues.at(-1)!),
summaryText: editableSummaryText(state.end),
eventDescriptions: editableEvents(currentInput.revision, state.end).map(
(row) =>
`${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
),
})
}
host = mountChart(chartSurface, options())
paintControls()
const driver = createDriver(
view,
chartSurface,
controls.dateInput,
() => host!.getScene(),
() => state,
() => currentInput,
)
return {
driver,
update(nextInput) {
currentInput = nextInput
sizeView(view, nextInput)
host!.update(options())
paintControls()
},
destroy() {
controls.destroy()
host!.destroy()
view.remove()
},
}
}
function createDriver(
view: HTMLDivElement,
chartSurface: HTMLDivElement,
dateInput: HTMLInputElement,
getScene: () => ChartScene<EditableEvent, Date | number, string>,
getState: () => EditableState,
getInput: () => ConformanceInput,
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(
chartSurface,
dateInput,
getScene(),
getState().end,
target,
)
},
readState() {
return interactionState(getState(), getInput())
},
geometry(query) {
return editableGeometry(
chartSurface,
getScene(),
getInput(),
getState().end,
query,
)
},
viewBounds(viewName) {
if (viewName !== undefined && viewName !== 'main') return null
return elementGeometry(view)
},
}
}
function resolveTarget(
chartSurface: HTMLDivElement,
dateInput: HTMLInputElement,
scene: ChartScene<EditableEvent, Date | number, string>,
end: Date,
target: ConformanceTarget,
) {
if (target.view !== undefined && target.view !== 'main') return null
if (target.anchor === 'control:date') return elementCenter(dateInput)
const date =
target.anchor === 'event:release:end'
? end
: editableDateFromAnchor(target.anchor)
if (!date) return null
const point = scenePointToClient(
chartSurface,
scene,
scene.scales.x.map(date),
scene.scales.y.map('Engineering'),
)
if (!point) return null
return {
...point,
focusElement:
chartSurface.querySelector<SVGElement>(
`[data-chart-handle-surface="${handleId}"]`,
) ?? point.focusElement,
}
}
function interactionState(state: EditableState, input: ConformanceInput) {
const rows = editableEvents(input.revision, state.end)
const design = rows.find((row) => row.id === 'design')
const campaign = rows.find((row) => row.id === 'campaign')
return {
editor: {
id: 'release',
start: editableDateKey(editableEventStart),
end: editableDateKey(state.end),
durationDays: editableDurationDays(editableEventStart, state.end),
editing: state.editing,
editCount: state.editCount,
},
events: {
count: rows.length,
ids: rows.map((row) => row.id),
designEnd: design ? editableDateKey(design.end) : null,
campaignStart: campaign ? editableDateKey(campaign.start) : null,
},
}
}
function editableGeometry(
chartSurface: HTMLDivElement,
scene: ChartScene<EditableEvent, Date | number, string>,
input: ConformanceInput,
end: Date,
query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
if (query.view !== undefined && query.view !== 'main') return []
if (query.role === 'dot') {
const handle = chartSurface.querySelector<SVGElement>(
`[data-chart-handle="${handleId}"]`,
)
return handle ? [elementGeometry(handle)] : []
}
if (query.role === 'rule') {
const track = chartSurface.querySelector<SVGElement>(
`[data-chart-handle-track="${handleId}"]`,
)
return track ? [elementGeometry(track)] : []
}
if (query.role !== 'rect') return []
const height = scene.scales.y.bandwidth
return editableEvents(input.revision, end).flatMap((row) => {
const start = scenePointToClient(
chartSurface,
scene,
scene.scales.x.map(row.start),
scene.scales.y.map(row.lane),
)
const finish = scenePointToClient(
chartSurface,
scene,
scene.scales.x.map(row.end),
scene.scales.y.map(row.lane),
)
const top = scenePointToClient(
chartSurface,
scene,
scene.scales.x.map(row.start),
scene.scales.y.map(row.lane) - height / 2,
)
const bottom = scenePointToClient(
chartSurface,
scene,
scene.scales.x.map(row.start),
scene.scales.y.map(row.lane) + height / 2,
)
if (!start || !finish || !top || !bottom) return []
return [
{
x: Math.min(start.x, finish.x),
y: Math.min(top.y, bottom.y),
width: Math.abs(finish.x - start.x),
height: Math.abs(bottom.y - top.y),
paint: editableEventColor(row.id),
},
]
})
}
function elementGeometry(
element: HTMLElement | 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.backgroundColor || style.stroke,
}
}
function elementCenter(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`
}
function editableHandleValueText(end: Date) {
return `Release: ${editableDateKey(editableEventStart)} → ${editableDateKey(end)} · ${editableDurationDays(editableEventStart, end)} days`
}
function editableSummaryText(end: Date) {
return `Release · ${compactDate(editableEventStart)} → ${compactDate(end)} · ${editableDurationDays(editableEventStart, end)} days`
}
function compactDate(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}
function editableAriaLabel(revision: number, end: Date) {
return `Editable schedule. ${editableEvents(revision, end)
.map(
(row) =>
`${row.label}, ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
)
.join('. ')}.`
}
function eventBarCanFitLabel(
event: EditableEvent,
width: number,
label: string,
) {
const plotWidth = Math.max(0, width - margin.left - margin.right)
const domainWidth = editableDomain[1].getTime() - editableDomain[0].getTime()
const eventWidth = event.end.getTime() - event.start.getTime()
const barWidth = domainWidth > 0 ? (eventWidth / domainWidth) * plotWidth : 0
return barWidth >= label.length * 6 + 10
}
function cloneDate(date: Date) {
return new Date(date.getTime())
}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 }
}