diff --git a/API-FRICTION.md b/API-FRICTION.md index dbcc9ffb..065e0ddf 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -351,6 +351,9 @@ Each entry records: | F-312 | Tick stubs could not be styled independently | API | resolved | | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | +| F-315 | Orthogonal dependency connectors need a hand-built curve | API | monitoring | +| F-316 | Keyboard focus cannot follow a dependency | API | monitoring | +| F-317 | Time-ranged bars cannot inset only their lane edges | API | monitoring | ## Findings @@ -9354,3 +9357,70 @@ Each entry records: direction changes. A focus dot appears during entrance while the data path and its running animation remain intact. Concurrent inline states remain a separate feature; this addresses the documented alternatives requested. + +### F-315 - Orthogonal dependency connectors need a hand-built curve + +- Status: monitoring +- Severity: medium +- Owner: API +- Observed in: catalog case 198, a Gantt plan with finish-to-start + dependencies +- Friction: `link` draws a straight segment and `arrow` draws a straight + arrow. A right-angle connector that leaves the predecessor's right edge, + steps, and enters the successor's left edge, plus its arrowhead, had to be + written as a d3 `CurveFactory` passed to the link's `curve` through + `d3Curve`. The curve sees only the two endpoint pixels. It cannot read the + resolved band step, so the definition estimates the lane gutter for backward + detours from the outer chart height. It cannot tell a bar from a milestone, + so dependencies into the milestone need a second link mark with a larger end + gap. +- Decision: keep the recipe. The public curve hook is enough and adds no bytes + to other charts. Consider a connector primitive with resolved band geometry + and target-aware end gaps only if a second timeline or network case repeats + this routing. +- Verification: case 198 passes the standard 320, 640, and 960 px light and + dark conformance matrix, before and after its revision update, with 13 + right-angle connectors in both renderers. The same curve factory drives the + Observable Plot reference. + +### F-316 - Keyboard focus cannot follow a dependency + +- Status: monitoring +- Severity: low +- Owner: API +- Observed in: catalog case 198, a Gantt plan with finish-to-start + dependencies +- Friction: default keyboard navigation steps through focusable points by x + center, then y, so phase summaries interleave with tasks instead of + following the lane tree. Changing only the order requires a complete focus + strategy. Its `resolve` receives points but not the scene, so the default + geometry-aware pointer hit test (`findNearestPoint` needs the scene) cannot + be reused; the recipe spreads `focusNearestY` and replaces `navigation` with + a y-then-x sort. Decorative connectors correctly add no focus stops, but no + key moves from a task to its predecessor or successor. The recipe names each + task's predecessors in its tooltip text instead. +- Decision: accept the lane-order strategy and text description for this + recipe. Graph navigation would add interaction state to the default host, + which is outside the current stop boundary. Revisit with a second dependency + or network case. +- Verification: the case 198 unit tests confirm one focus stop per phase, + task, and milestone, none for connectors or progress fills, and a + navigation order that matches the lane order. + +### F-317 - Time-ranged bars cannot inset only their lane edges + +- Status: monitoring +- Severity: low +- Owner: API +- Observed in: catalog case 198, a Gantt plan with phase summary bars +- Friction: `rect` `inset` removes pixels from all four edges, so a thinner + phase summary bar inside its band lane would also shorten its time extent. + `barX` `inset` trims only the categorical edges, but `barX` rejects `Date` + fields for `x1` and `x2` on a UTC axis (TS2322). The recipe uses + full-height bars and band padding instead of thinner summary bars. +- Decision: keep full-height bars for now. Consider either `Date` endpoints + on `barX` or an axis-specific `rect` inset if another timeline case needs + bars thinner than their lane. +- Verification: a strict compile probe of `barX(rows, { x1: 'start', x2: +'end', y: 'lane' })` with `Date` fields fails with TS2322. Case 198 uses + `rect` with `inset: 0`. diff --git a/PLAN.md b/PLAN.md index 420ec0bd..e1751455 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2490,7 +2490,10 @@ The third tranche pressures composition and performance: 1. Multi-pane financial chart with synchronized crosshair, viewport, and logical-range navigation. -2. Gantt/resource timeline with nested groups and dependencies. +2. Gantt/resource timeline with nested groups and dependencies: recipe + implemented as conformance case 198 over existing marks; a reusable + connector primitive and dependency keyboard traversal remain pending + (F-315, F-316). 3. General 100k/1m-row render, update, streaming, decimation, and lifecycle pressure is implemented; pan, zoom, and cursor-specific large-data pressure remains pending. diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md index 0dd3a650..905999eb 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md @@ -2,7 +2,7 @@ Date: 2026-08-10 -Scope: all 188 catalog directories. The 67 cases previously classified as +Scope: all 189 catalog directories. The 67 cases previously classified as strict custom authoring, preparation review, or shell-only are reviewed beside the former 42-case definition-native control group from [the custom authoring audit](./CUSTOM-AUTHORING-AUDIT.md). That audit remains @@ -31,12 +31,12 @@ work: | Disposition | Cases | Meaning | | --------------------- | ------: | ------------------------------------------------------------------------ | -| Definition now | 135 | Current marks and eager transforms are sufficient | +| Definition now | 136 | Current marks and eager transforms are sufficient | | First-party primitive | 35 | Add a reusable mark, transform, layout, guide, or controlled signal | | Optional primitive | 15 | Keep a heavy dependency granular, but hide its layout DTOs and lifecycle | | Application boundary | 2 | The remaining work is product state, DOM layout, or data arrival | | Inline custom mark | 1 | The geometry is intentionally case-specific | -| **Total** | **188** | | +| **Total** | **189** | | A normal definition does not require every implementation algorithm to live in Charts core. D3 may implement an optional `sankeyDiagram`, `densityContour`, @@ -322,6 +322,7 @@ ownership boundary. | [195 — shadcn tooltip-label-custom](./cases/195-shadcn-tooltip-label-custom/example.tsx) | Definition now | The self-contained native TanStack Charts tooltip example reproduces the pinned shadcn variant and card presentation. | | [196 — shadcn tooltip-label-formatter](./cases/196-shadcn-tooltip-label-formatter/example.tsx) | Definition now | The self-contained native TanStack Charts tooltip example reproduces the pinned shadcn variant and card presentation. | | [197 — shadcn tooltip-label-none](./cases/197-shadcn-tooltip-label-none/example.tsx) | Definition now | The self-contained native TanStack Charts tooltip example reproduces the pinned shadcn variant and card presentation. | +| [198 — Gantt dependencies](./cases/198-gantt-dependencies/example.tsx) | Definition now | Native rect, dot, rule, and decorative link marks compose phase lanes, progress, a milestone, and the status date; a case-local d3 curve routes each link as a right-angle dependency arrow. | ## Reference evidence @@ -353,8 +354,8 @@ bundle. ## Delivery result -All 188 catalog directories now have one roadmap record and case-local -evidence. All 185 normal-definition cases are verified against their current +All 189 catalog directories now have one roadmap record and case-local +evidence. All 186 normal-definition cases are verified against their current boundary; only cases 85, 86, and 116 retain accepted application or bespoke geometry work. The roadmap validator compares its IDs with the live catalog directories so a new case cannot silently remain outside this review. diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md b/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md index c2a456c1..ef75f57d 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md @@ -2,19 +2,19 @@ Date: 2026-08-10 -The catalog has 188 case directories. 185 visualizations now use +The catalog has 189 case directories. 186 visualizations now use normal chart definitions. Cases 85 and 86 retain application shells, and Case 116 retains one deliberately bespoke inline mark. No other case-owned layout or renderer is an accepted endpoint. | Disposition | Cases | Result | | --------------------- | ------: | ------------------------------------------------- | -| Definition now | 135 | Normal definition | +| Definition now | 136 | Normal definition | | First-party primitive | 35 | Normal definition | | Optional primitive | 15 | Normal definition | | Application boundary | 2 | Accepted boundary | | Inline custom mark | 1 | Accepted boundary | -| **Total** | **188** | **185 normal definitions; 3 accepted boundaries** | +| **Total** | **189** | **186 normal definitions; 3 accepted boundaries** | ## Shared decisions @@ -63,7 +63,7 @@ presentation policy. ## Verification -- The roadmap validator compares all 188 roadmap and audit IDs with the live +- The roadmap validator compares all 189 roadmap and audit IDs with the live catalog directories, requires unique IDs, validates the capability DAG, and requires every verified or accepted case to cite evidence under its own directory. @@ -260,6 +260,7 @@ presentation policy. | [195-shadcn-tooltip-label-custom — Tooltip - Custom label](./cases/195-shadcn-tooltip-label-custom/example.tsx) | Definition now | `current-definition-api` | `example.tsx`, `case.json` | | [196-shadcn-tooltip-label-formatter — Tooltip - Label Formatter](./cases/196-shadcn-tooltip-label-formatter/example.tsx) | Definition now | `current-definition-api` | `example.tsx`, `case.json` | | [197-shadcn-tooltip-label-none — Tooltip - No Label](./cases/197-shadcn-tooltip-label-none/example.tsx) | Definition now | `current-definition-api` | `example.tsx`, `case.json` | +| [198-gantt-dependencies — Project Gantt with nested phases and dependencies](./cases/198-gantt-dependencies/example.tsx) | Definition now | `current-definition-api` | `example.tsx`, `route.ts`, `tanstack.test.ts` | | [bar-grouped — Grouped bars](./cases/bar-grouped/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | | [bar-horizontal-ranking — Horizontal ranking with long labels](./cases/bar-horizontal-ranking/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | | [bar-stacked — Stacked bars](./cases/bar-stacked/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/case.json b/benchmarks/conformance/cases/198-gantt-dependencies/case.json new file mode 100644 index 00000000..99e894e7 --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/case.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "height": 520, + "order": 1990, + "id": "198-gantt-dependencies", + "title": "Project Gantt with nested phases and dependencies", + "family": "interval", + "intent": "Show a small project plan as phase header lanes with indented task lanes, progress fills, a milestone, a status-date rule, and right-angle finish-to-start dependency arrows, with every task reachable by keyboard.", + "support": "composed", + "features": [ + "gantt chart", + "band lanes over a UTC time scale", + "phase summary lanes with indented child lanes", + "progress fill over each task", + "milestone marker", + "status-date reference rule", + "orthogonal finish-to-start dependency arrows", + "decorative connectors outside keyboard focus", + "keyboard task descriptions" + ], + "geometry": [ + { + "role": "rect", + "count": 24, + "maxCount": 24, + "rendererRoles": { "observable-plot": "bar" } + }, + { "role": "link", "count": 13, "maxCount": 13 }, + { "role": "dot", "count": 1, "maxCount": 1 }, + { "role": "rule", "count": 1 } + ], + "source": { + "title": "Highcharts Gantt dependencies between tasks", + "url": "https://www.highcharts.com/docs/gantt/gantt-task-dependencies" + }, + "ai": { + "create": "Create a responsive Gantt chart from typed project tasks with phase, dates, progress, and finish-to-start predecessors: phase header lanes followed by indented task lanes on a band scale, UTC time, progress fills, a milestone, a status-date rule, and right-angle dependency arrows that stay out of keyboard focus.", + "maintain": "Add a task and a predecessor to one phase without changing lane order rules, breaking the phase roll-up, adding focus stops for connectors, or routing any dependency segment diagonally." + } +} diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/data.ts b/benchmarks/conformance/cases/198-gantt-dependencies/data.ts new file mode 100644 index 00000000..f16b6d2e --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/data.ts @@ -0,0 +1,137 @@ +export const planGroups = ['Plan', 'Build', 'Launch'] as const + +export type PlanGroup = (typeof planGroups)[number] + +export interface PlanTask { + id: string + label: string + group: PlanGroup + start: Date + /** Equal to `start` for a milestone. */ + end: Date + /** Completed share of the scheduled duration, from 0 to 1. */ + progress: number + /** Finish-to-start predecessors by task id. */ + after: readonly string[] +} + +const day = (value: string) => new Date(`${value}T00:00:00Z`) + +const tasks = [ + ['research', 'User research', 'Plan', '2026-03-02', '2026-03-13', 1, []], + ['audit', 'Content audit', 'Plan', '2026-03-04', '2026-03-18', 1, []], + [ + 'sitemap', + 'Site map', + 'Plan', + '2026-03-19', + '2026-03-27', + 1, + ['research', 'audit'], + ], + [ + 'wireframes', + 'Wireframes', + 'Plan', + '2026-03-30', + '2026-04-10', + 0.7, + ['sitemap'], + ], + [ + 'visual', + 'Visual design', + 'Build', + '2026-03-30', + '2026-04-17', + 0.4, + ['sitemap'], + ], + ['cms', 'CMS setup', 'Build', '2026-03-23', '2026-04-10', 0.5, ['audit']], + [ + 'frontend', + 'Front end', + 'Build', + '2026-04-20', + '2026-05-15', + 0.1, + ['wireframes', 'visual'], + ], + [ + 'migration', + 'Content migration', + 'Build', + '2026-04-13', + '2026-05-08', + 0.2, + ['cms'], + ], + [ + 'qa', + 'QA and fixes', + 'Launch', + '2026-05-18', + '2026-05-29', + 0, + ['frontend', 'migration'], + ], + [ + 'accessibility', + 'Accessibility review', + 'Launch', + '2026-05-18', + '2026-05-26', + 0, + ['frontend'], + ], + [ + 'launch', + 'Go live', + 'Launch', + '2026-06-01', + '2026-06-01', + 0, + ['qa', 'accessibility'], + ], +] as const satisfies readonly (readonly [ + string, + string, + PlanGroup, + string, + string, + number, + readonly string[], +])[] + +/** Status dates for the initial plan and one revised weekly status update. */ +export const statusDates = [day('2026-04-15'), day('2026-04-22')] as const + +/** + * Returns the project plan as reported on one status date. The revision keeps + * every task identity and dependency, advances progress, and slips one task. + */ +export function planForRevision(revision: number): PlanTask[] { + const revised = revision % 2 === 1 + return tasks.map(([id, label, group, start, end, progress, after]) => ({ + id, + label, + group, + start: day(start), + end: revised && id === 'migration' ? day('2026-05-13') : day(end), + progress: revised ? revisedProgress(id, progress) : progress, + after, + })) +} + +export function statusDateForRevision(revision: number): Date { + return statusDates[revision % 2 === 1 ? 1 : 0] +} + +function revisedProgress(id: string, progress: number) { + if (id === 'wireframes') return 1 + if (id === 'visual') return 0.75 + if (id === 'cms') return 0.9 + if (id === 'frontend') return 0.2 + if (id === 'migration') return 0.3 + return progress +} diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx b/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx new file mode 100644 index 00000000..1799b20d --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx @@ -0,0 +1,240 @@ +import { Chart } from '@tanstack/charts/react/tooltip' +import { tooltip as exampleTooltip } from '@tanstack/charts/tooltip' + +import { + d3Curve, + defineChart, + dot, + link, + rect, + ruleX, + type ChartPoint, +} from '@tanstack/charts' +import { focusNearestY } from '@tanstack/charts/focus' +import { decorative } from '@tanstack/charts/mark/decorative' +import { scaleBand, scaleUtc } from 'd3-scale' +import { planForRevision, planGroups, statusDateForRevision } from './data' +import { planLayout } from './plan' +import { elbowRoute } from './route' +import type { PlanItem } from './plan' + +const groupColors = [ + 'var(--ts-chart-1, #2563eb)', + 'var(--ts-chart-2, #f97316)', + 'var(--ts-chart-3, #10b981)', +] +const milestoneRadius = 6 +const shortDate = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}) +const monthDay = new Intl.DateTimeFormat('en-US', { + month: 'numeric', + day: 'numeric', + timeZone: 'UTC', +}) +const percent = new Intl.NumberFormat('en-US', { style: 'percent' }) + +export interface ChartOptions { + revision: number +} + +export function describePlanItem(item: PlanItem) { + const after = item.after.length ? ` · after ${item.after.join(', ')}` : '' + if (item.kind === 'milestone') { + return `${item.label} milestone · ${shortDate.format(item.start)}${after}` + } + const dates = `${shortDate.format(item.start)} – ${shortDate.format(item.end)}` + const done = `${percent.format(item.progress)} complete` + return item.kind === 'group' + ? `${item.label} phase · ${dates} · ${done}` + : `${item.label} · ${item.group} · ${dates} · ${done}${after}` +} + +// Pointer focus snaps to the hovered lane. Arrow keys walk the lane tree from +// top to bottom instead of the default left-to-right order. +const laneFocus = { + ...focusNearestY, + navigation: ( + points: readonly TPoint[], + ) => [...points].sort((left, right) => left.y - right.y || left.x - right.x), +} + +export const createExampleChart = (input: ChartOptions) => { + const plan = planLayout(planForRevision(input.revision)) + const statusDate = statusDateForRevision(input.revision) + const tasks = plan.items.filter((item) => item.kind === 'task') + const groups = plan.items.filter((item) => item.kind === 'group') + const milestones = plan.items.filter((item) => item.kind === 'milestone') + const [domainStart, domainEnd] = plan.domain + + return defineChart( + ({ width, height }) => { + const narrow = width < 520 + const labelWidth = narrow ? 128 : 156 + const lanes = scaleBand() + .domain(plan.lanes) + .paddingInner(0.32) + .paddingOuter(0.2) + // Half of one lane step keeps backward connectors in the lane gutter. + // Assumes about 72 px of combined top and bottom margin for the x axis. + const gutter = Math.max(6, ((height - 72) / plan.lanes.length) * 0.5) + const connector = { + x1: 'from', + y1: 'fromLane', + x2: 'to', + y2: 'toLane', + key: 'id', + stroke: 'currentColor', + strokeOpacity: 0.6, + strokeWidth: 1.25, + } as const + + return { + marks: [ + decorative( + rect(groups, { + id: 'group-lanes', + key: 'id', + x1: () => domainStart, + x2: () => domainEnd, + y: 'lane', + fill: 'currentColor', + fillOpacity: 0.06, + inset: 0, + }), + ), + rect(tasks, { + id: 'tasks', + key: 'id', + x1: 'start', + x2: 'end', + y: 'lane', + color: 'group', + fillOpacity: 0.3, + inset: 0, + radius: 3, + }), + decorative( + rect(plan.progress, { + id: 'progress', + key: 'id', + x1: 'start', + x2: 'end', + y: 'lane', + color: 'group', + inset: 0, + radius: 3, + }), + ), + // An outlined, lightly filled summary keeps crossing connectors visible. + rect(groups, { + id: 'group-summaries', + key: 'id', + x1: 'start', + x2: 'end', + y: 'lane', + fill: 'currentColor', + fillOpacity: 0.2, + stroke: 'currentColor', + strokeWidth: 1.5, + inset: 0, + radius: 2, + }), + decorative( + link( + plan.dependencies.filter((entry) => !entry.toMilestone), + { + ...connector, + id: 'task-dependencies', + curve: d3Curve(elbowRoute({ gutter })), + }, + ), + ), + decorative( + link( + plan.dependencies.filter((entry) => entry.toMilestone), + { + ...connector, + id: 'milestone-dependencies', + curve: d3Curve( + elbowRoute({ gutter, endGap: milestoneRadius + 2 }), + ), + }, + ), + ), + dot(milestones, { + id: 'milestones', + key: 'id', + x: 'start', + y: 'lane', + color: 'group', + r: milestoneRadius, + stroke: 'currentColor', + strokeWidth: 1.5, + }), + decorative( + ruleX([statusDate], { + id: 'status-date', + stroke: 'currentColor', + strokeOpacity: 0.7, + strokeDasharray: '4 3', + }), + ), + ] as const, + scales: { + x: { + scale: scaleUtc().domain(plan.domain), + grid: true, + axis: { + ticks: { + count: Math.max(3, Math.floor((width - labelWidth) / 90)), + format: (value: Date) => + narrow ? monthDay.format(value) : shortDate.format(value), + }, + }, + }, + y: { + scale: lanes, + axis: { + line: false, + ticks: { size: 0, padding: 0 }, + tickLabels: { + anchor: 'start', + // Left-align the lane tree inside the fixed label column. + dx: ({ value }) => + (plan.groupLanes.has(value) ? 8 : 20) - labelWidth, + fontWeight: ({ value }) => + plan.groupLanes.has(value) ? 700 : 400, + opacity: ({ value }) => (plan.groupLanes.has(value) ? 1 : 0.8), + }, + }, + }, + }, + color: { + domain: planGroups, + range: groupColors, + }, + margin: { left: labelWidth }, + } + }, + { + keyboard: true, + focus: laneFocus, + tooltip: { + use: exampleTooltip, + format: ({ datum }: ChartPoint) => describePlanItem(datum), + }, + }, + ) +} + +export const exampleAriaLabel = + 'Website relaunch plan: three phases, ten tasks, a go-live milestone, and finish-to-start dependencies' + +export const chart = createExampleChart({ revision: 0 }) + +export default function Example() { + return +} diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/plan.ts b/benchmarks/conformance/cases/198-gantt-dependencies/plan.ts new file mode 100644 index 00000000..b14fa52d --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/plan.ts @@ -0,0 +1,139 @@ +import { planGroups } from './data' +import type { PlanGroup, PlanTask } from './data' + +/** One focusable schedule row: a group summary, a task, or a milestone. */ +export interface PlanItem { + id: string + label: string + kind: 'group' | 'task' | 'milestone' + group: PlanGroup + /** Band-scale lane. Group lanes use the group name; child lanes use the label. */ + lane: string + start: Date + end: Date + progress: number + /** Predecessor labels, for descriptions. */ + after: readonly string[] +} + +/** A completed share drawn over its task. */ +export interface ProgressSpan { + id: string + group: PlanGroup + lane: string + start: Date + end: Date +} + +/** One finish-to-start dependency from a predecessor's end to a successor's start. */ +export interface Dependency { + id: string + fromLane: string + toLane: string + from: Date + to: Date + toMilestone: boolean +} + +export interface PlanLayout { + lanes: string[] + groupLanes: ReadonlySet + items: PlanItem[] + progress: ProgressSpan[] + dependencies: Dependency[] + domain: [Date, Date] +} + +const dayMs = 86_400_000 + +/** + * Orders lanes as group header followed by its child rows, rolls child dates + * and duration-weighted progress into each group summary, and resolves every + * finish-to-start dependency into lane and date endpoints. + */ +export function planLayout(tasks: readonly PlanTask[]): PlanLayout { + const byId = new Map(tasks.map((task) => [task.id, task])) + const lanes: string[] = [] + const items: PlanItem[] = [] + + for (const group of planGroups) { + const children = tasks.filter((task) => task.group === group) + if (!children.length) continue + const start = new Date(Math.min(...children.map((task) => +task.start))) + const end = new Date(Math.max(...children.map((task) => +task.end))) + const duration = children.reduce((sum, task) => sum + span(task), 0) + const done = children.reduce( + (sum, task) => sum + span(task) * task.progress, + 0, + ) + lanes.push(group) + items.push({ + id: `group:${group}`, + label: group, + kind: 'group', + group, + lane: group, + start, + end, + progress: duration > 0 ? done / duration : 0, + after: [], + }) + for (const task of children) { + lanes.push(task.label) + items.push({ + id: task.id, + label: task.label, + kind: +task.start === +task.end ? 'milestone' : 'task', + group, + lane: task.label, + start: task.start, + end: task.end, + progress: task.progress, + after: task.after.map((id) => byId.get(id)?.label ?? id), + }) + } + } + + const progress = items + .filter((item) => item.kind === 'task' && item.progress > 0) + .map((item) => ({ + id: item.id, + group: item.group, + lane: item.lane, + start: item.start, + end: new Date(+item.start + (+item.end - +item.start) * item.progress), + })) + + const dependencies = tasks.flatMap((task) => + task.after.flatMap((id) => { + const predecessor = byId.get(id) + if (!predecessor) return [] + return [ + { + id: `${id}->${task.id}`, + fromLane: predecessor.label, + toLane: task.label, + from: predecessor.end, + to: task.start, + toMilestone: +task.start === +task.end, + }, + ] + }), + ) + + const first = Math.min(...tasks.map((task) => +task.start)) + const last = Math.max(...tasks.map((task) => +task.end)) + + return { + lanes, + groupLanes: new Set(planGroups), + items, + progress, + dependencies, + domain: [new Date(first - 3 * dayMs), new Date(last + 4 * dayMs)], + } +} + +function span(task: PlanTask) { + return Math.max(0, +task.end - +task.start) +} diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts b/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts new file mode 100644 index 00000000..470a3a87 --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts @@ -0,0 +1,155 @@ +import * as Plot from '@observablehq/plot' +import { planForRevision, planGroups, statusDateForRevision } from './data' +import { planLayout } from './plan' +import { elbowRoute } from './route' +import { mountObservablePlot } from '../../shared/mount' +import type { ConformanceMount } from '../../types' + +const groupColors = [ + 'var(--ts-chart-1, #2563eb)', + 'var(--ts-chart-2, #f97316)', + 'var(--ts-chart-3, #10b981)', +] +const milestoneRadius = 6 +const shortDate = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}) +const monthDay = new Intl.DateTimeFormat('en-US', { + month: 'numeric', + day: 'numeric', + timeZone: 'UTC', +}) + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => { + const plan = planLayout(planForRevision(nextInput.revision)) + const statusDate = statusDateForRevision(nextInput.revision) + const narrow = nextInput.width < 520 + const labelWidth = narrow ? 128 : 156 + // Assumes about 72 px of combined top and bottom margin for the x axis. + const gutter = Math.max( + 6, + ((nextInput.height - 72) / plan.lanes.length) * 0.5, + ) + const [domainStart, domainEnd] = plan.domain + const groups = plan.items.filter((item) => item.kind === 'group') + const connector = { + x1: 'from', + y1: 'fromLane', + x2: 'to', + y2: 'toLane', + stroke: 'currentColor', + strokeOpacity: 0.6, + strokeWidth: 1.25, + } as const + + return Plot.plot({ + width: nextInput.width, + height: nextInput.height, + ariaLabel: + 'Website relaunch plan: three phases, ten tasks, a go-live milestone, and finish-to-start dependencies', + marginLeft: labelWidth, + x: { + type: 'utc', + domain: plan.domain, + grid: true, + label: null, + ticks: Math.max(3, Math.floor((nextInput.width - labelWidth) / 90)), + tickFormat: (value: Date) => + narrow ? monthDay.format(value) : shortDate.format(value), + }, + y: { + type: 'band', + domain: plan.lanes, + paddingInner: 0.32, + paddingOuter: 0.2, + label: null, + axis: null, + }, + color: { domain: planGroups, range: groupColors }, + marks: [ + Plot.axisY( + plan.lanes.filter((lane) => plan.groupLanes.has(lane)), + { + tickSize: 0, + tickPadding: labelWidth - 8, + textAnchor: 'start', + fontWeight: 'bold', + }, + ), + Plot.axisY( + plan.lanes.filter((lane) => !plan.groupLanes.has(lane)), + { + tickSize: 0, + tickPadding: labelWidth - 20, + textAnchor: 'start', + }, + ), + Plot.barX(groups, { + x1: () => domainStart, + x2: () => domainEnd, + y: 'lane', + fill: 'currentColor', + fillOpacity: 0.06, + inset: 0, + }), + Plot.barX( + plan.items.filter((item) => item.kind === 'task'), + { + x1: 'start', + x2: 'end', + y: 'lane', + fill: 'group', + fillOpacity: 0.3, + inset: 0, + }, + ), + Plot.barX(plan.progress, { + x1: 'start', + x2: 'end', + y: 'lane', + fill: 'group', + inset: 0, + }), + Plot.barX(groups, { + x1: 'start', + x2: 'end', + y: 'lane', + fill: 'currentColor', + fillOpacity: 0.2, + stroke: 'currentColor', + strokeWidth: 1.5, + inset: 0, + }), + Plot.link( + plan.dependencies.filter((entry) => !entry.toMilestone), + { ...connector, curve: elbowRoute({ gutter }) }, + ), + Plot.link( + plan.dependencies.filter((entry) => entry.toMilestone), + { + ...connector, + curve: elbowRoute({ gutter, endGap: milestoneRadius + 2 }), + }, + ), + Plot.dot( + plan.items.filter((item) => item.kind === 'milestone'), + { + x: 'start', + y: 'lane', + fill: 'group', + r: milestoneRadius, + stroke: 'currentColor', + strokeWidth: 1.5, + }, + ), + Plot.ruleX([statusDate], { + stroke: 'currentColor', + strokeOpacity: 0.7, + strokeDasharray: '4 3', + }), + ], + }) + }) diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/route.ts b/benchmarks/conformance/cases/198-gantt-dependencies/route.ts new file mode 100644 index 00000000..aa9efa73 --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/route.ts @@ -0,0 +1,68 @@ +import type { CurveFactory } from 'd3-shape' + +export interface ElbowRouteOptions { + /** Horizontal pixels the connector leaves the predecessor before turning. */ + stub?: number + /** Pixels between the arrow tip and the successor's left edge. */ + endGap?: number + /** Vertical pixels from the successor's center to its lane gutter. */ + gutter?: number + /** Arrowhead length in pixels. */ + head?: number +} + +/** + * Routes a two-point finish-to-start dependency with right angles only. + * + * The connector exits the predecessor's right edge. When the successor starts + * far enough to the right, it drops beside the predecessor and enters the + * successor's left edge. Otherwise it runs back along the successor's lane + * gutter before entering from the left. Every segment is horizontal or + * vertical, and the arrowhead always points right into the successor. + */ +export function elbowRoute({ + stub = 8, + endGap = 2, + gutter = 12, + head = 5, +}: ElbowRouteOptions = {}): CurveFactory { + return (context) => { + let points: [number, number][] = [] + return { + areaStart() {}, + areaEnd() {}, + lineStart() { + points = [] + }, + point(x, y) { + points.push([x, y]) + }, + lineEnd() { + const first = points[0] + const last = points.at(-1) + if (!first || !last || points.length < 2) return + const [x0, y0] = first + const x1 = last[0] - endGap + const y1 = last[1] + const exit = x0 + stub + + context.moveTo(x0, y0) + if (x1 - exit >= stub) { + context.lineTo(exit, y0) + context.lineTo(exit, y1) + } else { + const lane = y1 - Math.sign(y1 - y0 || 1) * gutter + const entry = x1 - stub + context.lineTo(exit, y0) + context.lineTo(exit, lane) + context.lineTo(entry, lane) + context.lineTo(entry, y1) + } + context.lineTo(x1, y1) + context.moveTo(x1 - head, y1 - head * 0.8) + context.lineTo(x1, y1) + context.lineTo(x1 - head, y1 + head * 0.8) + }, + } + } +} diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts new file mode 100644 index 00000000..dd5f588f --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts @@ -0,0 +1,37 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { planForRevision } from './data' +import { planLayout } from './plan' +import { createExampleChart } from './tanstack' +import type { PlanItem } from './plan' + +describe('Gantt dependency recipe', () => { + it.each([0, 1])( + 'gives revision %s one keyboard stop per schedule row and none to connectors', + (revision) => { + const plan = planLayout(planForRevision(revision)) + const scene = createChartRuntime().render( + createExampleChart({ revision }), + { width: 640, height: 520 }, + ) + const focused = scene.points.map((point) => point.datum.id).sort() + + expect(focused).toEqual(plan.items.map((item) => item.id).sort()) + expect(scene.scales.y.domain).toEqual(plan.lanes) + }, + ) + + it('walks keyboard focus down the lane tree', () => { + const definition = createExampleChart({ revision: 0 }) + const scene = createChartRuntime().render(definition, { + width: 640, + height: 520, + }) + const focus = definition.focus + if (!focus || typeof focus !== 'object') throw new Error('missing focus') + + expect( + focus.navigation(scene.points).map((point) => point.datum.lane), + ).toEqual(planLayout(planForRevision(0)).lanes) + }) +}) diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.ts b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.ts new file mode 100644 index 00000000..6c029e65 --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.ts @@ -0,0 +1,8 @@ +import { createExampleChart, exampleAriaLabel } from './example' +import { tanstackExampleMount } from '../../shared/mount' + +export * from './example' + +export const mount = tanstackExampleMount(createExampleChart, exampleAriaLabel) + +export const catalogCase = mount diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 721ec1ca..f601e3d4 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -12407,6 +12407,62 @@ "entries": { "example": "benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx" } + }, + { + "schemaVersion": 1, + "height": 520, + "order": 1990, + "id": "198-gantt-dependencies", + "title": "Project Gantt with nested phases and dependencies", + "family": "interval", + "intent": "Show a small project plan as phase header lanes with indented task lanes, progress fills, a milestone, a status-date rule, and right-angle finish-to-start dependency arrows, with every task reachable by keyboard.", + "support": "composed", + "features": [ + "gantt chart", + "band lanes over a UTC time scale", + "phase summary lanes with indented child lanes", + "progress fill over each task", + "milestone marker", + "status-date reference rule", + "orthogonal finish-to-start dependency arrows", + "decorative connectors outside keyboard focus", + "keyboard task descriptions" + ], + "geometry": [ + { + "role": "rect", + "count": 24, + "maxCount": 24, + "rendererRoles": { + "observable-plot": "bar" + } + }, + { + "role": "link", + "count": 13, + "maxCount": 13 + }, + { + "role": "dot", + "count": 1, + "maxCount": 1 + }, + { + "role": "rule", + "count": 1 + } + ], + "source": { + "title": "Highcharts Gantt dependencies between tasks", + "url": "https://www.highcharts.com/docs/gantt/gantt-task-dependencies" + }, + "ai": { + "create": "Create a responsive Gantt chart from typed project tasks with phase, dates, progress, and finish-to-start predecessors: phase header lanes followed by indented task lanes on a band scale, UTC time, progress fills, a milestone, a status-date rule, and right-angle dependency arrows that stay out of keyboard focus.", + "maintain": "Add a task and a predecessor to one phase without changing lane order rules, breaking the phase roll-up, adding focus stops for connectors, or routing any dependency segment diagonally." + }, + "entries": { + "example": "benchmarks/conformance/cases/198-gantt-dependencies/example.tsx" + } } ] } diff --git a/benchmarks/conformance/definition-coverage-roadmap.json b/benchmarks/conformance/definition-coverage-roadmap.json index 793a2a3b..c23ce82c 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.json +++ b/benchmarks/conformance/definition-coverage-roadmap.json @@ -6164,6 +6164,45 @@ } ] }, + { + "id": "198-gantt-dependencies", + "coverage": "prepared", + "disposition": "definition-now", + "phase": "phase-0", + "status": "verified", + "capabilities": ["current-definition-api"], + "evidence": [ + "cases/198-gantt-dependencies/example.tsx", + "cases/198-gantt-dependencies/tanstack.test.ts", + "cases/198-gantt-dependencies/case.json" + ], + "work": [ + { + "kind": "data-space-layout", + "stage": "before-definition", + "owner": "case", + "coordinateSpace": "data", + "sources": ["plan.ts"], + "summary": "Order phase header lanes before their task lanes, roll task dates and duration-weighted progress into each phase, and resolve finish-to-start dependencies into lane and date endpoints." + }, + { + "kind": "responsive-pixel-layout", + "stage": "mark-render", + "owner": "case", + "coordinateSpace": "resolved-plot", + "sources": ["route.ts"], + "summary": "Route each dependency link through a case-local d3 curve that draws right-angle segments and an arrowhead from the link's two resolved endpoints." + }, + { + "kind": "definition-composition", + "stage": "definition-builder", + "owner": "charts", + "coordinateSpace": "none", + "sources": ["example.tsx"], + "summary": "Compose native rect, dot, ruleX, and decorative link marks on band lanes and UTC time, with keyboard focus limited to phases, tasks, and the milestone." + } + ] + }, { "id": "bar-grouped", "coverage": "declarative", diff --git a/benchmarks/conformance/definition-coverage-roadmap.test.ts b/benchmarks/conformance/definition-coverage-roadmap.test.ts index 225b783d..77845469 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.test.ts +++ b/benchmarks/conformance/definition-coverage-roadmap.test.ts @@ -140,12 +140,12 @@ describe('definition coverage roadmap', () => { .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - expect(auditedIds).toHaveLength(188) - expect(new Set(auditedIds).size).toBe(188) - expect(roadmapIds).toHaveLength(188) - expect(new Set(roadmapIds).size).toBe(188) - expect(overviewIds).toHaveLength(188) - expect(new Set(overviewIds).size).toBe(188) + expect(auditedIds).toHaveLength(189) + expect(new Set(auditedIds).size).toBe(189) + expect(roadmapIds).toHaveLength(189) + expect(new Set(roadmapIds).size).toBe(189) + expect(overviewIds).toHaveLength(189) + expect(new Set(overviewIds).size).toBe(189) expect([...roadmapIds].sort()).toEqual([...auditedIds].sort()) expect([...roadmapIds].sort()).toEqual([...overviewIds].sort()) expect([...roadmapIds].sort()).toEqual([...catalogIds].sort()) @@ -154,13 +154,13 @@ describe('definition coverage roadmap', () => { expect(countBy(roadmap.cases, 'disposition')).toEqual({ 'application-boundary': 2, - 'definition-now': 135, + 'definition-now': 136, 'first-party-primitive': 35, 'inline-custom-mark': 1, 'optional-primitive': 15, }) expect(countBy(roadmap.cases, 'phase')).toEqual({ - 'phase-0': 137, + 'phase-0': 138, 'phase-1': 2, 'phase-2': 26, 'phase-3': 11, @@ -170,8 +170,8 @@ describe('definition coverage roadmap', () => { const roadmapById = new Map(roadmap.cases.map((entry) => [entry.id, entry])) const auditRows = coverageRows(audit, false) const overviewRows = coverageRows(overview, true) - expect(auditRows).toHaveLength(188) - expect(overviewRows).toHaveLength(188) + expect(auditRows).toHaveLength(189) + expect(overviewRows).toHaveLength(189) for (const row of auditRows) { expect(dispositionByLabel[row.disposition]).toBe( roadmapById.get(row.id)?.disposition, diff --git a/benchmarks/conformance/previews/198-gantt-dependencies.svg b/benchmarks/conformance/previews/198-gantt-dependencies.svg new file mode 100644 index 00000000..2124aeab --- /dev/null +++ b/benchmarks/conformance/previews/198-gantt-dependencies.svg @@ -0,0 +1 @@ + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 9b352e03..b9a05cde 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "e9da35fe64694acbe80a296e2b848ce6904a0778cb545edb87a5e94cff8b9396", + "sourceHash": "f79b8b8343174e5569667c35f2f555d89c70d2bac5bec3e26076d523baf0dfe8", "assets": [ { "id": "01-line-gaps", @@ -943,6 +943,11 @@ "id": "197-shadcn-tooltip-label-none", "sha256": "4968486648c607560e8b47274b3fcd628134c2f9453a0d09d9362861e0d011bf", "bytes": 5019 + }, + { + "id": "198-gantt-dependencies", + "sha256": "44e6763558c1b1cda02e4b4ed8abb8f7eae83caf303aa78ce418854e3d3d2aaa", + "bytes": 11404 } ] } diff --git a/docs/config.json b/docs/config.json index 87ed2e2d..d612a0fe 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1071,6 +1071,10 @@ { "label": "shadcn Tooltip - No Label", "to": "/charts/catalog/charts/197-shadcn-tooltip-label-none/" + }, + { + "label": "Project Gantt with nested phases and dependencies", + "to": "/charts/catalog/charts/198-gantt-dependencies/" } ] }, diff --git a/docs/examples/intervals-and-financial.md b/docs/examples/intervals-and-financial.md index baae028f..fb6339c7 100644 --- a/docs/examples/intervals-and-financial.md +++ b/docs/examples/intervals-and-financial.md @@ -13,6 +13,7 @@ force intervals through a point-value channel. | Reader question | Start with | | ------------------------------------------------------------- | ----------------------------------------------------- | | How did each trading day move from open to close? | Horizontal price interval | +| Which tasks block which, and how far along is each phase? | Gantt lanes with dependency arrows | | How uncertain is each point estimate? | Point plus low-high error bar | | What were open, high, low, and close for each period? | Candlestick | | How does a percentile range evolve over time? | Quantile ribbon plus median line | @@ -35,6 +36,34 @@ Keep trading dates stable and lane order explicit. Date labels rely on automatic guide measurement; verify them at the smallest supported width with [Responsive Charts](../guides/responsive-charts.md). +## Plan a project with dependencies + +A Gantt chart puts each task on its own lane and its start and end on a time +axis. This plan groups ten tasks under three phase header lanes, fills each +task's completed share, marks the go-live milestone, and draws the status date +as a dashed rule. + + + +Derive the lane order, phase roll-ups, progress spans, and dependency +endpoints in an ordinary module before the definition. Lanes are a +`scaleBand` domain, so a phase lane and its task lanes keep their order; the +y-axis `tickLabels` options indent task labels and bold the phase labels. + +Each finish-to-start dependency is a `link` from the predecessor's end to the +successor's start. The link's `curve` receives the two resolved pixel points, +so a small d3 curve factory passed through `d3Curve` draws right-angle +segments and an arrowhead. It leaves the predecessor's right edge and enters +the successor's left edge. When the successor starts too soon for a direct +step, the connector runs back through the lane gutter first. + +Wrap connectors, progress fills, and the status rule in `decorative` so +keyboard focus stops only on phases, tasks, and the milestone. Arrow keys +step left to right by default; a `focus` strategy that spreads `focusNearestY` +and sorts `navigation` by y, then x, walks the lane tree from top to bottom. +Name each task's predecessors in its tooltip text, because the connectors +themselves are not focusable. + ## Preserve uncertainty bounds An error bar combines a point estimate, a low-high link, and endpoint ticks. diff --git a/packages/charts-core/docs/config.json b/packages/charts-core/docs/config.json index 87ed2e2d..d612a0fe 100644 --- a/packages/charts-core/docs/config.json +++ b/packages/charts-core/docs/config.json @@ -1071,6 +1071,10 @@ { "label": "shadcn Tooltip - No Label", "to": "/charts/catalog/charts/197-shadcn-tooltip-label-none/" + }, + { + "label": "Project Gantt with nested phases and dependencies", + "to": "/charts/catalog/charts/198-gantt-dependencies/" } ] }, diff --git a/packages/charts-core/docs/examples/intervals-and-financial.md b/packages/charts-core/docs/examples/intervals-and-financial.md index baae028f..fb6339c7 100644 --- a/packages/charts-core/docs/examples/intervals-and-financial.md +++ b/packages/charts-core/docs/examples/intervals-and-financial.md @@ -13,6 +13,7 @@ force intervals through a point-value channel. | Reader question | Start with | | ------------------------------------------------------------- | ----------------------------------------------------- | | How did each trading day move from open to close? | Horizontal price interval | +| Which tasks block which, and how far along is each phase? | Gantt lanes with dependency arrows | | How uncertain is each point estimate? | Point plus low-high error bar | | What were open, high, low, and close for each period? | Candlestick | | How does a percentile range evolve over time? | Quantile ribbon plus median line | @@ -35,6 +36,34 @@ Keep trading dates stable and lane order explicit. Date labels rely on automatic guide measurement; verify them at the smallest supported width with [Responsive Charts](../guides/responsive-charts.md). +## Plan a project with dependencies + +A Gantt chart puts each task on its own lane and its start and end on a time +axis. This plan groups ten tasks under three phase header lanes, fills each +task's completed share, marks the go-live milestone, and draws the status date +as a dashed rule. + + + +Derive the lane order, phase roll-ups, progress spans, and dependency +endpoints in an ordinary module before the definition. Lanes are a +`scaleBand` domain, so a phase lane and its task lanes keep their order; the +y-axis `tickLabels` options indent task labels and bold the phase labels. + +Each finish-to-start dependency is a `link` from the predecessor's end to the +successor's start. The link's `curve` receives the two resolved pixel points, +so a small d3 curve factory passed through `d3Curve` draws right-angle +segments and an arrowhead. It leaves the predecessor's right edge and enters +the successor's left edge. When the successor starts too soon for a direct +step, the connector runs back through the lane gutter first. + +Wrap connectors, progress fills, and the status rule in `decorative` so +keyboard focus stops only on phases, tasks, and the milestone. Arrow keys +step left to right by default; a `focus` strategy that spreads `focusNearestY` +and sorts `navigation` by y, then x, walks the lane tree from top to bottom. +Name each task's predecessors in its tooltip text, because the connectors +themselves are not focusable. + ## Preserve uncertainty bounds An error bar combines a point estimate, a low-high link, and endpoint ticks. diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 319683ac..e58827a6 100644 --- a/scripts/catalog-definition-shapes.test.mjs +++ b/scripts/catalog-definition-shapes.test.mjs @@ -22,6 +22,7 @@ const responsiveDefinitions = [ '161-shadcn-line-step/example.tsx', '181-shadcn-radar-label-custom/example.tsx', '186-shadcn-radial-label/example.tsx', + '198-gantt-dependencies/example.tsx', '29-waterfall/example.tsx', '85-scrollable-resource-lanes/example.tsx', '92-editable-event-range/example.tsx',