TanStack
Catalog

Synchronized cursors across views

interaction

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 }
}