TanStack
Catalog

Scrollable resource timeline lanes

interaction

1,434 lines · 8 files · 37.9 kB

cases/85-scrollable-resource-lanes/colors.ts7 lines · dependency
cases/85-scrollable-resource-lanes/colors.ts
import type { TimelineStatus } from './scenario'

export const timelineStatusColors: Readonly<Record<TimelineStatus, string>> = {
  planned: '#64748b',
  active: '#2563eb',
  review: '#c2410c',
}
cases/85-scrollable-resource-lanes/model.ts3 lines · dependency
cases/85-scrollable-resource-lanes/model.ts
export function timelineDateKey(date: Date) {
  return date.toISOString().slice(0, 10)
}
cases/85-scrollable-resource-lanes/scenario.ts92 lines · dependency
cases/85-scrollable-resource-lanes/scenario.ts
export const resourceLanes = [
  'Design',
  'Infrastructure',
  'API',
  'Quality',
  'Docs',
] as const

export type ResourceLane = (typeof resourceLanes)[number]

export const timelineStatuses = ['planned', 'active', 'review'] as const

export type TimelineStatus = (typeof timelineStatuses)[number]

export interface ResourceTask {
  id: string
  resource: ResourceLane
  label: string
  start: Date
  end: Date
  status: TimelineStatus
}

const day = 86_400_000

export const resourceTimelineDomain: readonly [Date, Date] = [
  utcDay(1),
  utcDay(74),
]

const initialTasks: readonly ResourceTask[] = [
  task('design-plan', 'Design', 'Experience plan', 2, 11, 'planned'),
  task('design-system', 'Design', 'Interface system', 34, 49, 'active'),
  task(
    'infra-foundation',
    'Infrastructure',
    'Runtime foundation',
    4,
    16,
    'active',
  ),
  task(
    'infra-hardening',
    'Infrastructure',
    'Runtime hardening',
    55,
    68,
    'review',
  ),
  task('api-contract', 'API', 'Contract review', 1, 9, 'review'),
  task('api-build', 'API', 'Endpoint build', 27, 44, 'active'),
  task('quality-fixtures', 'Quality', 'Fixture suite', 7, 18, 'planned'),
  task('quality-release', 'Quality', 'Release checks', 46, 59, 'review'),
  task('docs-outline', 'Docs', 'Guide outline', 3, 14, 'planned'),
  task('docs-publish', 'Docs', 'Publish guides', 60, 72, 'active'),
]

export function resourceTasks(revision = 0): readonly ResourceTask[] {
  if (revision % 2 === 0) return initialTasks

  return initialTasks.map((row) => {
    if (row.id === 'api-build') {
      return { ...row, end: new Date(row.end.getTime() + day * 3) }
    }
    if (row.id === 'quality-release') {
      return { ...row, start: new Date(row.start.getTime() - day * 2) }
    }
    return row
  })
}

function task(
  id: string,
  resource: ResourceLane,
  label: string,
  startDay: number,
  endDay: number,
  status: TimelineStatus,
): ResourceTask {
  return {
    id,
    resource,
    label,
    start: utcDay(startDay),
    end: utcDay(endDay),
    status,
  }
}

function utcDay(dayOfYear: number) {
  return new Date(Date.UTC(2025, 0, dayOfYear))
}
cases/85-scrollable-resource-lanes/shell.ts277 lines · dependency
cases/85-scrollable-resource-lanes/shell.ts
import { timelineStatusColors } from './colors'
import { resourceLanes, timelineStatuses } from './scenario'
import type { ConformanceInput } from '../../types'
import type { ResourceLane, ResourceTask } from './scenario'

export const timelineMargin = {
  top: 18,
  right: 24,
  bottom: 50,
  left: 12,
} as const

const headerHeight = 42
const focusScrollPadding = 32

export interface ResourceTimelineShell {
  root: HTMLDivElement
  viewport: HTMLDivElement
  chartSurface: HTMLDivElement
  laneRail: HTMLDivElement
  schedule: HTMLUListElement
  taskDetails: HTMLOutputElement
}

