From 9b405be9ff2bbcebd10bebfaceccc52708af9b16 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 14:20:13 -1000 Subject: [PATCH 1/4] feat(catalog): add Gantt recipe with nested phases and dependencies Add conformance case 198: phase header lanes with indented task lanes on scaleBand and scaleUtc, progress fills, a go-live milestone, a status-date rule, and right-angle finish-to-start arrows routed by a case-local d3 curve on link. Connectors, progress, and the rule are decorative, so keyboard focus stops on every phase, task, and milestone. Observable Plot reference uses the same routing curve. Document the recipe in Intervals and Financial Charts, register the case in the catalog index, previews, navigation, and definition coverage records, and log F-310, F-313, and F-314 in the API friction log. --- API-FRICTION.md | 64 +++++ PLAN.md | 5 +- .../conformance/DEFINITION-COVERAGE-AUDIT.md | 11 +- .../DEFINITION-COVERAGE-OVERVIEW.md | 9 +- .../cases/198-gantt-dependencies/case.json | 40 ++++ .../cases/198-gantt-dependencies/data.ts | 137 +++++++++++ .../cases/198-gantt-dependencies/example.tsx | 225 ++++++++++++++++++ .../cases/198-gantt-dependencies/plan.ts | 139 +++++++++++ .../cases/198-gantt-dependencies/plot.ts | 152 ++++++++++++ .../cases/198-gantt-dependencies/route.ts | 68 ++++++ .../198-gantt-dependencies/tanstack.test.ts | 23 ++ .../cases/198-gantt-dependencies/tanstack.ts | 8 + benchmarks/conformance/catalog-index.json | 56 +++++ .../definition-coverage-roadmap.json | 39 +++ .../definition-coverage-roadmap.test.ts | 20 +- .../previews/198-gantt-dependencies.svg | 1 + benchmarks/conformance/previews/manifest.json | 7 +- docs/config.json | 4 + docs/examples/intervals-and-financial.md | 27 +++ packages/charts-core/docs/config.json | 4 + .../docs/examples/intervals-and-financial.md | 27 +++ scripts/catalog-definition-shapes.test.mjs | 1 + 22 files changed, 1046 insertions(+), 21 deletions(-) create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/case.json create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/data.ts create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/example.tsx create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/plan.ts create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/plot.ts create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/route.ts create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts create mode 100644 benchmarks/conformance/cases/198-gantt-dependencies/tanstack.ts create mode 100644 benchmarks/conformance/previews/198-gantt-dependencies.svg diff --git a/API-FRICTION.md b/API-FRICTION.md index 1c43acbb..da530617 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -343,6 +343,9 @@ Each entry records: | F-304 | Gradient resources hid cross-renderer ownership | API | resolved | | F-305 | Categorical legend styling required a replacement renderer | API | resolved | | F-306 | Inferred consumer declarations could not name core types | Tooling | resolved | +| F-310 | Orthogonal dependency connectors need a hand-built curve | API | monitoring | +| F-313 | Keyboard focus cannot follow a dependency | API | monitoring | +| F-314 | Time-ranged bars cannot inset only their lane edges | API | monitoring | ## Findings @@ -9045,3 +9048,64 @@ Each entry records: with the pinned TypeScript compiler and typechecks the generated declarations as a downstream consumer. Packed runtime, React Native, and seven framework adapter gates pass. All 60 comparison bundle measurements are unchanged. + +### F-310 - 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-313 - 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: keyboard navigation steps through focusable points in scene order. + 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 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 test confirms one focus stop per phase, + task, and milestone, and none for connectors or progress fills, before and + after the revision update. + +### F-314 - 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 e5f1f3b1..d7fa4127 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-310, F-313). 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..f7c9779a --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx @@ -0,0 +1,225 @@ +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 { 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}` +} + +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. + 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, + }), + ), + rect(groups, { + id: 'group-summaries', + key: 'id', + x1: 'start', + x2: 'end', + y: 'lane', + fill: 'currentColor', + fillOpacity: 0.62, + 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, + 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..8aad8a91 --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts @@ -0,0 +1,152 @@ +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 + 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.62, + 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..fe4420bf --- /dev/null +++ b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts @@ -0,0 +1,23 @@ +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) + }, + ) +}) 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..6796958d --- /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 bc8af86a..8088da79 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "4b5e1de199530168188592df376662b15aa5c570dc24bc742e761d312e5fe141", + "sourceHash": "ea9ef3e393954c9cdb731a542dc06470c7f23c554bc0e59ce7c9ebfdff1b2f28", "assets": [ { "id": "01-line-gaps", @@ -943,6 +943,11 @@ "id": "197-shadcn-tooltip-label-none", "sha256": "af6631dd0b4e88a936d9cff1ae28a90b549ef6c2d72513b90e9dadb80d4f437a", "bytes": 5019 + }, + { + "id": "198-gantt-dependencies", + "sha256": "c9cad49c2dfc65eba4ac22b293d13430b920ef148a1e1c981e972d80a6dfa651", + "bytes": 11284 } ] } diff --git a/docs/config.json b/docs/config.json index 34c4418a..bf680b2e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1059,6 +1059,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..23d7ae44 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,32 @@ 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. 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 34c4418a..bf680b2e 100644 --- a/packages/charts-core/docs/config.json +++ b/packages/charts-core/docs/config.json @@ -1059,6 +1059,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..23d7ae44 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,32 @@ 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. 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', From b03bbbbde353680580c18cf64478416e2edaf467 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:11:20 -1000 Subject: [PATCH 2/4] docs(friction): renumber Gantt recipe findings to F-307 through F-309 --- API-FRICTION.md | 12 ++++++------ PLAN.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index da530617..16fed060 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -343,9 +343,9 @@ Each entry records: | F-304 | Gradient resources hid cross-renderer ownership | API | resolved | | F-305 | Categorical legend styling required a replacement renderer | API | resolved | | F-306 | Inferred consumer declarations could not name core types | Tooling | resolved | -| F-310 | Orthogonal dependency connectors need a hand-built curve | API | monitoring | -| F-313 | Keyboard focus cannot follow a dependency | API | monitoring | -| F-314 | Time-ranged bars cannot inset only their lane edges | API | monitoring | +| F-307 | Orthogonal dependency connectors need a hand-built curve | API | monitoring | +| F-308 | Keyboard focus cannot follow a dependency | API | monitoring | +| F-309 | Time-ranged bars cannot inset only their lane edges | API | monitoring | ## Findings @@ -9049,7 +9049,7 @@ Each entry records: as a downstream consumer. Packed runtime, React Native, and seven framework adapter gates pass. All 60 comparison bundle measurements are unchanged. -### F-310 - Orthogonal dependency connectors need a hand-built curve +### F-307 - Orthogonal dependency connectors need a hand-built curve - Status: monitoring - Severity: medium @@ -9074,7 +9074,7 @@ Each entry records: right-angle connectors in both renderers. The same curve factory drives the Observable Plot reference. -### F-313 - Keyboard focus cannot follow a dependency +### F-308 - Keyboard focus cannot follow a dependency - Status: monitoring - Severity: low @@ -9092,7 +9092,7 @@ Each entry records: task, and milestone, and none for connectors or progress fills, before and after the revision update. -### F-314 - Time-ranged bars cannot inset only their lane edges +### F-309 - Time-ranged bars cannot inset only their lane edges - Status: monitoring - Severity: low diff --git a/PLAN.md b/PLAN.md index d7fa4127..ac4aa5aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2493,7 +2493,7 @@ The third tranche pressures composition and performance: 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-310, F-313). + (F-307, F-308). 3. General 100k/1m-row render, update, streaming, decimation, and lifecycle pressure is implemented; pan, zoom, and cursor-specific large-data pressure remains pending. From e31e62ae35c69bf70176c9c45dcbb289e8021b4e Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:26:38 -1000 Subject: [PATCH 3/4] fix(catalog): walk Gantt focus by lane and keep connectors visible Use a focusNearestY-based strategy whose navigation sorts by y then x so arrow keys follow the lane tree, and assert the order in the unit test. Outline phase summaries with a light fill so crossing connectors stay visible in both themes. Note the fixed margin assumption in the gutter estimate and correct F-308. --- API-FRICTION.md | 26 ++++++++++++------- .../cases/198-gantt-dependencies/example.tsx | 17 +++++++++++- .../cases/198-gantt-dependencies/plot.ts | 5 +++- .../198-gantt-dependencies/tanstack.test.ts | 14 ++++++++++ .../previews/198-gantt-dependencies.svg | 2 +- benchmarks/conformance/previews/manifest.json | 6 ++--- docs/examples/intervals-and-financial.md | 8 +++--- .../docs/examples/intervals-and-financial.md | 8 +++--- 8 files changed, 64 insertions(+), 22 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 16fed060..5d4825c0 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9081,16 +9081,22 @@ Each entry records: - Owner: API - Observed in: catalog case 198, a Gantt plan with finish-to-start dependencies -- Friction: keyboard navigation steps through focusable points in scene order. - 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 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 test confirms one focus stop per phase, - task, and milestone, and none for connectors or progress fills, before and - after the revision update. +- 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-309 - Time-ranged bars cannot inset only their lane edges diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx b/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx index f7c9779a..1799b20d 100644 --- a/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx +++ b/benchmarks/conformance/cases/198-gantt-dependencies/example.tsx @@ -10,6 +10,7 @@ import { 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' @@ -51,6 +52,15 @@ export function describePlanItem(item: PlanItem) { : `${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) @@ -68,6 +78,7 @@ export const createExampleChart = (input: ChartOptions) => { .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', @@ -117,6 +128,7 @@ export const createExampleChart = (input: ChartOptions) => { radius: 3, }), ), + // An outlined, lightly filled summary keeps crossing connectors visible. rect(groups, { id: 'group-summaries', key: 'id', @@ -124,7 +136,9 @@ export const createExampleChart = (input: ChartOptions) => { x2: 'end', y: 'lane', fill: 'currentColor', - fillOpacity: 0.62, + fillOpacity: 0.2, + stroke: 'currentColor', + strokeWidth: 1.5, inset: 0, radius: 2, }), @@ -207,6 +221,7 @@ export const createExampleChart = (input: ChartOptions) => { }, { keyboard: true, + focus: laneFocus, tooltip: { use: exampleTooltip, format: ({ datum }: ChartPoint) => describePlanItem(datum), diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts b/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts index 8aad8a91..470a3a87 100644 --- a/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts +++ b/benchmarks/conformance/cases/198-gantt-dependencies/plot.ts @@ -28,6 +28,7 @@ export const mount: ConformanceMount = (container, input) => 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, @@ -117,7 +118,9 @@ export const mount: ConformanceMount = (container, input) => x2: 'end', y: 'lane', fill: 'currentColor', - fillOpacity: 0.62, + fillOpacity: 0.2, + stroke: 'currentColor', + strokeWidth: 1.5, inset: 0, }), Plot.link( diff --git a/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts index fe4420bf..dd5f588f 100644 --- a/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts +++ b/benchmarks/conformance/cases/198-gantt-dependencies/tanstack.test.ts @@ -20,4 +20,18 @@ describe('Gantt dependency recipe', () => { 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/previews/198-gantt-dependencies.svg b/benchmarks/conformance/previews/198-gantt-dependencies.svg index 6796958d..2124aeab 100644 --- a/benchmarks/conformance/previews/198-gantt-dependencies.svg +++ b/benchmarks/conformance/previews/198-gantt-dependencies.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 8088da79..fef03528 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "ea9ef3e393954c9cdb731a542dc06470c7f23c554bc0e59ce7c9ebfdff1b2f28", + "sourceHash": "47d2927da721936995b3d211e171d063d0166387802732a6d5cf8ee6ad325ec5", "assets": [ { "id": "01-line-gaps", @@ -946,8 +946,8 @@ }, { "id": "198-gantt-dependencies", - "sha256": "c9cad49c2dfc65eba4ac22b293d13430b920ef148a1e1c981e972d80a6dfa651", - "bytes": 11284 + "sha256": "44e6763558c1b1cda02e4b4ed8abb8f7eae83caf303aa78ce418854e3d3d2aaa", + "bytes": 11404 } ] } diff --git a/docs/examples/intervals-and-financial.md b/docs/examples/intervals-and-financial.md index 23d7ae44..fb6339c7 100644 --- a/docs/examples/intervals-and-financial.md +++ b/docs/examples/intervals-and-financial.md @@ -58,9 +58,11 @@ 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. Name each task's -predecessors in its tooltip text, because the connectors themselves are not -focusable. +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 diff --git a/packages/charts-core/docs/examples/intervals-and-financial.md b/packages/charts-core/docs/examples/intervals-and-financial.md index 23d7ae44..fb6339c7 100644 --- a/packages/charts-core/docs/examples/intervals-and-financial.md +++ b/packages/charts-core/docs/examples/intervals-and-financial.md @@ -58,9 +58,11 @@ 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. Name each task's -predecessors in its tooltip text, because the connectors themselves are not -focusable. +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 From 116d53c57bfc753fea6a5ffcef94c586512325b5 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 15:08:17 -1000 Subject: [PATCH 4/4] docs: align Gantt plan friction references --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 178b6068..e1751455 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2493,7 +2493,7 @@ The third tranche pressures composition and performance: 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-307, F-308). + (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.