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