export function createResourceTimelineShell(
  document: Document,
  input: ConformanceInput,
  rows: readonly ResourceTask[],
): ResourceTimelineShell {
  const root = document.createElement('div')
  root.style.display = 'grid'
  root.style.gridTemplateRows = `${headerHeight}px minmax(0, 1fr)`
  root.style.position = 'relative'

  const { header, taskDetails } = createTimelineHeader(document)
  const body = document.createElement('div')
  body.style.display = 'grid'
  body.style.minHeight = '0'

  const laneRail = document.createElement('div')
  laneRail.dataset.conformanceLaneRail = ''
  laneRail.setAttribute('aria-label', 'Resource lanes')
  Object.assign(laneRail.style, {
    position: 'relative',
    zIndex: '2',
    overflow: 'hidden',
    borderRight: '1px solid color-mix(in srgb, CanvasText 18%, transparent)',
    background: 'Canvas',
    color: 'CanvasText',
    font: '600 11px/1.15 system-ui, sans-serif',
  })

  const viewport = document.createElement('div')
  viewport.dataset.conformanceView = 'main'
  viewport.dataset.conformanceScrollViewport = ''
  viewport.setAttribute('role', 'region')
  viewport.setAttribute(
    'aria-label',
    'Scrollable resource schedule. Use horizontal scrolling to move through dates.',
  )
  viewport.tabIndex = 0
  Object.assign(viewport.style, {
    overflowX: 'auto',
    overflowY: 'hidden',
    overscrollBehaviorX: 'contain',
    position: 'relative',
    scrollbarGutter: 'stable',
  })

  const chartSurface = document.createElement('div')
  viewport.append(chartSurface)
  body.append(laneRail, viewport)

  const schedule = document.createElement('ul')
  schedule.setAttribute('aria-label', 'Task schedule details')
  Object.assign(schedule.style, {
    position: 'absolute',
    width: '1px',
    height: '1px',
    padding: '0',
    margin: '-1px',
    overflow: 'hidden',
    clip: 'rect(0, 0, 0, 0)',
    whiteSpace: 'nowrap',
    border: '0',
  })

  root.append(header, body, schedule)
  const shell = {
    root,
    viewport,
    chartSurface,
    laneRail,
    schedule,
    taskDetails,
  }
  sizeResourceTimelineShell(shell, input, rows)
  return shell
}

export function sizeResourceTimelineShell(
  shell: ResourceTimelineShell,
  input: ConformanceInput,
  rows: readonly ResourceTask[],
) {
  const railWidth = timelineLaneRailWidth(input.width)
  const bodyHeight = timelineBodyHeight(input.height)
  const viewportWidth = Math.max(1, input.width - railWidth)
  const body = shell.laneRail.parentElement
  if (body) body.style.gridTemplateColumns = `${railWidth}px minmax(0, 1fr)`

  shell.root.style.width = `${input.width}px`
  shell.root.style.height = `${input.height}px`
  shell.laneRail.style.width = `${railWidth}px`
  shell.laneRail.style.height = `${bodyHeight}px`
  shell.viewport.style.width = `${viewportWidth}px`
  shell.viewport.style.height = `${bodyHeight}px`
  shell.chartSurface.style.width = `${timelineContentWidth(viewportWidth)}px`
  shell.chartSurface.style.height = `${timelineChartHeight(bodyHeight)}px`
  renderSchedule(shell.schedule, rows)
}

export function timelineBodyHeight(height: number) {
  return Math.max(220, height - headerHeight)
}

export function timelineContentWidth(viewportWidth: number) {
  return Math.max(960, viewportWidth * 2)
}

export function timelineChartHeight(viewportHeight: number) {
  return Math.max(240, viewportHeight - 16)
}

export function timelineLaneRailWidth(width: number) {
  return Math.round(Math.max(96, Math.min(128, width * 0.28)))
}

export function updateTimelineTaskDetails(
  shell: ResourceTimelineShell,
  task: ResourceTask | null,
) {
  shell.taskDetails.textContent = task
    ? `${task.resource} · ${task.label} · ${task.status} · ${formatDate(
        task.start,
      )}${formatDate(task.end)}`
    : 'Scroll dates →'
  shell.taskDetails.title = task
    ? shell.taskDetails.textContent
    : 'Scroll horizontally through the schedule'
}

export function renderTimelineLaneRail(
  rail: HTMLDivElement,
  position: (lane: ResourceLane) => number | null,
) {
  const document = rail.ownerDocument
  rail.replaceChildren(
    ...resourceLanes.flatMap((lane) => {
      const centerY = position(lane)
      if (centerY === null || !Number.isFinite(centerY)) return []
      const label = document.createElement('span')
      label.dataset.conformanceLane = lane
      label.textContent = lane
      Object.assign(label.style, {
        position: 'absolute',
        top: `${centerY}px`,
        left: '8px',
        right: '6px',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        transform: 'translateY(-50%)',
        whiteSpace: 'nowrap',
      })
      label.title = lane
      return [label]
    }),
  )
}

