Source

712 chart lines · 5 files · 19.9 kB

cases/85-scrollable-resource-lanes/tanstack.ts353 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,
  sizeResourceTimelineShell,
  timelineBodyHeight,
  timelineChartHeight,
  timelineContentWidth,
  timelineLaneRailWidth,
  timelineMargin,
  updateTimelineTaskDetails,
} from './shell'
import {
  resourceLanes,
  resourceTasks,
  resourceTimelineDomain,
  timelineStatuses,
} from './scenario'
import { timelineDateKey } from './model'
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
const focusScrollPadding = 32

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

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

  return 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,
    }
  })
}

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 = (points: readonly ChartPoint<ResourceTask>[]) => {
    const point = points[0] ?? 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> => ({
    definition: defineChart(definition(nextInput), {
      animate: 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,
          )}`,
      },
    }),
    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.',
    onFocusGroupChange: updateFocusedTask,
  })
  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>,
  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>,
  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 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 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',
  })
}
cases/85-scrollable-resource-lanes/colors.ts7 lines · support
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 · support
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 · support
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.ts257 lines · support
cases/85-scrollable-resource-lanes/shell.ts
import { scaleBand } from 'd3-scale'
import { timelineStatusColors } from './colors'
import { resourceLanes, timelineStatuses } from './scenario'
import type { ConformanceInput } from '../../types'
import type { ResourceTask } from './scenario'

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

const headerHeight = 42

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`
  renderLaneRail(shell.laneRail, timelineChartHeight(bodyHeight))
  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'
}

function createTimelineHeader(document: Document) {
  const header = document.createElement('div')
  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 renderLaneRail(rail: HTMLDivElement, height: number) {
  const scale = scaleBand<string>()
    .domain(resourceLanes)
    .range([timelineMargin.top, height - timelineMargin.bottom])
    .paddingInner(0.08)
    .paddingOuter(0.04)
  const document = rail.ownerDocument
  rail.replaceChildren(
    ...resourceLanes.map((lane) => {
      const label = document.createElement('span')
      label.dataset.conformanceLane = lane
      label.textContent = lane
      Object.assign(label.style, {
        position: 'absolute',
        top: `${(scale(lane) ?? 0) + scale.bandwidth() / 2}px`,
        left: '8px',
        right: '6px',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        transform: 'translateY(-50%)',
        whiteSpace: 'nowrap',
      })
      label.title = lane
      return label
    }),
  )
}

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',
  })
}