1,436 lines · 9 files · 38.6 kB
cases/87-echarts-synchronized-cursors/colors.ts8 lines · dependency
cases/87-echarts-synchronized-cursors/colors.ts
import type { SynchronizedCursorView } from './model'
export const synchronizedCursorColors: Readonly<
Record<SynchronizedCursorView, string>
> = {
current: '#2563eb',
previous: '#e11d48',
}cases/87-echarts-synchronized-cursors/model.ts48 lines · dependency
cases/87-echarts-synchronized-cursors/model.ts
import type { TravelersRow } from '@charts-poc/demo-data/travelers'
export type SynchronizedCursorView = 'current' | 'previous'
export const synchronizedCursorViews: readonly SynchronizedCursorView[] = [
'current',
'previous',
]
export const synchronizedCursorYDomains: Readonly<
Record<SynchronizedCursorView, readonly [number, number]>
> = {
current: [0, 1_200_000],
previous: [0, 3_000_000],
}
export function synchronizedCursorDateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function synchronizedCursorAnchorDate(anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
const timestamp = Date.parse(key.includes('T') ? key : `${key}T00:00:00.000Z`)
if (!Number.isFinite(timestamp)) return null
return new Date(timestamp)
}
export function synchronizedCursorDatumAtDate(
rows: readonly TravelersRow[],
date: Date,
) {
const timestamp = date.getTime()
return rows.find((datum) => datum.date.getTime() === timestamp) ?? null
}
export function synchronizedCursorNearestDatum(
rows: readonly TravelersRow[],
date: Date,
) {
const timestamp = date.getTime()
return rows.reduce<TravelersRow | undefined>((nearest, datum) => {
if (!nearest) return datum
return Math.abs(datum.date.getTime() - timestamp) <
Math.abs(nearest.date.getTime() - timestamp)
? datum
: nearest
}, undefined)
}cases/87-echarts-synchronized-cursors/selection.ts15 lines · dependency
cases/87-echarts-synchronized-cursors/selection.ts
import type { TravelersRow } from '@charts-poc/demo-data/travelers'
export function selectSynchronizedCursorData(
rows: readonly TravelersRow[],
revision = 0,
): readonly TravelersRow[] {
const advanced = Math.abs(Math.trunc(revision)) % 2
return rows.slice(8 - advanced, 16 - advanced).reverse()
}
export function synchronizedCursorDates(
rows: readonly TravelersRow[],
): readonly Date[] {
return rows.map((row) => row.date)
}cases/87-echarts-synchronized-cursors/summary.ts134 lines · dependency
cases/87-echarts-synchronized-cursors/summary.ts
import {
synchronizedCursorDateKey,
synchronizedCursorDatumAtDate,
} from './model'
import { travelers } from '@charts-poc/demo-data/travelers'
import { selectSynchronizedCursorData } from './selection'
import { synchronizedCursorColors } from './colors'
import type { ConformanceInput } from '../../types'
export interface SynchronizedSummary {
root: HTMLDivElement
date: HTMLSpanElement
current: HTMLSpanElement
previous: HTMLSpanElement
}
export function createSynchronizedSummary(
document: Document,
): SynchronizedSummary {
const root = document.createElement('div')
root.dataset.conformanceSynchronizedSummary = ''
root.setAttribute('role', 'status')
root.setAttribute('aria-live', 'polite')
root.setAttribute('aria-atomic', 'true')
Object.assign(root.style, {
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
alignItems: 'center',
gap: '8px',
minHeight: '56px',
padding: '6px 12px',
boxSizing: 'border-box',
borderBottom: '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
background: 'color-mix(in srgb, Canvas 95%, CanvasText 5%)',
color: 'CanvasText',
font: '500 12px/1.25 system-ui, sans-serif',
})
const date = summaryOutput(document, 'Linked date', 'currentColor')
date.dataset.conformanceSynchronizedDate = ''
const current = summaryOutput(
document,
'2020 travelers',
synchronizedCursorColors.current,
)
current.dataset.conformanceSynchronizedCurrent = ''
const previous = summaryOutput(
document,
'2019 travelers',
synchronizedCursorColors.previous,
)
previous.dataset.conformanceSynchronizedPrevious = ''
root.append(
date.parentElement!,
current.parentElement!,
previous.parentElement!,
)
return { root, date, current, previous }
}
export function updateSynchronizedSummary(
summary: SynchronizedSummary,
date: Date | null,
input: ConformanceInput,
pinned: boolean,
) {
if (!date) {
summary.date.textContent = 'Focus either chart'
summary.current.textContent = '—'
summary.previous.textContent = '—'
delete summary.root.dataset.date
summary.root.dataset.pinned = 'false'
return
}
const rows = selectSynchronizedCursorData(travelers, input.revision)
const row = synchronizedCursorDatumAtDate(rows, date)
summary.date.textContent = `${formatDate(date)}${pinned ? ' · pinned' : ''}`
summary.current.textContent = row?.current.toLocaleString() ?? '—'
summary.previous.textContent = row?.previous.toLocaleString() ?? '—'
summary.root.dataset.date = synchronizedCursorDateKey(date)
summary.root.dataset.pinned = String(pinned)
}
function summaryOutput(document: Document, labelText: string, color: string) {
const cell = document.createElement('label')
Object.assign(cell.style, {
display: 'grid',
gridTemplateColumns: '8px minmax(0, 1fr)',
gridTemplateRows: 'auto auto',
columnGap: '6px',
minWidth: '0',
})
const swatch = document.createElement('span')
Object.assign(swatch.style, {
gridRow: '1 / 3',
alignSelf: 'center',
width: '8px',
height: '8px',
borderRadius: '999px',
background: color,
})
const label = document.createElement('span')
label.textContent = labelText
Object.assign(label.style, {
overflow: 'hidden',
color: 'currentColor',
fontSize: '10px',
letterSpacing: '0.02em',
opacity: '0.68',
textOverflow: 'ellipsis',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
})
const value = document.createElement('span')
value.textContent = labelText === 'Linked date' ? 'Focus either chart' : '—'
Object.assign(value.style, {
overflow: 'hidden',
fontWeight: '700',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})
cell.append(swatch, label, value)
return value
}
function formatDate(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}cases/87-echarts-synchronized-cursors/tanstack.ts462 lines · entry
cases/87-echarts-synchronized-cursors/tanstack.ts
import { travelers } from '@charts-poc/demo-data/travelers'
import { defineChart, dot, lineY, mountChart } from '@tanstack/charts'
import { focusGuideX } from '@tanstack/charts/focus/guide'
import { decorative } from '@tanstack/charts/mark/decorative'
import { tooltip } from '@tanstack/charts/tooltip'
import { viewGrid } from '@tanstack/charts/view'
import { scaleLinear, scaleUtc } from 'd3-scale'
import {
clientPointBounds,
scenePointToClient,
} from '../../shared/driver-geometry'
import { tanstackCase } from '../../shared/mount'
import { synchronizedCursorColors } from './colors'
import {
synchronizedCursorAnchorDate,
synchronizedCursorDateKey,
synchronizedCursorDatumAtDate,
synchronizedCursorNearestDatum,
synchronizedCursorViews,
synchronizedCursorYDomains,
} from './model'
import { selectSynchronizedCursorData } from './selection'
import { createSynchronizedSummary, updateSynchronizedSummary } from './summary'
import type {
ChartHostOptions,
ChartScene,
ChartTooltipOptions,
SceneGroup,
SceneNode,
} from '@tanstack/charts'
import type { TravelersRow } from '@charts-poc/demo-data/travelers'
import type { SynchronizedCursorView } from './model'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceJsonObject,
ConformanceMount,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
const summaryHeight = 56
const viewGap = 8
const viewMargin = { top: 16, right: 24, bottom: 34, left: 62 } as const
const travelerCountFormat = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
const month = new Intl.DateTimeFormat('en-US', {
month: 'short',
timeZone: 'UTC',
})
const synchronizedCursorTooltip: ChartTooltipOptions<TravelersRow> = {
sticky: true,
visibility: 'pinned',
anchor: 'point',
placement: ['bottom-right', 'bottom-left', 'right', 'left'],
offset: 10,
formatGroup: () => 'Pinned · Press Escape to release',
}
export const synchronizedCursorDefinition = (input: ConformanceInput) => {
const rows = selectSynchronizedCursorData(travelers, input.revision)
const composed = viewGrid({
id: 'synchronized-cursors',
rows: synchronizedCursorViews.map((view) => ({ id: view, grow: 1 })),
columns: [{ id: 'main', grow: 1 }],
rowGap: input.preview ? 4 : viewGap,
views: synchronizedCursorViews.map((view) => ({
id: view,
row: view,
column: 'main' as const,
...(view === 'previous' ? { share: { x: 'current' as const } } : {}),
chart: synchronizedCursorViewDefinition(
rows,
view,
input.preview === true,
),
})),
})
return defineChart(composed, {
svgAnimation: false,
keyboard: true,
focus: 'group-x',
focusRing: false,
maxFocusDistance: Number.POSITIVE_INFINITY,
tooltip: {
use: tooltip,
...synchronizedCursorTooltip,
},
})
}
function synchronizedCursorViewDefinition(
rows: readonly TravelersRow[],
view: SynchronizedCursorView,
preview: boolean,
) {
const group = () => view
return defineChart({
marks: [
decorative(
lineY(rows, {
id: `${view}-line`,
x: 'date',
y: view,
z: group,
stroke: synchronizedCursorColors[view],
strokeWidth: 2,
}),
),
dot(rows, {
id: `${view}-points`,
x: 'date',
y: view,
z: group,
fill: synchronizedCursorColors[view],
r: 3,
stroke: '#ffffff',
strokeWidth: 1,
}),
focusGuideX(rows, {
id: `${view}-guide`,
x: 'date',
y: view,
z: group,
match: 'x',
xRule: {
stroke: '#64748b',
strokeWidth: 1,
strokeDasharray: '4 4',
},
marker: {
radius: 5,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 2,
},
}),
],
x: {
scale: scaleUtc,
axis: preview
? false
: { ticks: { format: (value) => month.format(value) } },
},
y: {
scale: scaleLinear().domain(synchronizedCursorYDomains[view]),
grid: !preview,
axis: preview
? false
: {
ticks: { count: 4, format: travelerCountFormat.format },
label: view === 'current' ? '2020 travelers' : '2019 travelers',
},
},
margin: preview ? 0 : viewMargin,
})
}
export const catalogCase = tanstackCase(
synchronizedCursorDefinition,
'Linked 2020 and 2019 airport traveler time series',
synchronizedCursorTooltip,
{
focus(scene) {
return (
scene.points.find(
(point) =>
point.markId === 'synchronized-cursors:current:current-points' &&
synchronizedCursorDateKey(point.datum.date) === '2020-12-13',
) ?? null
)
},
},
)
export const mount: ConformanceMount = (container, input) => {
let currentInput = input
let focusedDate: Date | null = null
let pinned = false
const shell = container.ownerDocument.createElement('div')
const summary = createSynchronizedSummary(container.ownerDocument)
const chartFrame = container.ownerDocument.createElement('div')
shell.style.display = 'grid'
shell.style.gridTemplateRows = `${summaryHeight}px minmax(0, 1fr)`
chartFrame.style.minHeight = '0'
shell.append(summary.root, chartFrame)
container.append(shell)
sizeShell(shell, chartFrame, input)
const updateSummary = () =>
updateSynchronizedSummary(summary, focusedDate, currentInput, pinned)
const options = (): ChartHostOptions<TravelersRow, Date, number> => ({
definition: synchronizedCursorDefinition(currentInput),
width: currentInput.width,
height: chartHeight(currentInput),
ariaLabel: 'Linked 2020 and 2019 airport traveler time series',
ariaDescription:
'Move across either view or use the arrow keys to compare both years at the same date. Select a point to pin the cursor.',
onFocusGroupChange(points) {
const date = points[0]?.datum.date ?? null
focusedDate = date
if (!date) pinned = false
updateSummary()
},
onSelect(point) {
if (!point) return
focusedDate = point.datum.date
pinned = !pinned
updateSummary()
},
})
const host = mountChart(chartFrame, options())
updateSummary()
const driver = createDriver(
chartFrame,
() => currentInput,
() => host.getScene(),
() => ({ date: focusedDate, pinned }),
)
return {
driver,
update(nextInput) {
currentInput = nextInput
sizeShell(shell, chartFrame, nextInput)
host.update(options())
updateSummary()
},
destroy() {
host.destroy()
shell.remove()
},
}
}
function createDriver(
surface: HTMLElement,
getInput: () => ConformanceInput,
getScene: () => ChartScene<TravelersRow, Date, number>,
getState: () => { date: Date | null; pinned: boolean },
): ConformanceTestDriver {
return {
resolveTarget(target) {
return resolveTarget(surface, getInput(), getScene(), target)
},
readState() {
const state = getState()
return interactionState(
surface,
getInput(),
getScene(),
state.date,
state.pinned,
)
},
geometry(query) {
return geometry(surface, getScene(), query)
},
viewBounds(view) {
const synchronized = synchronizedView(view)
return synchronized
? logicalViewBounds(surface, getScene(), synchronized)
: null
},
}
}
function resolveTarget(
surface: HTMLElement,
input: ConformanceInput,
scene: ChartScene<TravelersRow, Date, number>,
target: ConformanceTarget,
) {
const view = synchronizedView(target.view)
const date = synchronizedCursorAnchorDate(target.anchor)
if (!view || !date) return null
const datum = synchronizedCursorNearestDatum(
selectSynchronizedCursorData(travelers, input.revision),
date,
)
if (!datum) return null
const point = pointsForView(scene, view).find(
(candidate) => candidate.datum.date.getTime() === datum.date.getTime(),
)
return point ? scenePointToClient(surface, scene, point.x, point.y) : null
}
function geometry(
surface: HTMLElement,
scene: ChartScene<TravelersRow, Date, number>,
query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
const view = synchronizedView(query.view)
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!view || !svg) return []
const bounds = svg.getBoundingClientRect()
const scaleX = bounds.width / scene.width
const scaleY = bounds.height / scene.height
const points = pointsForView(scene, view)
if (query.role === 'dot') {
return points.map((point) => ({
x: bounds.left + point.x * scaleX - 3 * scaleX,
y: bounds.top + point.y * scaleY - 3 * scaleY,
width: 6 * scaleX,
height: 6 * scaleY,
paint: synchronizedCursorColors[view],
}))
}
if (query.role === 'line') {
const sample = clientPointBounds(
points.map((point) => [point.x, point.y] as const),
bounds,
{ scaleX, scaleY, paint: synchronizedCursorColors[view] },
)
return sample ? [sample] : []
}
return []
}
function pointsForView(
scene: ChartScene<TravelersRow, Date, number>,
view: SynchronizedCursorView,
) {
const markId = `synchronized-cursors:${view}:${view}-points`
return scene.points.filter((point) => point.markId === markId)
}
function synchronizedView(
view: string | undefined,
): SynchronizedCursorView | null {
return view === 'current' || view === 'previous' ? view : null
}
function logicalViewBounds(
surface: HTMLElement,
scene: ChartScene<TravelersRow, Date, number>,
view: SynchronizedCursorView,
): ConformanceGeometrySample | null {
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
const group = viewGroup(scene, view)
if (!svg || !group?.clip) return null
const bounds = svg.getBoundingClientRect()
const scaleX = bounds.width / scene.width
const scaleY = bounds.height / scene.height
const x = (group.translateX ?? 0) + viewMargin.left
const y = (group.translateY ?? 0) + viewMargin.top
return {
x: bounds.left + x * scaleX,
y: bounds.top + y * scaleY,
width: Math.max(
1,
(group.clip.width - viewMargin.left - viewMargin.right) * scaleX,
),
height: Math.max(
1,
(group.clip.height - viewMargin.top - viewMargin.bottom) * scaleY,
),
}
}
function viewGroup(
scene: ChartScene,
view: SynchronizedCursorView,
): SceneGroup | undefined {
return flatten(scene.nodes).find(
(node): node is SceneGroup =>
node.kind === 'group' &&
node.className === 'ts-chart__view' &&
node.key === `synchronized-cursors:${view}:view`,
)
}
function flatten(nodes: readonly SceneNode[]): readonly SceneNode[] {
return nodes.flatMap((node) =>
node.kind === 'group' ? [node, ...flatten(node.children)] : [node],
)
}
function interactionState(
surface: HTMLElement,
input: ConformanceInput,
scene: ChartScene<TravelersRow, Date, number>,
date: Date | null,
pinned: boolean,
): ConformanceJsonObject {
const rows = selectSynchronizedCursorData(travelers, input.revision)
const current = renderedCrosshairState(surface, scene, 'current')
const previous = renderedCrosshairState(surface, scene, 'previous')
return {
shared: {
date: date ? synchronizedCursorDateKey(date) : null,
currentValue: date
? (synchronizedCursorDatumAtDate(rows, date)?.current ?? null)
: null,
previousValue: date
? (synchronizedCursorDatumAtDate(rows, date)?.previous ?? null)
: null,
pinned,
},
crosshairs: {
aligned:
current.xNormalized !== null &&
previous.xNormalized !== null &&
Math.abs(current.xNormalized - previous.xNormalized) < 0.005,
current,
previous,
},
}
}
function renderedCrosshairState(
surface: HTMLElement,
scene: ChartScene<TravelersRow, Date, number>,
view: SynchronizedCursorView,
) {
const viewBounds = logicalViewBounds(surface, scene, view)
if (!viewBounds) return { visible: false, xNormalized: null }
const line = [
...surface.querySelectorAll<SVGLineElement>(
'.ts-chart__focus-guide-x-rule',
),
].find((candidate) => {
const bounds = candidate.getBoundingClientRect()
const centerY = bounds.top + bounds.height / 2
return (
centerY >= viewBounds.y && centerY <= viewBounds.y + viewBounds.height
)
})
if (!line) return { visible: false, xNormalized: null }
const bounds = line.getBoundingClientRect()
const x = bounds.left + bounds.width / 2
return {
visible: true,
xNormalized: (x - viewBounds.x) / viewBounds.width,
}
}
function chartHeight(input: ConformanceInput) {
return Math.max(280, input.height - summaryHeight)
}
function sizeShell(
shell: HTMLElement,
chartFrame: HTMLElement,
input: ConformanceInput,
) {
shell.style.width = `${input.width}px`
shell.style.height = `${input.height}px`
chartFrame.style.width = `${input.width}px`
chartFrame.style.height = `${chartHeight(input)}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 }
}