export function ensureTimelineFocusVisible(
  viewport: HTMLDivElement,
  centerX: number,
) {
  const previous = viewport.scrollLeft
  const visibleStart = previous + focusScrollPadding
  const visibleEnd = previous + viewport.clientWidth - focusScrollPadding
  let next = previous
  if (centerX < visibleStart) {
    next = centerX - focusScrollPadding
  } else if (centerX > visibleEnd) {
    next = centerX - viewport.clientWidth + focusScrollPadding
  }
  viewport.scrollLeft = Math.max(
    0,
    Math.min(next, viewport.scrollWidth - viewport.clientWidth),
  )
  return Math.abs(viewport.scrollLeft - previous) > 1
}

function createTimelineHeader(document: Document) {
  const header = document.createElement('div')
  header.dataset.conformanceTimelineLegend = ''
  header.setAttribute('aria-label', 'Task status legend')
  Object.assign(header.style, {
    display: 'flex',
    alignItems: 'center',
    gap: '8px 12px',
    padding: '5px 10px',
    boxSizing: 'border-box',
    overflow: 'hidden',
    borderBottom: '1px solid color-mix(in srgb, CanvasText 14%, transparent)',
    background: 'color-mix(in srgb, Canvas 94%, CanvasText 6%)',
    color: 'CanvasText',
    font: '600 11px/1.2 system-ui, sans-serif',
    whiteSpace: 'nowrap',
  })

  for (const status of timelineStatuses) {
    const item = document.createElement('span')
    item.dataset.conformanceTimelineStatus = status
    item.style.display = 'inline-flex'
    item.style.alignItems = 'center'
    item.style.gap = '5px'
    const swatch = document.createElement('span')
    Object.assign(swatch.style, {
      width: '9px',
      height: '9px',
      borderRadius: '3px',
      background: timelineStatusColors[status],
    })
    const label = document.createElement('span')
    label.textContent = status[0]?.toUpperCase() + status.slice(1)
    item.append(swatch, label)
    header.append(item)
  }

  const taskDetails = document.createElement('output')
  taskDetails.dataset.conformanceOverflowCue = ''
  taskDetails.dataset.conformanceTimelineDetails = ''
  taskDetails.setAttribute('aria-live', 'polite')
  taskDetails.setAttribute('aria-atomic', 'true')
  taskDetails.textContent = 'Scroll dates →'
  Object.assign(taskDetails.style, {
    marginLeft: 'auto',
    overflow: 'hidden',
    opacity: '0.76',
    textOverflow: 'ellipsis',
  })
  header.append(taskDetails)
  return { header, taskDetails }
}

function renderSchedule(
  schedule: HTMLUListElement,
  rows: readonly ResourceTask[],
) {
  const document = schedule.ownerDocument
  schedule.replaceChildren(
    ...rows.map((row) => {
      const item = document.createElement('li')
      item.textContent = `${row.resource}: ${row.label}, ${row.status}, ${formatDate(
        row.start,
      )} through ${formatDate(row.end)}`
      return item
    }),
  )
}

function formatDate(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  })
}
cases/85-scrollable-resource-lanes/tanstack.ts356 lines · entry
cases/85-scrollable-resource-lanes/tanstack.ts
import { defineChart, mountChart, rect } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleBand, scaleUtc } from 'd3-scale'
import { timelineStatusColors } from './colors'
import {
  createResourceTimelineShell,
  ensureTimelineFocusVisible,
  renderTimelineLaneRail,
  sizeResourceTimelineShell,
  timelineBodyHeight,
  timelineChartHeight,
  timelineContentWidth,
  timelineLaneRailWidth,
  timelineMargin,
  updateTimelineTaskDetails,
} from './shell'
import {
  resourceLanes,
  resourceTasks,
  resourceTimelineDomain,
  timelineStatuses,
} from './scenario'
import { timelineDateKey } from './model'
import { tanstackCase } from '../../shared/mount'
import type {
  ChartHost,
  ChartPoint,
  ChartScene,
  ChartHostOptions,
} from '@tanstack/charts'
import type { ResourceTask } from './scenario'
import type {
  ConformanceGeometryQuery,
  ConformanceGeometrySample,
  ConformanceInput,
  ConformanceMount,
  ConformanceTarget,
  ConformanceTestDriver,
} from '../../types'

const taskInset = 5

interface TimelineFocusState {
  taskId: string | null
  centerX: number | null
  scrolled: boolean
}

export const resourceTimelineDefinition = (input: ConformanceInput) => {
  const rows = resourceTasks(input.revision)

  return defineChart(
    defineChart(({ width }) => {
      return {
        marks: [
          rect(rows, {
            x1: 'start',
            x2: 'end',
            y: 'resource',
            color: 'status',
            inset: taskInset,
            radius: 4,
            stroke: '#ffffff',
            strokeWidth: 1,
          }),
        ],
        x: {
          scale: scaleUtc().domain(resourceTimelineDomain),
          grid: true,
          axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } },
        },
        y: {
          scale: scaleBand<string>()
            .domain(resourceLanes)
            .paddingInner(0.08)
            .paddingOuter(0.04),
          grid: false,
          axis: false,
        },
        color: {
          domain: timelineStatuses,
          range: timelineStatuses.map((status) => timelineStatusColors[status]),
        },
        margin: timelineMargin,
      }
    }),
    {
      svgAnimation: false,
      keyboard: true,
      tooltip: {
        use: tooltip,
        format: (point) =>
          `${point.datum.resource} · ${point.datum.label} · ${
            point.datum.status
          } · ${formatTaskDate(point.datum.start)}${formatTaskDate(
            point.datum.end,
          )}`,
      },
    },
  )
}

export const catalogCase = tanstackCase(
  resourceTimelineDefinition,
  'Tasks scheduled across five resource lanes',
  {
    format: (point) =>
      `${point.datum.resource} · ${point.datum.label} · ${
        point.datum.status
      } · ${formatTaskDate(point.datum.start)}${formatTaskDate(
        point.datum.end,
      )}`,
  },
)

export const mount: ConformanceMount = (container, input) => {
  let currentInput = input
  const shell = createResourceTimelineShell(
    container.ownerDocument,
    input,
    resourceTasks(input.revision),
  )
  container.append(shell.root)
  const { viewport, chartSurface } = shell
  const focusState: TimelineFocusState = {
    taskId: null,
    centerX: null,
    scrolled: false,
  }

  const updateFocusedTask = (point: ChartPoint<ResourceTask> | null) => {
    focusState.taskId = point?.datum.id ?? null
    focusState.centerX = point?.x ?? null
    focusState.scrolled = point
      ? ensureTimelineFocusVisible(viewport, point.x)
      : false
    updateTimelineTaskDetails(shell, point?.datum ?? null)
  }

  const chartOptions = (
    nextInput: ConformanceInput,
  ): ChartHostOptions<ResourceTask, number, string> => ({
    definition: resourceTimelineDefinition(nextInput),
    width: timelineContentWidth(
      nextInput.width - timelineLaneRailWidth(nextInput.width),
    ),
    height: timelineChartHeight(timelineBodyHeight(nextInput.height)),
    ariaLabel: 'Tasks scheduled across five resource lanes',
    ariaDescription:
      'Focus the chart and use the arrow, Home, and End keys to inspect tasks. Offscreen tasks scroll into view.',
    onFocusChange: updateFocusedTask,
    onRender: ({ scene }) => {
      renderTimelineLaneRail(shell.laneRail, (lane) => scene.scales.y.map(lane))
    },
  })
  const host = mountChart(chartSurface, chartOptions(input))
  const driver = createDriver(
    viewport,
    chartSurface,
    () => currentInput,
    host,
    focusState,
  )

  return {
    driver,
    update(nextInput) {
      const scrollLeft = viewport.scrollLeft
      currentInput = nextInput
      sizeResourceTimelineShell(
        shell,
        nextInput,
        resourceTasks(nextInput.revision),
      )
      host.update(chartOptions(nextInput))
      viewport.scrollLeft = Math.min(
        scrollLeft,
        Math.max(0, viewport.scrollWidth - viewport.clientWidth),
      )
    },
    destroy() {
      host.destroy()
      shell.root.remove()
    },
  }
}

function createDriver(
  viewport: HTMLDivElement,
  chartSurface: HTMLDivElement,
  getInput: () => ConformanceInput,
  host: ChartHost<ResourceTask, number, string>,
  focusState: TimelineFocusState,
): ConformanceTestDriver {
  return {
    resolveTarget(target) {
      return timelineTarget(viewport, chartSurface, host, target)
    },
    readState() {
      return timelineState(viewport, getInput(), focusState)
    },
    geometry(query) {
      return timelineGeometry(
        viewport,
        chartSurface,
        getInput(),
        host.getScene(),
        query,
      )
    },
  }
}

function timelineTarget(
  viewport: HTMLDivElement,
  chartSurface: HTMLDivElement,
  host: ChartHost<ResourceTask, number, string>,
  target: ConformanceTarget,
) {
  if (target.view !== undefined && target.view !== 'main') {
    return null
  }
  if (target.anchor.startsWith('task:')) {
    const taskId = target.anchor.slice('task:'.length)
    const scene = host.getScene()
    const point = scene.points.find(
      (candidate) => candidate.datum.id === taskId,
    )
    const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
    if (!point || !svg) return null
    const bounds = svg.getBoundingClientRect()
    return {
      x: bounds.left + (point.x / scene.width) * bounds.width,
      y: bounds.top + (point.y / scene.height) * bounds.height,
      focusElement: svg,
    }
  }
  if (target.anchor !== 'viewport') return null
  const bounds = viewport.getBoundingClientRect()
  return {
    x: bounds.left + bounds.width / 2,
    y: bounds.top + bounds.height / 2,
    focusElement: viewport,
  }
}

function timelineState(
  viewport: HTMLDivElement,
  input: ConformanceInput,
  focusState: TimelineFocusState,
) {
  const rows = resourceTasks(input.revision)
  const apiBuild = rows.find((row) => row.id === 'api-build')
  const qualityRelease = rows.find((row) => row.id === 'quality-release')
  return {
    viewport: {
      scrollLeft: viewport.scrollLeft,
      clientWidth: viewport.clientWidth,
      scrollWidth: viewport.scrollWidth,
    },
    lanes: {
      count: resourceLanes.length,
      names: resourceLanes,
    },
    tasks: {
      count: rows.length,
      ids: rows.map((row) => row.id),
      apiBuildEnd: apiBuild ? timelineDateKey(apiBuild.end) : null,
      qualityReleaseStart: qualityRelease
        ? timelineDateKey(qualityRelease.start)
        : null,
    },
    domain: {
      start: timelineDateKey(resourceTimelineDomain[0]),
      end: timelineDateKey(resourceTimelineDomain[1]),
    },
    focus: {
      taskId: focusState.taskId,
      visible:
        focusState.centerX !== null &&
        focusState.centerX >= viewport.scrollLeft &&
        focusState.centerX <= viewport.scrollLeft + viewport.clientWidth,
      scrolled: focusState.scrolled,
    },
  }
}

function timelineGeometry(
  viewport: HTMLDivElement,
  chartSurface: HTMLDivElement,
  input: ConformanceInput,
  scene: ChartScene<ResourceTask>,
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (
    (query.view !== undefined && query.view !== 'main') ||
    query.role !== 'rect'
  ) {
    return []
  }
  const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (!svg) return []
  const svgBounds = svg.getBoundingClientRect()
  const viewportBounds = viewport.getBoundingClientRect()
  const scaleX = svgBounds.width / scene.width
  const scaleY = svgBounds.height / scene.height
  const height = Math.max(
    0,
    (scene.scales.y.bandwidth - taskInset * 2) * scaleY,
  )

  return resourceTasks(input.revision).flatMap((row) => {
    const x1 = scene.scales.x.map(row.start)
    const x2 = scene.scales.x.map(row.end)
    const centerY = scene.scales.y.map(row.resource)
    const sample = clipClientSample(
      {
        x: svgBounds.left + Math.min(x1, x2) * scaleX,
        y:
          svgBounds.top +
          (centerY - scene.scales.y.bandwidth / 2 + taskInset) * scaleY,
        width: Math.abs(x2 - x1) * scaleX,
        height,
        paint: timelineStatusColors[row.status],
      },
      viewportBounds,
    )
    return sample ? [sample] : []
  })
}

function clipClientSample(
  sample: ConformanceGeometrySample,
  viewport: DOMRect,
): ConformanceGeometrySample | null {
  const left = Math.max(sample.x, viewport.left)
  const top = Math.max(sample.y, viewport.top)
  const right = Math.min(sample.x + sample.width, viewport.right)
  const bottom = Math.min(sample.y + sample.height, viewport.bottom)
  if (right <= left || bottom <= top) return null
  return {
    x: left,
    y: top,
    width: right - left,
    height: bottom - top,
    paint: sample.paint,
  }
}

function formatTaskDate(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    timeZone: 'UTC',
  })
}
shared/mount.ts179 lines · dependency
shared/mount.ts
import {
  defineChart,
  isResponsiveChartDefinition,
  mountChart,
} from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import type {
  DomChartDefinition,
  ChartDefinitionOptions,
  ChartValue,
  ChartTooltipOptions,
} from '@tanstack/charts'
import type {
  ConformanceHandle,
  ConformanceInput,
  ConformanceMount,
} from '../types'
import { catalogPreviewDefinition, type CatalogPreviewOptions } from './preview'

export function mountObservablePlot(
  container: HTMLElement,
  input: ConformanceInput,
  render: (input: ConformanceInput) => HTMLElement | SVGSVGElement,
): ConformanceHandle {
  let element = render(input)
  container.append(element)

  return {
    update(nextInput) {
      const nextElement = render(nextInput)
      element.replaceWith(nextElement)
      element = nextElement
    },
    destroy() {
      element.remove()
    },
  }
}

export function tanstackMount<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  const mount: ConformanceMount = (container, input) => {
    const options = {
      definition: withConformanceBehavior(
        createDefinition(input),
        input,
        interactiveTooltip,
        previewOptions,
      ),
      width: input.width,
      height: input.height,
      ariaLabel,
    } as const
    const host = mountChart(container, options)
    applyCatalogPreviewFocus(host, input, previewOptions)

    return {
      update(nextInput) {
        host.update({
          ...options,
          definition: withConformanceBehavior(
            createDefinition(nextInput),
            nextInput,
            interactiveTooltip,
            previewOptions,
          ),
          width: nextInput.width,
          height: nextInput.height,
        })
        applyCatalogPreviewFocus(host, nextInput, previewOptions)
      },
      destroy() {
        host.destroy()
      },
    }
  }

  const catalogCase = Object.assign(mount, {
    createDefinition,
    ariaLabel,
    interactiveTooltip,
  })

  return Object.assign(catalogCase, { mount: catalogCase })
}

export interface TanStackConformanceCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  (container: HTMLElement, input: ConformanceInput): ConformanceHandle
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>
  ariaLabel: string
  interactiveTooltip: true | ChartTooltipOptions<TDatum>
  mount: ConformanceMount
}

export function tanstackCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  return tanstackMount(
    createDefinition,
    ariaLabel,
    interactiveTooltip,
    previewOptions,
  )
}

export function withConformanceBehavior<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  input: ConformanceInput,
  interactiveTooltip: true | ChartTooltipOptions<TDatum>,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  const presentation =
    input.preview === true
      ? catalogPreviewDefinition(definition, previewOptions)
      : definition
  const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
    svgAnimation: false,
    ...(input.interactive === true ||
    (input.preview === true && previewOptions.focus)
      ? {}
      : { focus: false }),
    keyboard: input.interactive === true,
    tooltip:
      input.interactive !== true
        ? false
        : interactiveTooltip === true
          ? tooltip
          : { use: tooltip, ...interactiveTooltip },
  }

  if (isResponsiveChartDefinition(presentation)) {
    return defineChart(presentation, behavior)
  }
  return defineChart(presentation, behavior)
}

function applyCatalogPreviewFocus<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  host: ReturnType<typeof mountChart<TDatum, TXValue, TYValue>>,
  input: ConformanceInput,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue>,
) {
  if (input.preview !== true || !options.focus) return
  host.interaction.setControlledFocus(options.focus(host.getScene(), input), {
    source: 'programmatic',
  })
}
shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
  ChartPoint,
  ChartScene,
  ChartValue,
  DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'

export interface CatalogPreviewOptions<
  TDatum = unknown,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  /** Keep the source definition's Cartesian axes and grid. */
  guides?: boolean
  /** Keep the source definition's color legend. */
  legend?: boolean
  /** Keep the source definition's authored or automatic margins. */
  margin?: boolean
  /** Paint one deterministic source point through the chart's focus strategy. */
  focus?: (
    scene: ChartScene<TDatum, TXValue, TYValue>,
    input: ConformanceInput,
  ) => ChartPoint<TDatum, TXValue, TYValue> | null
}

export function catalogPreviewDefinition<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  if (isResponsiveChartDefinition(definition)) {
    return {
      ...definition,
      chart(context) {
        const spec = definition.chart(context)
        const color = previewColor(spec.color, options.legend === true)
        return {
          ...spec,
          ...(options.guides === true ? {} : { guides: false }),
          ...(options.margin === true ? {} : { margin: 0 }),
          ...(color ? { color } : {}),
        }
      },
    }
  }

  const color = previewColor(definition.color, options.legend === true)
  return {
    ...definition,
    ...(options.guides === true ? {} : { guides: false }),
    ...(options.margin === true ? {} : { margin: 0 }),
    ...(color ? { color } : {}),
  }
}

function previewColor<TColor extends { legend?: unknown }>(
  color: TColor | undefined,
  keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
  if (!color || keepLegend) return color
  const { legend: _legend, ...withoutLegend } = color
  return withoutLegend
}

export function samplePreviewData<TDatum>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limit: number,
  accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
  if (input.preview !== true || data.length <= limit) return data

  const selected = new Set<number>()
  const slots = Math.max(2, limit - accessors.length * 2)
  for (let slot = 0; slot < slots; slot += 1) {
    selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
  }

  for (const accessor of accessors) {
    let minimumIndex = -1
    let minimum = Number.POSITIVE_INFINITY
    let maximumIndex = -1
    let maximum = Number.NEGATIVE_INFINITY

    data.forEach((datum, index) => {
      const value = accessor(datum)
      if (value === null || value === undefined || !Number.isFinite(value)) {
        return
      }
      if (value < minimum) {
        minimum = value
        minimumIndex = index
      }
      if (value > maximum) {
        maximum = value
        maximumIndex = index
      }
    })

    if (minimumIndex >= 0) selected.add(minimumIndex)
    if (maximumIndex >= 0) selected.add(maximumIndex)
  }

  return data.filter((_datum, index) => selected.has(index))
}

export function samplePreviewSeries<TDatum, TSeries>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limitPerSeries: number,
  series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
  if (input.preview !== true) return data

  const indicesBySeries = new Map<TSeries, number[]>()
  data.forEach((datum, index) => {
    const key = series(datum)
    const indices = indicesBySeries.get(key) ?? []
    indices.push(index)
    indicesBySeries.set(key, indices)
  })

  const selected = new Set<number>()
  for (const indices of indicesBySeries.values()) {
    if (indices.length <= limitPerSeries) {
      indices.forEach((index) => selected.add(index))
      continue
    }
    for (let slot = 0; slot < limitPerSeries; slot += 1) {
      const index =
        indices[
          Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
        ]
      if (index !== undefined) selected.add(index)
    }
  }

  return data.filter((_datum, index) => selected.has(index))
}
types.ts376 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
  'observable-plot' | 'recharts' | 'echarts'

export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'

export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'

export type ConformanceGeometryRole =
  | 'arc'
  | 'area'
  | 'arrow'
  | 'bar'
  | 'cell'
  | 'contour'
  | 'delaunay'
  | 'density'
  | 'dot'
  | 'frame'
  | 'geo'
  | 'hexagon'
  | 'line'
  | 'link'
  | 'rect'
  | 'radar'
  | 'regression'
  | 'rule'
  | 'text'
  | 'tick'
  | 'vector'
  | 'voronoi'
  | 'waffle'

export interface ConformanceInput {
  width: number
  height: number
  revision: number
  interactive?: boolean
  /** Use lower-detail geometry suited to compact catalog cards. */
  preview?: boolean
  /** True only for semantic browser scenarios, not catalog or visual mounts. */
  behavior?: boolean
}

export interface ConformanceHandle {
  update: (input: ConformanceInput) => void
  driver?: ConformanceTestDriver
  destroy: () => void
}

export type ConformanceMount = (
  container: HTMLElement,
  input: ConformanceInput,
) => ConformanceHandle

export interface ConformanceGeometryExpectation {
  id?: string
  view?: string
  role: ConformanceGeometryRole
  count: number
  maxCount?: number
  rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}

export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'

export interface ConformanceGuideExpectation {
  id: string
  axis:
    | ConformanceAxis
    | (Record<'tanstack', ConformanceAxis> &
        Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
  sequence?: readonly string[]
  maxRepeat?: number
}

export type ConformanceJsonValue =
  | null
  | boolean
  | number
  | string
  | readonly ConformanceJsonValue[]
  | ConformanceJsonObject

export interface ConformanceJsonObject {
  readonly [key: string]: ConformanceJsonValue
}

export interface ConformanceTarget {
  view?: string
  anchor: string
}

export type ConformanceRenderedTarget =
  | {
      selector: string
      index?: number
      role?: never
      name?: never
      exact?: never
      root?: never
      page?: never
    }
  | {
      role: string
      name?: string
      exact?: boolean
      index?: number
      selector?: never
      root?: never
      page?: never
    }
  | {
      root: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      page?: never
    }
  | {
      page: true
      selector?: never
      role?: never
      name?: never
      exact?: never
      index?: never
      root?: never
    }

export interface ConformanceResolvedTarget {
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  x: number
  /** Viewport-relative client coordinate used by Playwright mouse input. */
  y: number
  /** Optional element to focus before a real Playwright keyboard action. */
  focusElement?: HTMLElement | SVGElement
}

export interface ConformanceGeometryQuery {
  view?: string
  role: ConformanceGeometryRole
}

export interface ConformanceGeometrySample {
  /** Viewport-relative client box, matching getBoundingClientRect coordinates. */
  x: number
  y: number
  width: number
  height: number
  paint?: string
}

export interface ConformanceTestDriver {
  /**
   * Benchmark-only semantic bridge. Case metadata names anchors; each renderer
   * resolves those anchors without exposing renderer-specific selectors.
   */
  resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
  readState: () => ConformanceJsonObject
  geometry?: (
    query: ConformanceGeometryQuery,
  ) => readonly ConformanceGeometrySample[]
  /**
   * Viewport-relative logical view bounds. Multi-grid renderers may expose
   * independent views without separate DOM roots.
   */
  viewBounds?: (view?: string) => ConformanceGeometrySample | null
  settle?: () => void | Promise<void>
}

export type ConformanceStateAssertion =
  | {
      path: string
      equals: ConformanceJsonValue
    }
  | {
      path: string
      includes: ConformanceJsonValue
    }
  | {
      path: string
      approx: number
      tolerance: number
    }

type ConformanceRenderedStringMatcher =
  | {
      equals: string | null
      includes?: never
    }
  | {
      includes: string
      equals?: never
    }

type ConformanceRenderedNumberMatcher =
  | {
      equals: number
      approx?: never
      tolerance?: never
      atLeast?: never
      atMost?: never
    }
  | {
      approx: number
      tolerance: number
      equals?: never
      atLeast?: never
      atMost?: never
    }
  | {
      atLeast: number
      equals?: never
      approx?: never
      tolerance?: never
      atMost?: never
    }
  | {
      atMost: number
      equals?: never
      approx?: never
      tolerance?: never
      atLeast?: never
    }

export type ConformanceRenderedAssertion =
  | ({
      target: ConformanceRenderedTarget
      property: 'count'
    } & ConformanceRenderedNumberMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'text'
    } & ConformanceRenderedStringMatcher)
  | ({
      target: ConformanceRenderedTarget
      property: 'attribute'
      attribute: string
    } & ConformanceRenderedStringMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'visible' | 'focused'
      equals: boolean
    }
  | ({
      target: ConformanceRenderedTarget
      property:
        | 'scrollLeft'
        | 'scrollTop'
        | 'scrollWidth'
        | 'scrollHeight'
        | 'clientWidth'
        | 'clientHeight'
        | 'width'
        | 'height'
    } & ConformanceRenderedNumberMatcher)
  | {
      target: ConformanceRenderedTarget
      property: 'contained'
      within?: ConformanceRenderedTarget
      tolerance?: number
      equals: true
    }

export type ConformanceInteractionStep =
  | {
      type: 'pointerMove'
      target: ConformanceTarget
      steps?: number
    }
  | {
      type: 'pointerDown'
      target: ConformanceTarget
    }
  | {
      type: 'pointerUp'
      target: ConformanceTarget
    }
  | {
      type: 'pointerCancel'
    }
  | {
      type: 'pointerLeave'
      view?: string
    }
  | {
      type: 'update'
      revision: number
    }
  | {
      type: 'click'
      target: ConformanceTarget
    }
  | {
      type: 'key'
      key: string
      target?: ConformanceTarget
    }
  | {
      type: 'drag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
    }
  | {
      type: 'wheel'
      target: ConformanceTarget
      deltaX?: number
      deltaY?: number
      steps?: number
      deltaMode?: 'pixel' | 'line' | 'page'
    }
  | {
      type: 'touchTap'
      target: ConformanceTarget
    }
  | {
      type: 'touchDrag'
      from: ConformanceTarget
      to: ConformanceTarget
      steps?: number
      cancel?: boolean
    }
  | {
      type: 'wait'
      durationMs: number
    }
  | {
      type: 'assert'
      assertions: readonly ConformanceStateAssertion[]
    }
  | {
      type: 'assertRendered'
      assertions: readonly ConformanceRenderedAssertion[]
    }
  | {
      type: 'screenshot'
      name: string
      view?: string
    }

export interface ConformanceInteractionScenario {
  id: string
  steps: readonly ConformanceInteractionStep[]
}

export interface ConformanceCaseMeta {
  schemaVersion: 1
  referenceRenderer?: ConformanceReferenceRenderer
  order: number
  id: string
  title: string
  family: string
  intent: string
  support: ConformanceSupport
  features: readonly string[]
  geometry: readonly ConformanceGeometryExpectation[]
  minimumGeometrySimilarity?: number
  guideAssertions?: readonly ConformanceGuideExpectation[]
  interactionScenarios?: readonly ConformanceInteractionScenario[]
  source: {
    title: string
    url: string
  }
  ai: {
    create: string
    maintain: string
  }
}

export interface ConformanceImplementationModule {
  mount: ConformanceMount
  /** Definition-only mount used by compact generated catalog previews. */
  catalogCase?: { mount: ConformanceMount }
}