From c58395bb993a6c435da12a31fe0e9b3ed712ebab Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 10 Aug 2026 13:05:38 -0600 Subject: [PATCH 1/3] Add funnel chart example --- benchmarks/conformance/README.md | 4 +- .../cases/125-sales-funnel/case.json | 30 ++++++++ .../cases/125-sales-funnel/data.ts | 28 ++++++++ .../cases/125-sales-funnel/model.test.ts | 21 ++++++ .../cases/125-sales-funnel/model.ts | 70 +++++++++++++++++++ .../cases/125-sales-funnel/plot.ts | 43 ++++++++++++ .../cases/125-sales-funnel/tanstack.ts | 57 +++++++++++++++ .../conformance/previews/125-sales-funnel.svg | 1 + docs/examples/stacked-and-composition.md | 15 +++- llms.txt | 2 +- .../docs/examples/stacked-and-composition.md | 15 +++- packages/charts-core/llms.txt | 2 +- scripts/catalog-preview.mjs | 1 + 13 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 benchmarks/conformance/cases/125-sales-funnel/case.json create mode 100644 benchmarks/conformance/cases/125-sales-funnel/data.ts create mode 100644 benchmarks/conformance/cases/125-sales-funnel/model.test.ts create mode 100644 benchmarks/conformance/cases/125-sales-funnel/model.ts create mode 100644 benchmarks/conformance/cases/125-sales-funnel/plot.ts create mode 100644 benchmarks/conformance/cases/125-sales-funnel/tanstack.ts create mode 100644 benchmarks/conformance/previews/125-sales-funnel.svg diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index 05dac6ac..f0fbb4ef 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -197,7 +197,7 @@ comparison. ## Current scope -The executable corpus contains 110 paired cases: 76 sourced from Observable +The executable corpus contains 111 paired cases: 77 sourced from Observable Plot, 23 from Recharts, and 11 from Apache ECharts. It spans the common cartesian vocabulary plus the high-value catalog beyond it: @@ -207,7 +207,7 @@ cartesian vocabulary plus the high-value catalog beyond it: moving windows, Bollinger bands, contours, density contours, and hexbins; - normalized and wiggle stacks, Likert charts, waterfalls, difference fills, rankings, indexed lines, marginal distributions, ridgelines, violins, - Marimekko layouts, and waffles; + Marimekko layouts, funnels, and waffles; - pointer, grouped, and Voronoi-nearest tooltips; - pie, labeled pie, basic/centered/rounded/nested donuts, partial and needle gauges, single/comparative radar, numeric polar line/scatter, rose, radial diff --git a/benchmarks/conformance/cases/125-sales-funnel/case.json b/benchmarks/conformance/cases/125-sales-funnel/case.json new file mode 100644 index 00000000..f3e6a8be --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/case.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "order": 1210, + "id": "125-sales-funnel", + "title": "Sales conversion funnel", + "family": "composition", + "intent": "Show how 12,400 visitors narrow through leads, qualification, proposals, and closed sales while preserving every stage value.", + "support": "native", + "features": [ + "ordered funnel stages", + "native transposed areas", + "centered quantitative extents", + "separate keyed trapezoids", + "direct stage labels", + "raw stage values", + "responsive layout" + ], + "geometry": [ + { "role": "area", "count": 5, "maxCount": 5 }, + { "role": "text", "count": 5, "maxCount": 5 } + ], + "source": { + "title": "Bklit UI Funnel Chart", + "url": "https://bklit.com/docs/components/funnel-chart" + }, + "ai": { + "create": "Create a sales funnel from five raw stage values. Derive centered left and right endpoints for two boundaries per stage, render each stage as a separate native areaX trapezoid, and label every stage with its raw value.", + "maintain": "Keep stage order, semantic keys, proportional widths, centered extents, direct labels, raw values, and the tapered final edge stable across responsive sizes and data revisions." + } +} diff --git a/benchmarks/conformance/cases/125-sales-funnel/data.ts b/benchmarks/conformance/cases/125-sales-funnel/data.ts new file mode 100644 index 00000000..ba2c1c4a --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/data.ts @@ -0,0 +1,28 @@ +export interface FunnelStage { + id: string + label: string + value: number +} + +const revisions: readonly (readonly FunnelStage[])[] = [ + [ + { id: 'visitors', label: 'Visitors', value: 12_400 }, + { id: 'leads', label: 'Leads', value: 6_800 }, + { id: 'qualified', label: 'Qualified', value: 3_200 }, + { id: 'proposals', label: 'Proposals', value: 1_500 }, + { id: 'closed', label: 'Closed', value: 620 }, + ], + [ + { id: 'visitors', label: 'Visitors', value: 12_400 }, + { id: 'leads', label: 'Leads', value: 7_100 }, + { id: 'qualified', label: 'Qualified', value: 3_500 }, + { id: 'proposals', label: 'Proposals', value: 1_720 }, + { id: 'closed', label: 'Closed', value: 690 }, + ], +] + +export function funnelStagesForRevision( + revision: number, +): readonly FunnelStage[] { + return revisions[revision % revisions.length] ?? revisions[0] ?? [] +} diff --git a/benchmarks/conformance/cases/125-sales-funnel/model.test.ts b/benchmarks/conformance/cases/125-sales-funnel/model.test.ts new file mode 100644 index 00000000..c223dca4 --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/model.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { funnelStagesForRevision } from './data' +import { funnelLayout } from './model' + +describe('sales funnel layout', () => { + it('keeps every stage centered and connects adjacent widths', () => { + const stages = funnelStagesForRevision(0) + const layout = funnelLayout(stages) + + expect(layout.points).toHaveLength(stages.length * 2) + for (const [index, stage] of stages.entries()) { + const start = layout.points[index * 2] + const end = layout.points[index * 2 + 1] + expect(start?.x2).toBe(stage.value / 2) + expect(start?.x1).toBe(-stage.value / 2) + if (index < stages.length - 1) { + expect(end?.x2).toBe(stages[index + 1]!.value / 2) + } + } + }) +}) diff --git a/benchmarks/conformance/cases/125-sales-funnel/model.ts b/benchmarks/conformance/cases/125-sales-funnel/model.ts new file mode 100644 index 00000000..7184cf3a --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/model.ts @@ -0,0 +1,70 @@ +import type { FunnelStage } from './data' + +export interface FunnelPoint extends FunnelStage { + boundary: 'start' | 'end' + y: number + x1: number + x2: number +} + +export interface FunnelLabel extends FunnelStage { + x: number + y: number + text: string +} + +export interface FunnelLayout { + points: readonly FunnelPoint[] + labels: readonly FunnelLabel[] + xDomain: readonly [number, number] + yDomain: readonly [number, number] +} + +const segmentInset = 0.035 +const finalWidthRatio = 0.72 + +export function funnelLayout(stages: readonly FunnelStage[]): FunnelLayout { + const maximum = Math.max(0, ...stages.map((stage) => stage.value)) + const points = stages.flatMap((stage, index) => { + const nextValue = stages[index + 1]?.value ?? stage.value * finalWidthRatio + return [ + funnelPoint(stage, 'start', index + segmentInset, stage.value), + funnelPoint(stage, 'end', index + 1 - segmentInset, nextValue), + ] + }) + const labels = stages.map((stage, index) => ({ + ...stage, + x: maximum * 0.56, + y: index + 0.5, + text: `${stage.label} · ${compactNumber(stage.value)}`, + })) + + return { + points, + labels, + xDomain: [-maximum / 2, maximum * 0.96], + yDomain: [stages.length, 0], + } +} + +function funnelPoint( + stage: FunnelStage, + boundary: FunnelPoint['boundary'], + y: number, + width: number, +): FunnelPoint { + return { + ...stage, + boundary, + y, + x1: -width / 2, + x2: width / 2, + } +} + +function compactNumber(value: number): string { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value) +} diff --git a/benchmarks/conformance/cases/125-sales-funnel/plot.ts b/benchmarks/conformance/cases/125-sales-funnel/plot.ts new file mode 100644 index 00000000..bb41de71 --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/plot.ts @@ -0,0 +1,43 @@ +import * as Plot from '@observablehq/plot' +import { funnelStagesForRevision } from './data' +import { funnelLayout } from './model' +import { mountObservablePlot } from '../../shared/mount' +import type { ConformanceMount } from '../../types' + +const colors = ['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#60a5fa'] + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => { + const stages = funnelStagesForRevision(nextInput.revision) + const layout = funnelLayout(stages) + + return Plot.plot({ + width: nextInput.width, + height: nextInput.height, + ariaLabel: 'Sales conversion funnel', + margin: 12, + x: { domain: layout.xDomain, axis: null }, + y: { domain: layout.yDomain, axis: null }, + color: { domain: stages.map((stage) => stage.id), range: colors }, + marks: [ + Plot.areaX(layout.points, { + x1: 'x1', + x2: 'x2', + y: 'y', + z: 'id', + fill: 'id', + curve: 'linear', + }), + Plot.text(layout.labels, { + x: 'x', + y: 'y', + text: 'text', + textAnchor: 'start', + fill: 'currentColor', + fontSize: + nextInput.preview === true ? 8 : nextInput.width < 400 ? 10 : 12, + fontWeight: 600, + }), + ], + }) + }) diff --git a/benchmarks/conformance/cases/125-sales-funnel/tanstack.ts b/benchmarks/conformance/cases/125-sales-funnel/tanstack.ts new file mode 100644 index 00000000..14a856eb --- /dev/null +++ b/benchmarks/conformance/cases/125-sales-funnel/tanstack.ts @@ -0,0 +1,57 @@ +import { areaX, defineChart, text } from '@tanstack/charts' +import { scaleLinear } from 'd3-scale' +import { funnelStagesForRevision } from './data' +import { funnelLayout } from './model' +import { tanstackCase } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const colors = ['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#60a5fa'] +const firstStageValue = funnelStagesForRevision(0)[0]?.value ?? 1 + +export const salesFunnelDefinition = (input: ConformanceInput) => { + const stages = funnelStagesForRevision(input.revision) + const layout = funnelLayout(stages) + + return defineChart({ + marks: [ + areaX(layout.points, { + id: 'funnel-stages', + x1: 'x1', + x2: 'x2', + y: 'y', + z: 'id', + color: 'id', + key: (point) => `${point.id}:${point.boundary}`, + fillOpacity: 1, + }), + text(layout.labels, { + id: 'funnel-labels', + x: 'x', + y: 'y', + text: 'text', + key: 'id', + anchor: 'start', + fontSize: input.preview === true ? 8 : input.width < 400 ? 10 : 12, + fontWeight: 600, + }), + ], + x: { scale: scaleLinear().domain(layout.xDomain), axis: false }, + y: { scale: scaleLinear().domain(layout.yDomain), axis: false }, + color: { domain: stages.map((stage) => stage.id), range: colors }, + margin: 12, + }) +} + +export const catalogCase = tanstackCase( + salesFunnelDefinition, + 'Sales conversion funnel', + { + format: ({ datum }) => + `${datum.label} · ${datum.value.toLocaleString('en-US')} · ${Math.round( + (datum.value / firstStageValue) * 100, + )}% of visitors`, + }, + { margin: true }, +) + +export const mount = catalogCase.mount diff --git a/benchmarks/conformance/previews/125-sales-funnel.svg b/benchmarks/conformance/previews/125-sales-funnel.svg new file mode 100644 index 00000000..5db8b603 --- /dev/null +++ b/benchmarks/conformance/previews/125-sales-funnel.svg @@ -0,0 +1 @@ + diff --git a/docs/examples/stacked-and-composition.md b/docs/examples/stacked-and-composition.md index 50bcdb3d..8b5e17ed 100644 --- a/docs/examples/stacked-and-composition.md +++ b/docs/examples/stacked-and-composition.md @@ -1,6 +1,6 @@ --- title: Stacked and Composed Charts -description: Choose stacked, normalized, streamgraph, and mosaic compositions for part-to-whole comparisons. +description: Choose stacked, normalized, streamgraph, funnel, and mosaic compositions for part-to-whole and stage-retention comparisons. --- Stacked charts answer how a total divides into contributions. They work best @@ -21,6 +21,7 @@ values remain available elsewhere. | How does proportional mix change independently of the total? | Normalized 100% stack | | How does the overall shape of many positive series evolve? | Streamgraph | | How do two categorical part-to-whole dimensions interact? | Marimekko or mosaic | +| How much volume remains at each ordered conversion stage? | Funnel | | Which subgroup values must be compared precisely across groups? | Grouped bars or aligned small multiples | | Do contributions extend in positive and negative directions? | Diverging stack around an explicit zero | @@ -159,6 +160,18 @@ provide exact values through Use an ordinary stacked area when totals or baselines are part of the question. +## Show stage attrition + +A funnel encodes the remaining volume at each ordered stage. Use it when the +sequence is fixed and values decrease toward one outcome. Use bars when stages +can grow, reorder, or need precise comparison on a shared baseline. + + + +The example derives centered left and right endpoints from each raw stage value, +then uses one `areaX` trapezoid per stage. Keep the raw value available to the +tooltip and label; the narrowing shape alone is not precise enough for lookup. + ## Show shares as fixed units A waffle chart trades precise length comparison for countable, equal units. diff --git a/llms.txt b/llms.txt index e546bf53..cb2b658c 100644 --- a/llms.txt +++ b/llms.txt @@ -51,7 +51,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/examples/distributions.md — Distributions: Choose histograms, boxplots, empirical cumulative distributions, and violins for shape, spread, and rank. - docs/examples/heatmaps-and-densities.md — Heatmaps and Densities: Choose quantitative matrix cells, contours, or hexagonal bins to show concentration across two dimensions. - docs/examples/intervals-and-financial.md — Intervals and Financial Charts: Compose timelines, uncertainty intervals, candlesticks, and percentile ribbons from explicit endpoints. -- docs/examples/stacked-and-composition.md — Stacked and Composed Charts: Choose stacked, normalized, streamgraph, and mosaic compositions for part-to-whole comparisons. +- docs/examples/stacked-and-composition.md — Stacked and Composed Charts: Choose stacked, normalized, streamgraph, funnel, and mosaic compositions for part-to-whole and stage-retention comparisons. - docs/examples/facets-and-multiple-views.md — Facets and Multiple Views: Choose shared-scale facets, marginal views, and focus-plus-context layouts for coordinated comparisons. - docs/examples/networks-and-hierarchies.md — Networks and Hierarchies: Choose tidy trees, Sankey flows, spatial adjacency graphs, and force-directed networks for connected or nested data. - docs/examples/maps-and-spatial.md — Maps and Spatial Charts: Build choropleths, bubble maps, globes, routes, and vector fields for geographic or projected spatial questions. diff --git a/packages/charts-core/docs/examples/stacked-and-composition.md b/packages/charts-core/docs/examples/stacked-and-composition.md index 50bcdb3d..8b5e17ed 100644 --- a/packages/charts-core/docs/examples/stacked-and-composition.md +++ b/packages/charts-core/docs/examples/stacked-and-composition.md @@ -1,6 +1,6 @@ --- title: Stacked and Composed Charts -description: Choose stacked, normalized, streamgraph, and mosaic compositions for part-to-whole comparisons. +description: Choose stacked, normalized, streamgraph, funnel, and mosaic compositions for part-to-whole and stage-retention comparisons. --- Stacked charts answer how a total divides into contributions. They work best @@ -21,6 +21,7 @@ values remain available elsewhere. | How does proportional mix change independently of the total? | Normalized 100% stack | | How does the overall shape of many positive series evolve? | Streamgraph | | How do two categorical part-to-whole dimensions interact? | Marimekko or mosaic | +| How much volume remains at each ordered conversion stage? | Funnel | | Which subgroup values must be compared precisely across groups? | Grouped bars or aligned small multiples | | Do contributions extend in positive and negative directions? | Diverging stack around an explicit zero | @@ -159,6 +160,18 @@ provide exact values through Use an ordinary stacked area when totals or baselines are part of the question. +## Show stage attrition + +A funnel encodes the remaining volume at each ordered stage. Use it when the +sequence is fixed and values decrease toward one outcome. Use bars when stages +can grow, reorder, or need precise comparison on a shared baseline. + + + +The example derives centered left and right endpoints from each raw stage value, +then uses one `areaX` trapezoid per stage. Keep the raw value available to the +tooltip and label; the narrowing shape alone is not precise enough for lookup. + ## Show shares as fixed units A waffle chart trades precise length comparison for countable, equal units. diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index e546bf53..cb2b658c 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -51,7 +51,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/examples/distributions.md — Distributions: Choose histograms, boxplots, empirical cumulative distributions, and violins for shape, spread, and rank. - docs/examples/heatmaps-and-densities.md — Heatmaps and Densities: Choose quantitative matrix cells, contours, or hexagonal bins to show concentration across two dimensions. - docs/examples/intervals-and-financial.md — Intervals and Financial Charts: Compose timelines, uncertainty intervals, candlesticks, and percentile ribbons from explicit endpoints. -- docs/examples/stacked-and-composition.md — Stacked and Composed Charts: Choose stacked, normalized, streamgraph, and mosaic compositions for part-to-whole comparisons. +- docs/examples/stacked-and-composition.md — Stacked and Composed Charts: Choose stacked, normalized, streamgraph, funnel, and mosaic compositions for part-to-whole and stage-retention comparisons. - docs/examples/facets-and-multiple-views.md — Facets and Multiple Views: Choose shared-scale facets, marginal views, and focus-plus-context layouts for coordinated comparisons. - docs/examples/networks-and-hierarchies.md — Networks and Hierarchies: Choose tidy trees, Sankey flows, spatial adjacency graphs, and force-directed networks for connected or nested data. - docs/examples/maps-and-spatial.md — Maps and Spatial Charts: Build choropleths, bubble maps, globes, routes, and vector fields for geographic or projected spatial questions. diff --git a/scripts/catalog-preview.mjs b/scripts/catalog-preview.mjs index e9caeabc..cdb60c23 100644 --- a/scripts/catalog-preview.mjs +++ b/scripts/catalog-preview.mjs @@ -116,6 +116,7 @@ export const catalogTextPreviewCaseIds = [ '120-themed-interactive-area', '121-active-bar-dashboard', '123-active-donut-metric', + '125-sales-funnel', 'bar-horizontal-ranking', 'heatmap-labeled', ] From 37a0df178ea005f0836b6ba69e92ae2c2471e740 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 10 Aug 2026 18:33:20 -0600 Subject: [PATCH 2/3] Add drillable sunburst motion --- .changeset/drillable-sunburst-motion.md | 5 + API-FRICTION.md | 68 ++++ benchmarks/comparison/bundle-baseline.json | 8 +- .../conformance/DEFINITION-COVERAGE-AUDIT.md | 22 +- .../DEFINITION-COVERAGE-OVERVIEW.md | 14 +- benchmarks/conformance/README.md | 6 +- .../cases/125-sales-funnel/case.json | 2 +- .../cases/126-drillable-sunburst/case.json | 31 ++ .../cases/126-drillable-sunburst/center.ts | 126 +++++++ .../126-drillable-sunburst/model.test.ts | 43 +++ .../cases/126-drillable-sunburst/model.ts | 146 ++++++++ .../cases/126-drillable-sunburst/recharts.ts | 135 +++++++ .../126-drillable-sunburst/tanstack.test.ts | 354 ++++++++++++++++++ .../cases/126-drillable-sunburst/tanstack.ts | 239 ++++++++++++ benchmarks/conformance/catalog-index.json | 89 +++++ .../definition-coverage-roadmap.json | 67 +++- .../definition-coverage-roadmap.test.ts | 24 +- .../previews/126-drillable-sunburst.svg | 1 + benchmarks/conformance/previews/manifest.json | 12 +- docs/comparison.md | 10 +- docs/config.json | 8 + docs/examples/networks-and-hierarchies.md | 13 + docs/reference/marks/sunburst.md | 47 +++ packages/charts-core/docs/comparison.md | 10 +- packages/charts-core/docs/config.json | 8 + .../docs/examples/networks-and-hierarchies.md | 13 + .../docs/reference/marks/sunburst.md | 47 +++ .../src/hierarchy-sunburst.test.ts | 113 +++++- .../charts-core/src/hierarchy-sunburst.ts | 84 ++++- packages/charts-core/src/motion.ts | 289 +++++++++++++- .../charts-core/src/scene-motion-internal.ts | 23 ++ scripts/catalog-definition-shapes.test.mjs | 4 +- scripts/catalog-preview.mjs | 1 + scripts/catalog-preview.test.mjs | 2 + scripts/measure-bundles.mjs | 15 +- 35 files changed, 2018 insertions(+), 61 deletions(-) create mode 100644 .changeset/drillable-sunburst-motion.md create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/case.json create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/center.ts create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/model.test.ts create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/model.ts create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/recharts.ts create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/tanstack.test.ts create mode 100644 benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts create mode 100644 benchmarks/conformance/previews/126-drillable-sunburst.svg create mode 100644 packages/charts-core/src/scene-motion-internal.ts diff --git a/.changeset/drillable-sunburst-motion.md b/.changeset/drillable-sunburst-motion.md new file mode 100644 index 00000000..5f05b0d3 --- /dev/null +++ b/.changeset/drillable-sunburst-motion.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add controlled sunburst drill-down roots, bounded visible depth, and hierarchy-aware polar motion that keeps animated sectors centered. Add funnel and drillable sunburst catalog examples. diff --git a/API-FRICTION.md b/API-FRICTION.md index 2a806c1c..09e83631 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -300,6 +300,9 @@ Each entry records: | F-261 | Cartesian bars cannot round only exposed corners | API | open | | F-262 | Mark inference accepted an unsupported style option | API | open | | F-263 | Chromium transport suspension interrupted catalog previews | Tooling | resolved | +| F-264 | Drillable sunbursts required rebuilding hierarchy rows | API/Documentation | resolved | +| F-265 | Sunburst motion lost hierarchy across enter and exit | API | resolved | +| F-266 | Path-token motion distorted polar sectors | API/Tooling | resolved | ## Findings @@ -7804,3 +7807,68 @@ Each entry records: failures, and retained repeated-failure evidence. A full browser-backed run generated all 115 previews in both themes without changing any SVG asset; only the source hash changed. + +### F-264 — Drillable sunbursts required rebuilding hierarchy rows + +- Status: resolved +- Severity: high +- Owner: API/Documentation +- Observed in: drillable Flare sunburst catalog case 126 +- Friction: focusing a branch while limiting visible rings required the + application to filter and re-parent flat rows, re-aggregate boundary values, + and preserve canonical IDs manually. That duplicated hierarchy work already + owned by the mark and made animated continuity depend on case preparation. +- Decision: add `rootId` and `visibleDepth` to the optional `sunburst` mark. + The mark copies the selected hierarchy node as its structural layout root, + retains the complete hierarchy for aggregation and internal-node metadata, + and keeps canonical node keys across root changes. Application state still + owns selection, breadcrumbs, and drill-up controls. +- Verification: focused mark tests cover relative depth, hidden descendant + aggregation, stable keys, validation, and ring allocation. Case 126 passes + the full flat Flare source directly, limits the displayed window in the mark, + and verifies that one retained leaf path interpolates from the outer ring to + the inner ring during a root update. + +### F-265 — Sunburst motion lost hierarchy across enter and exit + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: drill-down and drill-up transitions in the Flare sunburst case +- Friction: stable keys animated nodes visible under both roots, but newly + revealed descendants had no identity in common with their disappearing + parent. Generic enter and exit opacity made a hierarchy change look + intermittent even though the retained-node paths were moving. +- Decision: sunburst sectors carry an internal ancestry relationship into the + motion scene. An entering descendant begins at the live geometry of its + nearest disappearing ancestor, and an exiting descendant collapses into the + live geometry of its nearest appearing ancestor. Unrelated nodes keep the + normal enter and exit behavior, and reduced-motion updates still snap. +- Verification: case 126 asserts the initial, intermediate, and final path for + retained descendants, drill-down entries, and drill-up exits. The live + catalog case confirms the entering `cluster` sector begins in the departing + `analytics` sector and separates over the authored tween. + +### F-266 — Path-token motion distorted polar sectors + +- Status: resolved +- Severity: high +- Owner: API/Tooling +- Observed in: retained, entering, and exiting arcs in the drillable Flare + sunburst case +- Friction: generic SVG path interpolation paired numeric tokens from two `d` + strings. Intermediate endpoints therefore left their common circles, making + sectors skew around the chart even though both endpoint layouts were valid + concentric arcs. +- Decision: optional marks may attach an opaque numeric geometry vector and a + stable projector to a scene path. Sunburst supplies start angle, end angle, + inner radius, and outer radius; motion interpolates those four values and the + sunburst-owned projector regenerates a valid sector each frame. The shared + contract contains no polar or D3 import, so ordinary motion consumers retain + none of the hierarchy implementation. +- Verification: focused temporal tests measure every intermediate outer and + inner endpoint against its declared radius for retained, entering, and + exiting sectors. Interrupted transitions retain their live numeric state, + reduced motion snaps, and retained-input bundle gates require only the small + scene-motion contract while forbidding sunburst, hierarchy, polar-sector, + d3-shape, and d3-path inputs from the isolated motion bundle. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 3afb263b..6910d01c 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-10T22:52:47.426Z", + "generatedAt": "2026-08-11T00:43:02.982Z", "packageVersions": { - "tanstack": "0.9.0", + "tanstack": "0.10.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "49b9f1e00567e6b2ef0ec77850befc96c1e7e7fb", - "inputDigest": "sha256:765b23a32b175d1f52e9b655a571c0334501d482c3d981fd181fe83d4c6cf5bd" + "revision": "7ac3c321ff253f51d2c7df7be6a63c6edb7b771f", + "inputDigest": "sha256:44f03725e05260cba794012df73385ad7df1ccd9a7cd44ad2e560a05ebf682e7" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md index abc2d57d..856a0138 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md @@ -2,11 +2,11 @@ Date: 2026-08-10 -Scope: all 115 catalog directories. The 67 cases previously classified as +Scope: all 117 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 -the historical before-state; Cases 119–124 were added afterward and are +the historical before-state; Cases 119–126 were added afterward and are classified by the same ownership test. This document records the current disposition of every case. @@ -16,10 +16,10 @@ live in the [definition coverage plan](./DEFINITION-COVERAGE-PLAN.md) and its ## Decision -One hundred twelve of the 115 cases present their visualization as a normal -chart definition. Sixty-three use the definition API without a new visualization +One hundred fourteen of the 117 cases present their visualization as a normal +chart definition. Sixty-four use the definition API without a new visualization primitive. Thirty-five use a reusable first-party primitive, including Case -70's final-scale bar thickness cap. Fourteen use a tree-shakeable first-party +70's final-scale bar thickness cap. Fifteen use a tree-shakeable first-party adapter around a heavier layout or gesture algorithm. Only two application shells and one custom mark justify case-owned custom @@ -31,12 +31,12 @@ work: | Disposition | Cases | Meaning | | --------------------- | ------: | ------------------------------------------------------------------------ | -| Definition now | 63 | Current marks and eager transforms are sufficient | +| Definition now | 64 | Current marks and eager transforms are sufficient | | First-party primitive | 35 | Add a reusable mark, transform, layout, guide, or controlled signal | -| Optional primitive | 14 | Keep a heavy dependency granular, but hide its layout DTOs and lifecycle | +| 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** | **115** | | +| **Total** | **117** | | A normal definition does not require every implementation algorithm to live in Charts core. D3 may implement an optional `sankeyDiagram`, `densityContour`, @@ -249,6 +249,8 @@ ownership boundary. | [122 — KPI sparklines](./cases/122-premium-kpi-sparklines/chart.ts) | Definition now | Three guide-free definitions use ordinary area and line marks, gradients, CSS-variable paint, and keyed spring updates; metric copy and responsive card layout remain application content. | | [123 — Active donut](./cases/123-active-donut-metric/chart.ts) | Definition now | Native pie allocation, rounded arcs, selected wedge and ring layers, center text, tooltip, and spring motion own the visualization; legend buttons own persistent selection. | | [124 — Theme matrix](./cases/124-theme-palette-matrix/chart.ts) | Definition now | The same area-and-line definition is rendered through three scoped CSS-variable themes with identical geometry; only the three-card matrix and labels are application composition. | +| [125 — Sales funnel](./cases/125-sales-funnel/tanstack.ts) | Definition now | Derive centered stage boundaries from the ordered raw values, then compose one native `areaX` group per stage with direct labels and stable semantic keys. | +| [126 — Drillable sunburst](./cases/126-drillable-sunburst/tanstack.ts) | Optional primitive | The optional sunburst mark owns focused-root partitioning, bounded visible depth, hierarchy-aware polar motion, and stable node geometry; the application owns selected-root state and the center back control. | ## Reference evidence @@ -280,8 +282,8 @@ bundle. ## Delivery result -All 115 catalog directories now have one roadmap record and case-local -evidence. All 112 normal-definition cases are verified against their current +All 117 catalog directories now have one roadmap record and case-local +evidence. All 114 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 87825b54..e1f3ea0d 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 115 case directories. One hundred twelve visualizations now use +The catalog has 117 case directories. One hundred fourteen 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 | 63 | Normal definition | +| Definition now | 64 | Normal definition | | First-party primitive | 35 | Normal definition | -| Optional primitive | 14 | Normal definition | +| Optional primitive | 15 | Normal definition | | Application boundary | 2 | Accepted boundary | | Inline custom mark | 1 | Accepted boundary | -| **Total** | **115** | **112 normal definitions; 3 accepted boundaries** | +| **Total** | **117** | **114 normal definitions; 3 accepted boundaries** | ## Shared decisions @@ -22,7 +22,7 @@ or renderer is an accepted endpoint. | ---------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Eager typed transforms | 02, 14, 18–22, 24, 26–31, 50–64, 71, 75, 78, 94, 96, 98–99, grouped and stacked bars | Keep row work eager and visible beside its consuming marks; do not add a chart-owned reactive preparation graph. | | Resolved mark layout | 33, 37, 39, 41, 43, 52, 74, both 111 cases | Schedule only work that needs final scales or bounds. Reuse internal projection, grouping, child adoption, and composition contracts without exposing a universal layout callback. | -| Spatial, hierarchy, and network adapters | 36–39, 40-force, 43, 65, 74, 101, both 111 cases | Keep heavy algorithms in exact optional subpaths while definitions accept semantic source rows. | +| Spatial, hierarchy, and network adapters | 36–39, 40-force, 43, 65, 74, 101, both 111 cases, 126 | Keep heavy algorithms in exact optional subpaths while definitions accept semantic source rows. | | Composite and distribution marks | 15, 31, 33, 62–64 | Couple statistics and geometry only where they form a stable named contract; otherwise keep public transforms explicit. | | Polar allocation and radial geometry | 75–78, 93–100 | Share value allocation, label anchoring, sector geometry, and radial bars. Case-specific selection and presentation remain explicit. | | View composition | 57, 87 | Use composed child definitions when tracks truly share one chart lifecycle. Case 83 correctly retains two hosts because detail and overview differ in data, domains, axes, margins, sizes, and behavior. | @@ -63,7 +63,7 @@ presentation policy. ## Verification -- The roadmap validator compares all 115 roadmap and audit IDs with the live +- The roadmap validator compares all 117 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. @@ -187,6 +187,8 @@ presentation policy. | [122-premium-kpi-sparklines — Premium KPI sparklines](./cases/122-premium-kpi-sparklines/chart.ts) | Definition now | `current-definition-api` | `chart.ts`, `tanstack.test.ts`, `view.tsx` | | [123-active-donut-metric — Active donut metric](./cases/123-active-donut-metric/chart.ts) | Definition now | `current-definition-api` | `chart.ts`, `tanstack.test.ts`, `view.tsx` | | [124-theme-palette-matrix — Theme palette matrix](./cases/124-theme-palette-matrix/chart.ts) | Definition now | `current-definition-api` | `chart.ts`, `tanstack.test.ts`, `view.tsx` | +| [125-sales-funnel — Sales conversion funnel](./cases/125-sales-funnel/tanstack.ts) | Definition now | `current-definition-api` | `tanstack.ts`, `model.test.ts`, `case.json` | +| [126-drillable-sunburst — Drillable Flare sunburst](./cases/126-drillable-sunburst/tanstack.ts) | Optional primitive | `hierarchy-sunburst` | `tanstack.ts`, `tanstack.test.ts`, `model.test.ts` | | [bar-grouped — Grouped bars](./cases/bar-grouped/tanstack.ts) | Definition now | `current-definition-api` | `tanstack.ts` | | [bar-horizontal-ranking — Horizontal ranking with long labels](./cases/bar-horizontal-ranking/tanstack.ts) | Definition now | `current-definition-api` | `tanstack.ts` | | [bar-stacked — Stacked bars](./cases/bar-stacked/tanstack.ts) | Definition now | `current-definition-api` | `tanstack.ts` | diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index f0fbb4ef..7332b56d 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -197,8 +197,8 @@ comparison. ## Current scope -The executable corpus contains 111 paired cases: 77 sourced from Observable -Plot, 23 from Recharts, and 11 from Apache ECharts. It spans the common +The executable corpus contains 112 paired cases: 77 sourced from Observable +Plot, 24 from Recharts, and 11 from Apache ECharts. It spans the common cartesian vocabulary plus the high-value catalog beyond it: - lines, areas, bars, intervals, heatmaps, histograms, facets, and framed @@ -211,7 +211,7 @@ cartesian vocabulary plus the high-value catalog beyond it: - pointer, grouped, and Voronoi-nearest tooltips; - pie, labeled pie, basic/centered/rounded/nested donuts, partial and needle gauges, single/comparative radar, numeric polar line/scatter, rose, radial - bars, and sunburst layouts; + bars, and static and drillable sunburst layouts; - trees, Delaunay links, force networks, vector fields, and GeoJSON maps. - regional and world choropleths, proportional symbols, orthographic globe and graticule layers, projected routes, 177 real country boundaries, 51 US diff --git a/benchmarks/conformance/cases/125-sales-funnel/case.json b/benchmarks/conformance/cases/125-sales-funnel/case.json index f3e6a8be..0a0dbd9f 100644 --- a/benchmarks/conformance/cases/125-sales-funnel/case.json +++ b/benchmarks/conformance/cases/125-sales-funnel/case.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "order": 1210, + "order": 1260, "id": "125-sales-funnel", "title": "Sales conversion funnel", "family": "composition", diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/case.json b/benchmarks/conformance/cases/126-drillable-sunburst/case.json new file mode 100644 index 00000000..1a280858 --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/case.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1270, + "id": "126-drillable-sunburst", + "title": "Drillable Flare sunburst", + "family": "hierarchy", + "intent": "Navigate a large hierarchy two levels at a time while retained and newly revealed sectors follow their hierarchy through each transition.", + "support": "native", + "features": [ + "native sunburst hierarchy mark", + "focused hierarchy root", + "bounded visible depth", + "drill-down and drill-up", + "pointer and keyboard activation", + "stable node identity", + "hierarchy-aware enter and exit", + "semantic polar interpolation", + "reduced-motion support" + ], + "geometry": [{ "role": "arc", "count": 13, "maxCount": 13 }], + "minimumGeometrySimilarity": 0.99, + "source": { + "title": "D3 zoomable sunburst", + "url": "https://observablehq.com/@d3/zoomable-sunburst" + }, + "ai": { + "create": "Create a drillable Flare sunburst that renders two descendant rings below a controlled root, uses semantic node selection for pointer and keyboard navigation, and animates retained, entering, and exiting sectors by interpolating polar geometry through hierarchy relationships.", + "maintain": "Keep the full flat hierarchy as source data, preserve canonical node IDs and ancestry across root changes, leave navigation state and the center back control in the application, and keep motion centered by interpolating sector angles and radii before regenerating each path." + } +} diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/center.ts b/benchmarks/conformance/cases/126-drillable-sunburst/center.ts new file mode 100644 index 00000000..0a028370 --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/center.ts @@ -0,0 +1,126 @@ +import { + flareAggregateValue, + flareLabel, + flareParentId, + formatFlareValue, +} from './model' + +export interface SunburstCenterLabel { + readonly id: 'current' | 'detail' + readonly angle: number + readonly radius: number + readonly text: string + readonly dy: number +} + +export function sunburstCenterLabels( + rootId: string, +): readonly SunburstCenterLabel[] { + const parentId = flareParentId(rootId) + return [ + { + id: 'current', + angle: 0, + radius: 0, + text: flareLabel(rootId), + dy: -6, + }, + { + id: 'detail', + angle: 0, + radius: 0, + text: parentId + ? `↑ ${flareLabel(parentId)}` + : formatFlareValue(flareAggregateValue(rootId)), + dy: 9, + }, + ] +} + +export function createSunburstCenterOverlay(document: Document): SVGSVGElement { + const namespace = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(namespace, 'svg') + svg.setAttribute('aria-hidden', 'true') + svg.dataset.conformanceSunburstCenter = '' + Object.assign(svg.style, { + position: 'absolute', + inset: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + overflow: 'visible', + }) + for (const id of ['current', 'detail']) { + const label = document.createElementNS(namespace, 'text') + label.dataset.sunburstCenterLabel = id + label.setAttribute('fill', 'CanvasText') + label.setAttribute('text-anchor', 'middle') + label.setAttribute('dominant-baseline', 'middle') + label.setAttribute('font-size', id === 'current' ? '12' : '10') + label.setAttribute('font-weight', id === 'current' ? '700' : '500') + svg.append(label) + } + return svg +} + +export function updateSunburstCenterOverlay( + svg: SVGSVGElement, + rootId: string, + width: number, + height: number, +) { + svg.setAttribute('viewBox', `0 0 ${width} ${height}`) + for (const label of sunburstCenterLabels(rootId)) { + const element = svg.querySelector( + `[data-sunburst-center-label="${label.id}"]`, + ) + element?.setAttribute('x', String(width / 2)) + element?.setAttribute('y', String(height / 2 + label.dy)) + if (element) element.textContent = label.text + } +} + +export function createSunburstCenterControl( + document: Document, +): HTMLButtonElement { + const button = document.createElement('button') + button.type = 'button' + button.dataset.conformanceSunburstBack = '' + styleSunburstCenterControl(button) + return button +} + +export function styleSunburstCenterControl(button: HTMLButtonElement) { + Object.assign(button.style, { + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + border: '0', + borderRadius: '999px', + background: 'transparent', + color: 'transparent', + padding: '0', + }) +} + +export function updateSunburstCenterControl( + button: HTMLButtonElement, + rootId: string, + width: number, + height: number, +) { + const parentId = flareParentId(rootId) + const diameter = Math.max(44, Math.min(width, height) * 0.27) + button.style.width = `${diameter}px` + button.style.height = `${diameter}px` + button.disabled = parentId === null + button.style.cursor = parentId ? 'pointer' : 'default' + button.style.pointerEvents = parentId ? 'auto' : 'none' + button.setAttribute( + 'aria-label', + parentId + ? `Back to ${flareLabel(parentId)}` + : `${flareLabel(rootId)}, ${formatFlareValue(flareAggregateValue(rootId))}`, + ) +} diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/model.test.ts b/benchmarks/conformance/cases/126-drillable-sunburst/model.test.ts new file mode 100644 index 00000000..de026bb5 --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/model.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + flareAggregateValue, + flareHasChildren, + flareNodeColor, + flarePreviewRootId, + flareRootId, + flareSunburstTree, + flareVisibleDepth, +} from './model' + +describe('drillable Flare sunburst model', () => { + it('keeps the complete hierarchy while exposing a bounded display tree', () => { + const overview = flareSunburstTree(flareRootId) + const analytics = flareSunburstTree(flarePreviewRootId) + + expect(flareVisibleDepth(flareRootId)).toBe(1) + expect(overview.children).toHaveLength(10) + expect(overview.children?.every((child) => !child.children)).toBe(true) + expect(flareVisibleDepth(flarePreviewRootId)).toBe(2) + expect(analytics.children?.map((child) => child.id)).toEqual([ + '/flare/analytics/graph', + '/flare/analytics/cluster', + '/flare/analytics/optimization', + ]) + expect( + analytics.children?.flatMap((child) => child.children ?? []), + ).toHaveLength(10) + expect(analytics.value).toBe(flareAggregateValue(flarePreviewRootId)) + }) + + it('retains navigation and paint identity outside the visible window', () => { + const cluster = '/flare/analytics/cluster' + const leaf = `${cluster}/AgglomerativeCluster` + + expect(flareHasChildren(cluster)).toBe(true) + expect(flareHasChildren(leaf)).toBe(false) + expect(flareNodeColor(leaf)).toBe(flareNodeColor(leaf)) + expect(flareNodeColor(leaf)).not.toBe( + flareNodeColor(`${cluster}/CommunityStructure`), + ) + }) +}) diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/model.ts b/benchmarks/conformance/cases/126-drillable-sunburst/model.ts new file mode 100644 index 00000000..a06c23dc --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/model.ts @@ -0,0 +1,146 @@ +import { flare } from '@charts-poc/demo-data/flare' +import type { FlareRow } from '@charts-poc/demo-data/flare' + +export const flareRootId = '/flare' +export const flarePreviewRootId = '/flare/analytics' + +export interface FlareSunburstTree { + readonly id: string + readonly name: string + readonly value: number + readonly fill: string + readonly children?: FlareSunburstTree[] +} + +const rowsById = new Map(flare.map((row) => [flareId(row.name), row])) +const childrenById = new Map() + +for (const row of flare) { + const id = flareId(row.name) + const parentId = flareParentId(id) + if (!parentId) continue + const children = childrenById.get(parentId) + if (children) children.push(id) + else childrenById.set(parentId, [id]) +} + +const aggregateValues = new Map() +const heights = new Map() + +export function flareId(path: string): string { + return `/${path.replaceAll('.', '/')}` +} + +export function flareLabel(id: string): string { + return rowsById.get(id)?.name.split('.').at(-1) ?? id.split('/').at(-1) ?? id +} + +export function flareParentId(id: string): string | null { + const split = id.lastIndexOf('/') + return split > 0 ? id.slice(0, split) : null +} + +export function flareHasChildren(id: string): boolean { + return (childrenById.get(id)?.length ?? 0) > 0 +} + +export function flareAggregateValue(id: string): number { + const cached = aggregateValues.get(id) + if (cached !== undefined) return cached + const own = rowsById.get(id)?.size ?? 0 + const value = (childrenById.get(id) ?? []).reduce( + (sum, childId) => sum + flareAggregateValue(childId), + own, + ) + aggregateValues.set(id, value) + return value +} + +export function flareHeight(id: string): number { + const cached = heights.get(id) + if (cached !== undefined) return cached + const childHeights = (childrenById.get(id) ?? []).map(flareHeight) + const height = childHeights.length ? Math.max(...childHeights) + 1 : 0 + heights.set(id, height) + return height +} + +export function flareVisibleRingCount(id: string): number { + return Math.min(flareVisibleDepth(id), flareHeight(id)) +} + +export function flareVisibleDepth(id: string): number { + return id === flareRootId ? 1 : 2 +} + +export function flareSunburstTree(id: string): FlareSunburstTree { + if (!rowsById.has(id)) throw new TypeError(`Unknown Flare node "${id}"`) + return treeNode(id, 0, flareVisibleDepth(id)) +} + +export function flareNodeColor(id: string): string { + const branch = id.split('/')[2] ?? 'flare' + const hue = branchHues[branch] ?? 220 + const lightness = 48 + (hash(id) % 4) * 5 + return `hsl(${hue} 70% ${lightness}%)` +} + +export function formatFlareValue(value: number): string { + return `${value.toLocaleString('en-US')} lines` +} + +export function flareRows(): readonly FlareRow[] { + return flare +} + +function treeNode( + id: string, + depth: number, + visibleDepth: number, +): FlareSunburstTree { + const children = + depth >= visibleDepth + ? [] + : [...(childrenById.get(id) ?? [])].sort(compareNodes) + return { + id, + name: flareLabel(id), + value: flareAggregateValue(id), + fill: flareNodeColor(id), + ...(children.length + ? { + children: children.map((childId) => + treeNode(childId, depth + 1, visibleDepth), + ), + } + : {}), + } +} + +function compareNodes(left: string, right: string): number { + return ( + flareAggregateValue(right) - flareAggregateValue(left) || + flareLabel(left).localeCompare(flareLabel(right)) + ) +} + +function hash(value: string): number { + let result = 0 + for (let index = 0; index < value.length; index += 1) { + result = (result * 31 + value.charCodeAt(index)) >>> 0 + } + return result +} + +const branchHues: Readonly> = { + analytics: 263, + animate: 198, + data: 36, + display: 330, + flex: 152, + physics: 232, + query: 18, + scale: 177, + util: 87, + vis: 355, +} diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/recharts.ts b/benchmarks/conformance/cases/126-drillable-sunburst/recharts.ts new file mode 100644 index 00000000..64eb5c77 --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/recharts.ts @@ -0,0 +1,135 @@ +import { createElement, useState } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import { SunburstChart } from 'recharts' +import { applyRechartsAccessibility } from '../../shared/recharts-mount' +import { + styleSunburstCenterControl, + sunburstCenterLabels, + updateSunburstCenterControl, +} from './center' +import { + flareHasChildren, + flareParentId, + flarePreviewRootId, + flareSunburstTree, +} from './model' +import type { ConformanceInput, ConformanceMount } from '../../types' +import type { SunburstData } from 'recharts' + +export const mount: ConformanceMount = (container, input) => { + let currentInput = input + const surface = container.ownerDocument.createElement('div') + const root = createRoot(surface) + container.append(surface) + + const render = () => { + flushSync(() => root.render(createElement(DrillableRecharts, currentInput))) + applyRechartsAccessibility(surface, 'Drillable Flare hierarchy') + } + render() + + return { + update(nextInput) { + currentInput = nextInput + render() + }, + destroy() { + flushSync(() => root.unmount()) + surface.remove() + }, + } +} + +function DrillableRecharts(input: ConformanceInput) { + const [rootId, setRootId] = useState(flarePreviewRootId) + const radius = Math.min(input.width, input.height) * 0.46 + const innerRadius = radius * 0.32 + const labels = sunburstCenterLabels(rootId) + const parentId = flareParentId(rootId) + + return createElement( + 'div', + { + 'data-conformance-view': 'main', + style: { + position: 'relative', + width: input.width, + height: input.height, + color: 'CanvasText', + }, + }, + createElement( + SunburstChart, + { + width: input.width, + height: input.height, + data: flareSunburstTree(rootId), + cx: input.width / 2, + cy: input.height / 2, + innerRadius, + outerRadius: radius, + startAngle: 0, + endAngle: 360, + padding: 2, + ringPadding: 2, + stroke: 'Canvas', + textOptions: { display: 'none' }, + onClick: (node: SunburstData) => { + const id = typeof node.id === 'string' ? node.id : null + if (id && flareHasChildren(id)) setRootId(id) + }, + }, + ...labels.map((label, index) => + createElement( + 'text', + { + key: label.id, + x: input.width / 2, + y: input.height / 2 + label.dy, + fill: 'CanvasText', + fontSize: index === 0 ? 12 : 10, + fontWeight: index === 0 ? 700 : 500, + textAnchor: 'middle', + dominantBaseline: 'middle', + pointerEvents: 'none', + }, + label.text, + ), + ), + ), + createElement(RechartsCenterControl, { + rootId, + width: input.width, + height: input.height, + onBack: () => { + if (parentId) setRootId(parentId) + }, + }), + ) +} + +function RechartsCenterControl({ + rootId, + width, + height, + onBack, +}: { + rootId: string + width: number + height: number + onBack: () => void +}) { + const ref = (element: HTMLButtonElement | null) => { + if (element) { + styleSunburstCenterControl(element) + updateSunburstCenterControl(element, rootId, width, height) + } + } + return createElement('button', { + ref, + type: 'button', + 'data-conformance-sunburst-back': '', + onClick: onBack, + }) +} diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.test.ts b/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.test.ts new file mode 100644 index 00000000..3b9ef597 --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.test.ts @@ -0,0 +1,354 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createChartRuntime } from '@tanstack/charts' +import { motion } from '@tanstack/charts/motion' +import { describe, expect, it, vi } from 'vitest' +import { + flareHasChildren, + flarePreviewRootId, + flareRootId, + flareRows, + flareVisibleDepth, +} from './model' +import { drillableSunburstDefinition, mount } from './tanstack' +import type { FlareRow } from '@charts-poc/demo-data/flare' +import type { SunburstNode } from '@tanstack/charts/hierarchy/sunburst' +import type { ChartScene, SceneNode } from '@tanstack/charts' + +const clusterId = '/flare/analytics/cluster' +const retainedLeafId = `${clusterId}/AgglomerativeCluster` + +describe('drillable native sunburst', () => { + it('uses the full flat source with a focused root and two visible depths', () => { + const scene = render(flarePreviewRootId) + + expect(scene.points).toHaveLength(13) + expect( + scene.points.every((point) => point.datum.id !== flarePreviewRootId), + ).toBe(true) + expect(new Set(scene.points.map((point) => point.datum.depth))).toEqual( + new Set([1, 2]), + ) + expect( + scene.points.find((point) => point.datum.id === clusterId)?.datum, + ).toMatchObject({ + parentId: flarePreviewRootId, + ancestorIds: [flarePreviewRootId], + depth: 1, + internal: true, + }) + }) + + it('moves retained descendants into the first ring with stable keys', () => { + const overview = render(flarePreviewRootId) + const focused = render(clusterId) + const before = pointById(overview, retainedLeafId) + const after = pointById(focused, retainedLeafId) + + expect(focused.points).toHaveLength(4) + expect(after.key).toBe(before.key) + expect(before.datum.depth).toBe(2) + expect(after.datum.depth).toBe(1) + expect(after.yValue).toBeLessThan(before.yValue) + }) + + it('interpolates retained sector paths during a root update', () => { + const overview = render(flarePreviewRootId) + const focused = render(clusterId) + const key = pointById(overview, retainedLeafId).key + const target = areaPath(focused, key) + const container = document.createElement('div') + const surface = motion, number, number>({ + initial: false, + }).mount(container, () => {}) + surface.render(overview, { ariaLabel: 'Drillable sunburst' }) + const retained = pathByKey(container, key) + const before = retained.getAttribute('d') + const frames = installManagedFrames() + + try { + surface.render(focused, { ariaLabel: 'Drillable sunburst' }) + expect(pathByKey(container, key)).toBe(retained) + expect(retained.getAttribute('d')).toBe(before) + expect(pathSkeleton(before ?? '')).toBe(pathSkeleton(target)) + + frames.run(0) + frames.run(360) + expect(retained.getAttribute('d')).not.toBe(before) + expect(retained.getAttribute('d')).not.toBe(target) + expectCenteredSectorPath(retained.getAttribute('d')) + frames.run(720) + expect(retained.getAttribute('d')).toBe(target) + } finally { + surface.destroy() + frames.restore() + } + }) + + it('retargets an interrupted sector from its live polar geometry', () => { + const overview = render(flarePreviewRootId) + const focused = render(clusterId) + const key = pointById(overview, retainedLeafId).key + const overviewPath = areaPath(overview, key) + const container = document.createElement('div') + const surface = motion, number, number>({ + initial: false, + }).mount(container, () => {}) + surface.render(overview, { ariaLabel: 'Drillable sunburst' }) + const retained = pathByKey(container, key) + const frames = installManagedFrames() + + try { + surface.render(focused, { ariaLabel: 'Drillable sunburst' }) + frames.run(0) + frames.run(240) + const interruptedPath = retained.getAttribute('d') + expect(interruptedPath).not.toBe(overviewPath) + expectCenteredSectorPath(interruptedPath) + + surface.render(overview, { ariaLabel: 'Drillable sunburst' }) + expect(pathByKey(container, key)).toBe(retained) + expect(retained.getAttribute('d')).toBe(interruptedPath) + + frames.run(240) + frames.run(480) + expect(retained.getAttribute('d')).not.toBe(interruptedPath) + expectCenteredSectorPath(retained.getAttribute('d')) + frames.run(960) + expect(retained.getAttribute('d')).toBe(overviewPath) + } finally { + surface.destroy() + frames.restore() + } + }) + + it('unfolds entering descendants from their disappearing parent sector', () => { + const overview = render(flareRootId) + const focused = render(flarePreviewRootId) + const parentKey = pointById(overview, flarePreviewRootId).key + const enteringKey = pointById(focused, clusterId).key + const source = areaPath(overview, parentKey) + const target = areaPath(focused, enteringKey) + const container = document.createElement('div') + const surface = motion, number, number>({ + initial: false, + }).mount(container, () => {}) + surface.render(overview, { ariaLabel: 'Drillable sunburst' }) + const frames = installManagedFrames() + + try { + surface.render(focused, { ariaLabel: 'Drillable sunburst' }) + const entering = pathByKey(container, enteringKey) + expect(entering.getAttribute('d')).toBe(source) + expect(entering.getAttribute('opacity')).toBe('0') + + frames.run(0) + frames.run(360) + expect(entering.getAttribute('d')).not.toBe(source) + expect(entering.getAttribute('d')).not.toBe(target) + expectCenteredSectorPath(entering.getAttribute('d')) + frames.run(720) + expect(entering.getAttribute('d')).toBe(target) + expect(entering.hasAttribute('opacity')).toBe(false) + } finally { + surface.destroy() + frames.restore() + } + }) + + it('collapses exiting descendants into their appearing parent sector', () => { + const focused = render(flarePreviewRootId) + const overview = render(flareRootId) + const exitingKey = pointById(focused, clusterId).key + const parentKey = pointById(overview, flarePreviewRootId).key + const source = areaPath(focused, exitingKey) + const target = areaPath(overview, parentKey) + const container = document.createElement('div') + const surface = motion, number, number>({ + initial: false, + }).mount(container, () => {}) + surface.render(focused, { ariaLabel: 'Drillable sunburst' }) + const exiting = pathByKey(container, exitingKey) + const frames = installManagedFrames() + + try { + surface.render(overview, { ariaLabel: 'Drillable sunburst' }) + expect(exiting.getAttribute('d')).toBe(source) + + frames.run(0) + frames.run(160) + expect(exiting.getAttribute('d')).not.toBe(source) + expect(exiting.getAttribute('d')).not.toBe(target) + expectCenteredSectorPath(exiting.getAttribute('d')) + frames.run(720) + expect( + container.querySelector(`path[data-ts-key="${exitingKey}"]`), + ).toBeNull() + } finally { + surface.destroy() + frames.restore() + } + }) + + it('drills with keyboard activation and returns through the center control', () => { + const container = document.createElement('div') + document.body.append(container) + const mounted = mount(container, { + width: 640, + height: 400, + revision: 0, + interactive: true, + }) + const svg = container.querySelector('svg.ts-chart') + const back = container.querySelector( + '[data-conformance-sunburst-back]', + ) + if (!svg || !back || !mounted.driver) { + throw new TypeError('Expected mounted drillable sunburst controls') + } + + try { + expect(mounted.driver.readState().rootId).toBe(flarePreviewRootId) + expect(back.getAttribute('aria-label')).toBe('Back to flare') + + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + for (let index = 0; index < 13; index += 1) { + const focusedId = mounted.driver.readState().focusedId + if (typeof focusedId === 'string' && flareHasChildren(focusedId)) break + svg.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + }), + ) + } + const selectedRoot = mounted.driver.readState().focusedId + expect( + typeof selectedRoot === 'string' && flareHasChildren(selectedRoot), + ).toBe(true) + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ) + expect(mounted.driver.readState().rootId).toBe(selectedRoot) + expect(back.getAttribute('aria-label')).toBe('Back to analytics') + + back.click() + expect(mounted.driver.readState().rootId).toBe(flarePreviewRootId) + } finally { + mounted.destroy() + container.remove() + } + }) + + it('keeps hierarchy viewport policy in the mark and navigation in the shell', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts', + ), + 'utf8', + ) + + expect(source).toContain('rootId') + expect(source).toContain('visibleDepth') + expect(source).not.toContain('partition(') + expect(source).not.toContain('arc(') + expect(flareRows()).toHaveLength(252) + expect(flareVisibleDepth(flarePreviewRootId)).toBe(2) + }) +}) + +function render(rootId: string) { + return createChartRuntime, number, number>().render( + drillableSunburstDefinition(rootId), + { width: 640, height: 400 }, + ) +} + +function pointById( + scene: ChartScene, number, number>, + id: string, +) { + const point = scene.points.find((candidate) => candidate.datum.id === id) + if (!point) throw new TypeError(`Expected point ${id}`) + return point +} + +function areaPath( + scene: ChartScene, number, number>, + key: string, +) { + const nodes = flatten(scene.nodes) + const area = nodes.find((node) => node.kind === 'area' && node.key === key) + if (!area || area.kind !== 'area' || !area.path) { + throw new TypeError(`Expected area ${key}`) + } + return area.path +} + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => + node.kind === 'group' ? [node, ...flatten(node.children)] : [node], + ) +} + +function pathByKey(container: HTMLElement, key: string) { + const path = container.querySelector( + `path[data-ts-key="${key}"]`, + ) + if (!path) throw new TypeError(`Expected path ${key}`) + return path +} + +function pathSkeleton(value: string) { + return value.replace(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi, '#') +} + +function expectCenteredSectorPath(path: string | null) { + const values = [ + ...(path ?? '').matchAll(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi), + ].map((match) => Number(match[0])) + expect(values).toHaveLength(18) + const outerRadius = values[2] ?? Number.NaN + const innerRadius = values[11] ?? Number.NaN + expect(Math.hypot(values[0] ?? 0, values[1] ?? 0)).toBeCloseTo(outerRadius, 2) + expect(Math.hypot(values[7] ?? 0, values[8] ?? 0)).toBeCloseTo(outerRadius, 2) + expect(Math.hypot(values[9] ?? 0, values[10] ?? 0)).toBeCloseTo( + innerRadius, + 2, + ) + expect(Math.hypot(values[16] ?? 0, values[17] ?? 0)).toBeCloseTo( + innerRadius, + 2, + ) +} + +function installManagedFrames() { + const callbacks = new Map() + let handle = 0 + const request = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + handle += 1 + callbacks.set(handle, callback) + return handle + }) + const cancel = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation((frame) => { + if (frame !== null && frame !== undefined) callbacks.delete(frame) + }) + return { + run(time: number) { + const next = callbacks.entries().next().value as + [number, FrameRequestCallback] | undefined + if (!next) throw new Error(`No animation frame scheduled at ${time}ms`) + callbacks.delete(next[0]) + next[1](time) + }, + restore() { + request.mockRestore() + cancel.mockRestore() + }, + } +} diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts b/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts new file mode 100644 index 00000000..3107258d --- /dev/null +++ b/benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts @@ -0,0 +1,239 @@ +import { defineChart } from '@tanstack/charts' +import { sunburst } from '@tanstack/charts/hierarchy/sunburst' +import { motion } from '@tanstack/charts/motion' +import { polar } from '@tanstack/charts/polar' +import { mountChartRenderer } from '@tanstack/charts/renderer' +import { tooltip } from '@tanstack/charts/tooltip' +import { readChartMotionState, settleChartMotion } from '../../shared/motion' +import { + createSunburstCenterControl, + createSunburstCenterOverlay, + updateSunburstCenterControl, + updateSunburstCenterOverlay, +} from './center' +import { + flareAggregateValue, + flareHasChildren, + flareLabel, + flareNodeColor, + flareParentId, + flarePreviewRootId, + flareRows, + flareVisibleDepth, + flareVisibleRingCount, + formatFlareValue, +} from './model' +import type { FlareRow } from '@charts-poc/demo-data/flare' +import type { SunburstNode } from '@tanstack/charts/hierarchy/sunburst' +import type { ChartPoint, ChartRendererHostOptions } from '@tanstack/charts' +import type { + ConformanceInput, + ConformanceMount, + ConformanceTestDriver, +} from '../../types' + +type DrillDatum = SunburstNode + +const tau = Math.PI * 2 +const ringPadding = 2 + +export function drillableSunburstDefinition(rootId: string) { + const ringCount = flareVisibleRingCount(rootId) + + return defineChart({ + marks: [ + polar({ + radiusRatio: 0.92, + startAngle: Math.PI / 2, + endAngle: Math.PI / 2 - tau, + marks: [ + sunburst(flareRows(), { + id: 'drillable-sunburst-arcs', + path: 'name', + delimiter: '.', + value: 'size', + rootId, + visibleDepth: flareVisibleDepth(rootId), + sort: (left, right) => + right.value - left.value || left.name.localeCompare(right.name), + innerRadius: ({ radius }) => radius * 0.32, + outerRadius: ({ radius }) => { + const innerRadius = radius * 0.32 + return ( + innerRadius + + ((radius - innerRadius) * ringCount) / (ringCount + 1) + + Math.max(0, ringCount - 1) * ringPadding + ) + }, + ringPadding, + fill: (node) => flareNodeColor(node.id), + stroke: 'Canvas', + strokeOpacity: 0.9, + strokeWidth: 2, + motion(context) { + return { + delay: + context.phase === 'enter' + ? Math.min(context.datumIndex * 18, 160) + : 0, + transition: + context.phase === 'exit' + ? { type: 'tween', duration: 320, easing: 'ease-out' } + : undefined, + } + }, + }), + ], + }), + ], + motion: { + transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, + }, + tooltip: { + use: tooltip, + format: ({ datum }) => `${datum.name} · ${formatFlareValue(datum.value)}`, + }, + keyboard: true, + margin: 0, + }) +} + +export const mount: ConformanceMount = (container, input) => { + let currentInput = input + let rootId = flarePreviewRootId + let focusedId: string | null = null + let host: + | ReturnType> + | undefined + const renderer = motion({ initial: false }) + const view = container.ownerDocument.createElement('div') + const chart = container.ownerDocument.createElement('div') + const centerOverlay = createSunburstCenterOverlay(container.ownerDocument) + const center = createSunburstCenterControl(container.ownerDocument) + view.dataset.conformanceView = 'main' + Object.assign(view.style, { + position: 'relative', + width: `${input.width}px`, + height: `${input.height}px`, + color: 'CanvasText', + }) + Object.assign(chart.style, { position: 'absolute', inset: '0' }) + view.append(chart, center) + container.append(view) + + const selectRoot = (nextRootId: string) => { + if (nextRootId === rootId || !flareHasChildren(nextRootId)) return + rootId = nextRootId + updateCenter() + host?.update(options()) + } + const selectPoint = (point: ChartPoint | null) => { + if (point) selectRoot(point.datum.id) + } + const goBack = () => { + const parentId = flareParentId(rootId) + if (!parentId) return + rootId = parentId + updateCenter() + host?.update(options()) + } + const options = (): ChartRendererHostOptions => ({ + definition: drillableSunburstDefinition(rootId), + renderer, + width: currentInput.width, + height: currentInput.height, + ariaLabel: 'Drillable Flare hierarchy', + ariaDescription: + 'Use arrow keys to inspect segments and Enter or Space to drill into a branch. Use the center button to move up.', + onFocusChange: (point) => { + focusedId = point?.datum.id ?? null + }, + onSelect: selectPoint, + }) + const updateCenter = () => { + updateSunburstCenterControl( + center, + rootId, + currentInput.width, + currentInput.height, + ) + updateSunburstCenterOverlay( + centerOverlay, + rootId, + currentInput.width, + currentInput.height, + ) + } + + center.addEventListener('click', goBack) + updateCenter() + host = mountChartRenderer(chart, options()) + chart.append(centerOverlay) + + const driver: ConformanceTestDriver = { + resolveTarget(target) { + if (target.view && target.view !== 'main') return null + if (target.anchor === 'control:back') return centerPoint(center) + const id = target.anchor.startsWith('node:') + ? target.anchor.slice('node:'.length) + : null + const point = id + ? host?.getScene().points.find((candidate) => candidate.datum.id === id) + : null + if (!point) return null + const svg = chart.querySelector('svg.ts-chart') + if (!svg) return null + const bounds = svg.getBoundingClientRect() + return { + x: bounds.left + point.x, + y: bounds.top + point.y, + focusElement: svg, + } + }, + readState() { + const nodes = host?.getScene().points.map((point) => point.datum.id) + return { + rootId, + parentId: flareParentId(rootId), + label: flareLabel(rootId), + value: flareAggregateValue(rootId), + focusedId, + visibleNodes: nodes ?? [], + motionState: readChartMotionState(chart), + } + }, + settle: () => settleChartMotion(chart, 3_000), + } + + return { + driver, + update(nextInput) { + currentInput = nextInput + view.style.width = `${nextInput.width}px` + view.style.height = `${nextInput.height}px` + updateCenter() + host?.update(options()) + }, + destroy() { + center.removeEventListener('click', goBack) + host?.destroy() + view.remove() + }, + } +} + +export const catalogCase = Object.assign(mount, { + mount, + createDefinition: () => drillableSunburstDefinition(flarePreviewRootId), + ariaLabel: 'Drillable Flare hierarchy', + interactiveTooltip: true as const, +}) + +function centerPoint(element: HTMLElement) { + const bounds = element.getBoundingClientRect() + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + focusElement: element, + } +} diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 6578cd4a..6ec84de2 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -10541,6 +10541,95 @@ "path": "benchmarks/conformance/cases/124-theme-palette-matrix/plot.ts" } } + }, + { + "schemaVersion": 1, + "order": 1260, + "id": "125-sales-funnel", + "title": "Sales conversion funnel", + "family": "composition", + "intent": "Show how 12,400 visitors narrow through leads, qualification, proposals, and closed sales while preserving every stage value.", + "support": "native", + "features": [ + "ordered funnel stages", + "native transposed areas", + "centered quantitative extents", + "separate keyed trapezoids", + "direct stage labels", + "raw stage values", + "responsive layout" + ], + "geometry": [ + { + "role": "area", + "count": 5, + "maxCount": 5 + }, + { + "role": "text", + "count": 5, + "maxCount": 5 + } + ], + "source": { + "title": "Bklit UI Funnel Chart", + "url": "https://bklit.com/docs/components/funnel-chart" + }, + "ai": { + "create": "Create a sales funnel from five raw stage values. Derive centered left and right endpoints for two boundaries per stage, render each stage as a separate native areaX trapezoid, and label every stage with its raw value.", + "maintain": "Keep stage order, semantic keys, proportional widths, centered extents, direct labels, raw values, and the tapered final edge stable across responsive sizes and data revisions." + }, + "entries": { + "tanstack": "benchmarks/conformance/cases/125-sales-funnel/tanstack.ts", + "reference": { + "renderer": "observable-plot", + "path": "benchmarks/conformance/cases/125-sales-funnel/plot.ts" + } + } + }, + { + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1270, + "id": "126-drillable-sunburst", + "title": "Drillable Flare sunburst", + "family": "hierarchy", + "intent": "Navigate a large hierarchy two levels at a time while retained and newly revealed sectors follow their hierarchy through each transition.", + "support": "native", + "features": [ + "native sunburst hierarchy mark", + "focused hierarchy root", + "bounded visible depth", + "drill-down and drill-up", + "pointer and keyboard activation", + "stable node identity", + "hierarchy-aware enter and exit", + "semantic polar interpolation", + "reduced-motion support" + ], + "geometry": [ + { + "role": "arc", + "count": 13, + "maxCount": 13 + } + ], + "minimumGeometrySimilarity": 0.99, + "source": { + "title": "D3 zoomable sunburst", + "url": "https://observablehq.com/@d3/zoomable-sunburst" + }, + "ai": { + "create": "Create a drillable Flare sunburst that renders two descendant rings below a controlled root, uses semantic node selection for pointer and keyboard navigation, and animates retained, entering, and exiting sectors by interpolating polar geometry through hierarchy relationships.", + "maintain": "Keep the full flat hierarchy as source data, preserve canonical node IDs and ancestry across root changes, leave navigation state and the center back control in the application, and keep motion centered by interpolating sector angles and radii before regenerating each path." + }, + "entries": { + "tanstack": "benchmarks/conformance/cases/126-drillable-sunburst/tanstack.ts", + "reference": { + "renderer": "recharts", + "path": "benchmarks/conformance/cases/126-drillable-sunburst/recharts.ts" + } + } } ] } diff --git a/benchmarks/conformance/definition-coverage-roadmap.json b/benchmarks/conformance/definition-coverage-roadmap.json index 2f870850..e4fc2ebc 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.json +++ b/benchmarks/conformance/definition-coverage-roadmap.json @@ -615,11 +615,12 @@ "phase": "phase-3", "status": "verified", "dependsOn": [], - "frictionIds": ["F-208"], + "frictionIds": ["F-208", "F-264"], "entryPoint": "@tanstack/charts/hierarchy/sunburst", "bundleFixture": "benchmarks/entries/charts-hierarchy-sunburst.ts", "evidence": [ "cases/101-sunburst/tanstack.ts", + "cases/126-drillable-sunburst/tanstack.ts", "../../packages/charts-core/src/hierarchy-sunburst.test.ts", "../entries/charts-hierarchy-sunburst.ts" ] @@ -3614,6 +3615,70 @@ } ] }, + { + "id": "125-sales-funnel", + "coverage": "prepared", + "disposition": "definition-now", + "phase": "phase-0", + "status": "verified", + "capabilities": ["current-definition-api"], + "evidence": [ + "cases/125-sales-funnel/tanstack.ts", + "cases/125-sales-funnel/model.test.ts", + "cases/125-sales-funnel/case.json" + ], + "work": [ + { + "kind": "data-space-layout", + "stage": "before-definition", + "owner": "case", + "coordinateSpace": "data", + "sources": ["model.ts"], + "summary": "Derive centered stage boundary endpoints and label positions from semantic ordered stage values." + }, + { + "kind": "definition-composition", + "stage": "definition-builder", + "owner": "charts", + "coordinateSpace": "none", + "sources": ["tanstack.ts"], + "summary": "Compose native areaX groups and direct text labels over explicit linear domains with stable stage keys." + } + ] + }, + { + "id": "126-drillable-sunburst", + "coverage": "app-composed", + "disposition": "optional-primitive", + "phase": "phase-4", + "status": "verified", + "capabilities": ["hierarchy-sunburst"], + "evidence": [ + "cases/126-drillable-sunburst/tanstack.ts", + "cases/126-drillable-sunburst/tanstack.test.ts", + "cases/126-drillable-sunburst/model.test.ts", + "../../packages/charts-core/src/hierarchy-sunburst.test.ts" + ], + "work": [ + { + "kind": "responsive-pixel-layout", + "stage": "mark-render", + "owner": "charts", + "coordinateSpace": "resolved-plot", + "sources": ["packages/charts-core/src/hierarchy-sunburst.ts"], + "dependencies": ["d3-hierarchy", "d3-shape"], + "summary": "Own focused-root partitioning, bounded visible depth, full-tree aggregation, responsive rings, and stable node geometry." + }, + { + "kind": "application-shell", + "stage": "post-render", + "owner": "application", + "coordinateSpace": "dom", + "sources": ["model.ts", "center.ts", "tanstack.ts"], + "summary": "Retain selected-root state, semantic drill selection, the center back control, and conformance observation." + } + ] + }, { "id": "bar-grouped", "coverage": "declarative", diff --git a/benchmarks/conformance/definition-coverage-roadmap.test.ts b/benchmarks/conformance/definition-coverage-roadmap.test.ts index fbd85c30..951440b9 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(115) - expect(new Set(auditedIds).size).toBe(115) - expect(roadmapIds).toHaveLength(115) - expect(new Set(roadmapIds).size).toBe(115) - expect(overviewIds).toHaveLength(115) - expect(new Set(overviewIds).size).toBe(115) + expect(auditedIds).toHaveLength(117) + expect(new Set(auditedIds).size).toBe(117) + expect(roadmapIds).toHaveLength(117) + expect(new Set(roadmapIds).size).toBe(117) + expect(overviewIds).toHaveLength(117) + expect(new Set(overviewIds).size).toBe(117) expect([...roadmapIds].sort()).toEqual([...auditedIds].sort()) expect([...roadmapIds].sort()).toEqual([...overviewIds].sort()) expect([...roadmapIds].sort()).toEqual([...catalogIds].sort()) @@ -154,24 +154,24 @@ describe('definition coverage roadmap', () => { expect(countBy(roadmap.cases, 'disposition')).toEqual({ 'application-boundary': 2, - 'definition-now': 63, + 'definition-now': 64, 'first-party-primitive': 35, 'inline-custom-mark': 1, - 'optional-primitive': 14, + 'optional-primitive': 15, }) expect(countBy(roadmap.cases, 'phase')).toEqual({ - 'phase-0': 65, + 'phase-0': 66, 'phase-1': 2, 'phase-2': 26, 'phase-3': 11, - 'phase-4': 11, + 'phase-4': 12, }) const roadmapById = new Map(roadmap.cases.map((entry) => [entry.id, entry])) const auditRows = coverageRows(audit, false) const overviewRows = coverageRows(overview, true) - expect(auditRows).toHaveLength(115) - expect(overviewRows).toHaveLength(115) + expect(auditRows).toHaveLength(117) + expect(overviewRows).toHaveLength(117) for (const row of auditRows) { expect(dispositionByLabel[row.disposition]).toBe( roadmapById.get(row.id)?.disposition, diff --git a/benchmarks/conformance/previews/126-drillable-sunburst.svg b/benchmarks/conformance/previews/126-drillable-sunburst.svg new file mode 100644 index 00000000..7a51bc4f --- /dev/null +++ b/benchmarks/conformance/previews/126-drillable-sunburst.svg @@ -0,0 +1 @@ +Use arrow keys to inspect segments and Enter or Space to drill into a branch. Use the center button to move up. diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 8e8b5ce3..8154f55d 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "7b5c4f82e6d298db7532ccf26f93768e0680e0beabe3c45e1f6f974ae876cdfe", + "sourceHash": "f71b56214bc650eab205bd970fb5c9ddabae61717dd5b6378f85a1514081441d", "assets": [ { "id": "01-line-gaps", @@ -578,6 +578,16 @@ "id": "124-theme-palette-matrix", "sha256": "98ef62e53130e9d0ba3ddb375dbde209b56b8348af95b859e593106855345cde", "bytes": 9796 + }, + { + "id": "125-sales-funnel", + "sha256": "67d8bd5f4dd650c1c633a9b37c61988d9d235c36752e0f333c44ee425eb0b442", + "bytes": 3255 + }, + { + "id": "126-drillable-sunburst", + "sha256": "163fd17e46238cb53209a371477b7bc78e196ccd66a385ee3ed2f6b60f675fc6", + "bytes": 9111 } ] } diff --git a/docs/comparison.md b/docs/comparison.md index fcd84a9e..abf3195e 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `49b9f1e` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `7ac3c32` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `49b9f1e`. +at page render time. The measured TanStack workspace revision is `7ac3c32`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-10`. +Baseline date: `2026-08-11`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -135,8 +135,8 @@ browser run, so this page does not publish a cross-machine timing leaderboard. ## Broader conformance -The catalog corpus contains 115 TanStack/reference pairs: 78 sourced from -Observable Plot, 26 from Recharts, and 11 from Apache ECharts. Twenty-two pairs +The catalog corpus contains 117 TanStack/reference pairs: 79 sourced from +Observable Plot, 27 from Recharts, and 11 from Apache ECharts. Twenty-two pairs carry executable interaction scenarios. Those counts describe selected reference coverage, not each library's feature ceiling or a list of built-in TanStack chart types. Chart.js participates in the standard and stress suites, diff --git a/docs/config.json b/docs/config.json index 65ba1282..6964efdf 100644 --- a/docs/config.json +++ b/docs/config.json @@ -758,6 +758,14 @@ { "label": "Theme palette matrix", "to": "/charts/catalog/charts/124-theme-palette-matrix/" + }, + { + "label": "Sales conversion funnel", + "to": "/charts/catalog/charts/125-sales-funnel/" + }, + { + "label": "Drillable Flare sunburst", + "to": "/charts/catalog/charts/126-drillable-sunburst/" } ] }, diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index aeabec57..d2c62200 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -282,6 +282,19 @@ as the other hierarchy entries, aggregates values, and allocates its sectors after the final polar radius resolves. Use `branchId` for inherited branch color and direct `SunburstNode` lineage for tooltips and state callbacks. +For a large hierarchy, keep the complete source data but show only the next +one or two levels below a controlled root. + + + +Set `rootId` to the selected node and `visibleDepth` to the number of descendant +rings. `onSelect` receives the same semantic node for pointer and keyboard +activation. The application owns the selected root and back control; the mark +retains aggregate values and stable node keys. With the motion renderer, +shared descendants interpolate their angles and radii while remaining centered +sectors. Newly revealed nodes unfold from their disappearing parent sector, +and drill-up reverses that relationship. + ## Reveal spatial adjacency A Delaunay network connects points that are neighbors in a triangulation. It diff --git a/docs/reference/marks/sunburst.md b/docs/reference/marks/sunburst.md index a9ebf6c6..bea5841d 100644 --- a/docs/reference/marks/sunburst.md +++ b/docs/reference/marks/sunburst.md @@ -22,6 +22,8 @@ const chart = defineChart({ path: 'name', delimiter: '.', value: 'size', + rootId: '/flare/analytics', + visibleDepth: 2, innerRadius: ({ radius }) => radius * 0.14, ringPadding: 2, color: 'branchId', @@ -84,6 +86,8 @@ independent of the authored delimiter. The original row and path remain on | `parentId` | `TransformValue` | Parent mode | Explicit parent identity | | `value` | `TransformValue` | Required | Nonnegative contribution aggregated through parents | | `sort` | `SunburstNodeComparator` | Source order | Sibling comparator over immutable node values | +| `rootId` | `string` | Hierarchy root | Node whose children form the first rendered ring | +| `visibleDepth` | `number` | All depths | Maximum descendant rings below the active root | | `innerRadius` | `PolarLength` | `0` | Responsive inner edge of the first rendered ring | | `outerRadius` | `PolarLength` | Layout radius | Responsive outer edge of the last rendered ring | | `ringPadding` | `number` | `0` | Fixed CSS-pixel gap between hierarchy depths | @@ -114,6 +118,49 @@ change the hierarchy values or angular allocation. Sectors replay the shared renderer-neutral D3 path commands into a sampled interaction polygon, so rounded, reversed, and complete sectors retain paint-faithful focus geometry. +## Drill-down and motion + +`rootId` makes an existing hierarchy node the structural root without changing +its canonical ID or rebuilding source rows. Its children become depth one, and +the root itself is not painted. `visibleDepth` is relative to that root; hidden +descendants still contribute to aggregate values and `internal` metadata. + +```ts +const definition = (rootId: string) => + defineChart({ + marks: [ + polar({ + marks: [ + sunburst(rows, { + id: 'package-sunburst', + path: 'name', + delimiter: '.', + value: 'size', + rootId, + visibleDepth: 2, + }), + ], + }), + ], + motion: { + transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, + }, + }) +``` + +Rebuild the definition when application navigation changes `rootId`, and mount +it with the optional `motion()` renderer. Retained descendants keep their node +keys. Sunburst motion interpolates each sector's angles and radii, then +regenerates a valid concentric arc around the fixed polar center every frame. +Newly revealed descendants unfold from their nearest disappearing ancestor +sector. During drill-up, removed descendants collapse into their nearest +appearing ancestor. Nodes without an overlapping lineage use the normal enter +or exit opacity transition. The renderer snaps these updates when the user +requests reduced motion. + +In path mode, pass the canonical slash ID such as `/flare/analytics`, not the +authored delimiter spelling. Explicit-parent IDs remain opaque. + ## Nodes and lineage The root is structural and is not painted. Every rendered sector carries one diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index fcd84a9e..abf3195e 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `49b9f1e` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `7ac3c32` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `49b9f1e`. +at page render time. The measured TanStack workspace revision is `7ac3c32`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-10`. +Baseline date: `2026-08-11`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -135,8 +135,8 @@ browser run, so this page does not publish a cross-machine timing leaderboard. ## Broader conformance -The catalog corpus contains 115 TanStack/reference pairs: 78 sourced from -Observable Plot, 26 from Recharts, and 11 from Apache ECharts. Twenty-two pairs +The catalog corpus contains 117 TanStack/reference pairs: 79 sourced from +Observable Plot, 27 from Recharts, and 11 from Apache ECharts. Twenty-two pairs carry executable interaction scenarios. Those counts describe selected reference coverage, not each library's feature ceiling or a list of built-in TanStack chart types. Chart.js participates in the standard and stress suites, diff --git a/packages/charts-core/docs/config.json b/packages/charts-core/docs/config.json index 65ba1282..6964efdf 100644 --- a/packages/charts-core/docs/config.json +++ b/packages/charts-core/docs/config.json @@ -758,6 +758,14 @@ { "label": "Theme palette matrix", "to": "/charts/catalog/charts/124-theme-palette-matrix/" + }, + { + "label": "Sales conversion funnel", + "to": "/charts/catalog/charts/125-sales-funnel/" + }, + { + "label": "Drillable Flare sunburst", + "to": "/charts/catalog/charts/126-drillable-sunburst/" } ] }, diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index aeabec57..d2c62200 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -282,6 +282,19 @@ as the other hierarchy entries, aggregates values, and allocates its sectors after the final polar radius resolves. Use `branchId` for inherited branch color and direct `SunburstNode` lineage for tooltips and state callbacks. +For a large hierarchy, keep the complete source data but show only the next +one or two levels below a controlled root. + + + +Set `rootId` to the selected node and `visibleDepth` to the number of descendant +rings. `onSelect` receives the same semantic node for pointer and keyboard +activation. The application owns the selected root and back control; the mark +retains aggregate values and stable node keys. With the motion renderer, +shared descendants interpolate their angles and radii while remaining centered +sectors. Newly revealed nodes unfold from their disappearing parent sector, +and drill-up reverses that relationship. + ## Reveal spatial adjacency A Delaunay network connects points that are neighbors in a triangulation. It diff --git a/packages/charts-core/docs/reference/marks/sunburst.md b/packages/charts-core/docs/reference/marks/sunburst.md index a9ebf6c6..bea5841d 100644 --- a/packages/charts-core/docs/reference/marks/sunburst.md +++ b/packages/charts-core/docs/reference/marks/sunburst.md @@ -22,6 +22,8 @@ const chart = defineChart({ path: 'name', delimiter: '.', value: 'size', + rootId: '/flare/analytics', + visibleDepth: 2, innerRadius: ({ radius }) => radius * 0.14, ringPadding: 2, color: 'branchId', @@ -84,6 +86,8 @@ independent of the authored delimiter. The original row and path remain on | `parentId` | `TransformValue` | Parent mode | Explicit parent identity | | `value` | `TransformValue` | Required | Nonnegative contribution aggregated through parents | | `sort` | `SunburstNodeComparator` | Source order | Sibling comparator over immutable node values | +| `rootId` | `string` | Hierarchy root | Node whose children form the first rendered ring | +| `visibleDepth` | `number` | All depths | Maximum descendant rings below the active root | | `innerRadius` | `PolarLength` | `0` | Responsive inner edge of the first rendered ring | | `outerRadius` | `PolarLength` | Layout radius | Responsive outer edge of the last rendered ring | | `ringPadding` | `number` | `0` | Fixed CSS-pixel gap between hierarchy depths | @@ -114,6 +118,49 @@ change the hierarchy values or angular allocation. Sectors replay the shared renderer-neutral D3 path commands into a sampled interaction polygon, so rounded, reversed, and complete sectors retain paint-faithful focus geometry. +## Drill-down and motion + +`rootId` makes an existing hierarchy node the structural root without changing +its canonical ID or rebuilding source rows. Its children become depth one, and +the root itself is not painted. `visibleDepth` is relative to that root; hidden +descendants still contribute to aggregate values and `internal` metadata. + +```ts +const definition = (rootId: string) => + defineChart({ + marks: [ + polar({ + marks: [ + sunburst(rows, { + id: 'package-sunburst', + path: 'name', + delimiter: '.', + value: 'size', + rootId, + visibleDepth: 2, + }), + ], + }), + ], + motion: { + transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, + }, + }) +``` + +Rebuild the definition when application navigation changes `rootId`, and mount +it with the optional `motion()` renderer. Retained descendants keep their node +keys. Sunburst motion interpolates each sector's angles and radii, then +regenerates a valid concentric arc around the fixed polar center every frame. +Newly revealed descendants unfold from their nearest disappearing ancestor +sector. During drill-up, removed descendants collapse into their nearest +appearing ancestor. Nodes without an overlapping lineage use the normal enter +or exit opacity transition. The renderer snaps these updates when the user +requests reduced motion. + +In path mode, pass the canonical slash ID such as `/flare/analytics`, not the +authored delimiter spelling. Explicit-parent IDs remain opaque. + ## Nodes and lineage The root is structural and is not painted. Every rendered sector carries one diff --git a/packages/charts-core/src/hierarchy-sunburst.test.ts b/packages/charts-core/src/hierarchy-sunburst.test.ts index 5260f775..2af49bc2 100644 --- a/packages/charts-core/src/hierarchy-sunburst.test.ts +++ b/packages/charts-core/src/hierarchy-sunburst.test.ts @@ -201,6 +201,88 @@ describe('sunburst', () => { expect(JSON.stringify(source)).toBe(before) }) + it('focuses a stable hierarchy root and limits visible descendant rings', () => { + const source = [ + { id: 'root', parent: null as string | null, value: 0 }, + { id: 'alpha', parent: 'root', value: 0 }, + { id: 'beta', parent: 'root', value: 7 }, + { id: 'branch', parent: 'alpha', value: 0 }, + { id: 'sibling', parent: 'alpha', value: 3 }, + { id: 'leaf', parent: 'branch', value: 5 }, + ] + const scene = render( + sunburst(source, { + id: 'focused', + nodeId: 'id', + parentId: 'parent', + value: 'value', + rootId: 'alpha', + visibleDepth: 1, + }), + ) + const byId = new Map(scene.points.map((point) => [point.datum.id, point])) + + expect([...byId.keys()]).toEqual(['branch', 'sibling']) + expect(byId.get('branch')?.datum).toMatchObject({ + parentId: 'alpha', + ancestorIds: ['alpha'], + branchId: 'branch', + depth: 1, + height: 1, + internal: true, + external: false, + value: 5, + }) + expect(byId.get('sibling')?.datum.value).toBe(3) + expect(scene.points.map((point) => point.key)).toEqual([ + 'focused:node:string:6:branch', + 'focused:node:string:7:sibling', + ]) + expect(areaNodes(scene.nodes).map(radialExtent)).toEqual([ + { minimum: 0, maximum: 80 }, + { minimum: 0, maximum: 80 }, + ]) + }) + + it('keeps retained node keys stable while changing the active root', () => { + const source = [ + { id: 'root', parent: null as string | null, value: 0 }, + { id: 'alpha', parent: 'root', value: 0 }, + { id: 'branch', parent: 'alpha', value: 0 }, + { id: 'leaf', parent: 'branch', value: 5 }, + ] + const mark = (rootId: string) => + sunburst(source, { + id: 'drill', + nodeId: 'id', + parentId: 'parent', + value: 'value', + rootId, + visibleDepth: 2, + }) + const overview = render(mark('root')) + const focused = render(mark('alpha')) + const overviewBranch = overview.points.find( + (point) => point.datum.id === 'branch', + ) + const focusedBranch = focused.points.find( + (point) => point.datum.id === 'branch', + ) + + expect(overview.points.map((point) => point.datum.id)).toEqual([ + 'alpha', + 'branch', + ]) + expect(focused.points.map((point) => point.datum.id)).toEqual([ + 'branch', + 'leaf', + ]) + expect(focusedBranch?.key).toBe(overviewBranch?.key) + expect(focusedBranch?.datum.depth).toBe(1) + expect(overviewBranch?.datum.depth).toBe(2) + expect(focusedBranch?.yValue).toBeLessThan(overviewBranch?.yValue ?? 0) + }) + it('preserves slash-containing explicit ids as opaque names', () => { const scene = render( sunburst( @@ -384,7 +466,7 @@ describe('sunburst', () => { expect(colors(withZero)).toEqual(colors(withoutZero)) }) - it('rejects invalid values, sorting, padding, and responsive radii', () => { + it('rejects invalid values, sorting, roots, depth, padding, and responsive radii', () => { for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => sunburst( @@ -428,6 +510,35 @@ describe('sunburst', () => { }), ).toThrow('sunburst: sort result must be finite') + expect(() => + sunburst(pathRows, { + path: 'path', + delimiter: '.', + value: 'value', + rootId: '', + }), + ).toThrow('sunburst: rootId must be a nonempty string') + + expect(() => + sunburst(pathRows, { + path: 'path', + delimiter: '.', + value: 'value', + rootId: '/root/missing', + }), + ).toThrow('sunburst: rootId "/root/missing" does not exist') + + for (const visibleDepth of [0, -1, 1.5, Number.NaN]) { + expect(() => + sunburst(pathRows, { + path: 'path', + delimiter: '.', + value: 'value', + visibleDepth, + }), + ).toThrow('sunburst: visibleDepth must be a positive integer') + } + expect(() => render( sunburst(pathRows, { diff --git a/packages/charts-core/src/hierarchy-sunburst.ts b/packages/charts-core/src/hierarchy-sunburst.ts index 9d16ca28..332a735a 100644 --- a/packages/charts-core/src/hierarchy-sunburst.ts +++ b/packages/charts-core/src/hierarchy-sunburst.ts @@ -11,6 +11,10 @@ import { import { channelValues, isChartKey, isFiniteNumber, visualValue } from './mark' import { createPolarMark } from './polar-mark-internal' import { resolvePolarSector } from './polar-sector-internal' +import { + sceneMotionNode, + type SceneMotionMetadata, +} from './scene-motion-internal' import { valueKey } from './scales' import type { FlatHierarchyDatum, @@ -25,6 +29,7 @@ import type { ChartKey, ChartMarkMotionOptions, ChartPoint, + SceneArea, SceneNode, VisualChannel, } from './types' @@ -58,6 +63,10 @@ interface SunburstSharedOptions extends ChartMarkMotionOptions< readonly className?: string readonly value: TransformValue readonly sort?: SunburstNodeComparator + /** Hierarchy node whose children form the first rendered ring. */ + readonly rootId?: string + /** Maximum descendant rings rendered below the active root. */ + readonly visibleDepth?: number readonly innerRadius?: PolarLength readonly outerRadius?: PolarLength /** Fixed pixel gap between adjacent hierarchy rings. Defaults to zero. */ @@ -154,18 +163,24 @@ export function sunburst( }) } + const layoutRoot = resolveLayoutRoot(hierarchy.root, options.rootId) + const visibleDepth = options.visibleDepth ?? layoutRoot.height + if (options.visibleDepth !== undefined) { + assertPositiveInteger(visibleDepth, 'visibleDepth') + } + const ringPadding = options.ringPadding ?? 0 assertNonnegativeFinite(ringPadding, 'ringPadding') - const ringCount = hierarchy.root.height + const ringCount = Math.min(layoutRoot.height, visibleDepth) const partitioned = createPartition>().size([ 1, - Math.max(1, ringCount + 1), - ])(hierarchy.root) as HierarchyRectangularNode> & + Math.max(1, layoutRoot.height + 1), + ])(layoutRoot) as HierarchyRectangularNode> & FlatHierarchyNode const nodes = partitioned .descendants() .slice(1) - .filter((node) => node.x1 > node.x0) + .filter((node) => node.depth <= visibleDepth && node.x1 > node.x0) .map((node) => ({ node: context(node as FlatHierarchyNode), start: node.x0, @@ -267,7 +282,7 @@ export function sunburst( y: layout.centerY + y, color: fill, } - children.push({ + const area = { kind: 'area', key, points: sector.points, @@ -283,7 +298,21 @@ export function sunburst( opacity: options.opacity, lineJoin: 'round', }, - }) + [sceneMotionNode]: { + path: { + values: [startAngle, endAngle, radius1, radius2], + project: projectSunburstSectorPath, + }, + hierarchy: { + markId: id, + id: node.id, + ancestorIds: node.ancestorIds, + }, + }, + } satisfies SceneArea & { + readonly [sceneMotionNode]: SceneMotionMetadata + } + children.push(area) points.push(point) }) @@ -309,6 +338,25 @@ export function sunburst( ) } +function projectSunburstSectorPath(values: readonly number[]) { + const [startAngle, endAngle, innerRadius, outerRadius] = values + if ( + startAngle === undefined || + endAngle === undefined || + innerRadius === undefined || + outerRadius === undefined + ) { + return undefined + } + return resolvePolarSector({ + startAngle, + endAngle, + innerRadius, + outerRadius, + cornerRadius: 0, + })?.path +} + function sunburstNodeContext( node: FlatHierarchyNode, ): SunburstNode { @@ -340,6 +388,30 @@ function assertNonnegativeFinite(value: unknown, description: string) { } } +function assertPositiveInteger(value: unknown, description: string) { + if (!Number.isInteger(value) || (value as number) < 1) { + throw new TypeError(`sunburst: ${description} must be a positive integer`) + } +} + +function resolveLayoutRoot( + root: FlatHierarchyNode, + rootId: string | undefined, +): FlatHierarchyNode { + if (rootId === undefined) return root.copy() as FlatHierarchyNode + if (typeof rootId !== 'string' || rootId.length === 0) { + throw new TypeError('sunburst: rootId must be a nonempty string') + } + const selected = root + .descendants() + .find((node) => node.data.id === rootId) as + FlatHierarchyNode | undefined + if (!selected) { + throw new TypeError(`sunburst: rootId "${rootId}" does not exist`) + } + return selected.copy() as FlatHierarchyNode +} + function classes(base: string, custom: string | undefined): string { return custom ? `${base} ${custom}` : base } diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 0f130612..6c28e0bd 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -3,6 +3,12 @@ import { resolveFocusGuides } from './focus-presentation' import { resolveMarkStateScene } from './mark-state' import { reconcileChartSvg, reconcileChartSvgFragment } from './reconcile' import { chartSceneSource } from './scene-source' +import { + sceneMotionNode, + type SceneMotionMetadata, + type SceneMotionNode, + type SceneMotionPathGeometry, +} from './scene-motion-internal' import { viewportTranslationChanged } from './scene-point-map' import { createChartSpring } from './spring' import { renderChartSvgWithResources } from './svg-resources' @@ -1229,6 +1235,13 @@ function addUpdateTrack( const pointRollingSnap = pointRolling?.outcome.kind === 'fallback' && pointRolling.outcome.fallback === 'snap' + const semanticPath = addSemanticPathUpdateTrack( + current, + next, + tracks, + context, + timingContext, + ) const nextNames = new Set(next.getAttributeNames()) for (const name of current.getAttributeNames()) { if ( @@ -1244,6 +1257,7 @@ function addUpdateTrack( const target = next.getAttribute(name) const previous = current.getAttribute(name) if (target === previous) continue + if (semanticPath && name === 'd') continue if ( pointRollingSnap && rollingPointGeometryAttributes.has(name) && @@ -1345,6 +1359,84 @@ function addUpdateTrack( }) } +function addSemanticPathUpdateTrack( + current: Element, + next: Element, + tracks: MotionTrack[], + context: MotionReconcileContext, + timingContext: ChartMotionContext | undefined, +) { + if (current.localName !== 'path') return false + const key = current.getAttribute('data-ts-key') + const targetPath = next.getAttribute('d') + if (!key || !targetPath || !context.previousScene) return false + const previous = sceneMotionEntry(context.previousScene, key)?.metadata.path + const target = sceneMotionEntry(context.scene, key)?.metadata.path + const geometry = compatiblePathGeometry(previous, target) + if (!geometry) return false + const resolvedContext = + timingContext ?? elementTimingContext(current, 'update', context.scene) + if (!resolvedContext) return false + const sourceValues = livePathGeometryValues( + current, + geometry.source, + context.runtime, + ) + current.setAttribute('data-ts-motion-role', resolvedContext.role) + tracks.push( + semanticPathTrack({ + element: current, + sourceValues, + target: geometry.target, + targetPath, + timing: context.timingFor(resolvedContext), + runtime: context.runtime, + finish() { + current.removeAttribute('data-ts-motion-role') + }, + cancel() { + current.removeAttribute('data-ts-motion-role') + }, + }), + ) + return true +} + +function semanticPathTrack(options: { + element: Element + sourceValues: readonly number[] + target: SceneMotionPathGeometry + targetPath: string + timing: ResolvedTiming + runtime: MotionRuntime + finish: () => void + cancel: () => void +}): MotionTrack { + const states = elementValueStates( + options.runtime, + options.element, + 'semantic-path', + options.sourceValues, + ) + return { + ...options.timing, + values: bindMotionValues( + states, + options.sourceValues, + options.target.values, + ), + apply(values) { + const path = options.target.project(values) + if (path) options.element.setAttribute('d', path) + }, + finish() { + options.element.setAttribute('d', options.targetPath) + options.finish() + }, + cancel: options.cancel, + } +} + function translatedX(transform: string | null) { if (!transform) return undefined const match = @@ -1447,8 +1539,36 @@ function addEnterMotionTrack( return } + const hierarchyGeometry = hierarchyRelatedGeometry( + element, + context.scene, + context.previousScene, + 'enter', + ) + if (hierarchyGeometry) { + const sourceValues = livePathGeometryValues( + hierarchyGeometry.relatedElement, + hierarchyGeometry.source, + context.runtime, + ) + const sourcePath = hierarchyGeometry.target.project(sourceValues) + if (sourcePath) element.setAttribute('d', sourcePath) + tracks.push( + semanticPathTrack({ + element, + sourceValues, + target: hierarchyGeometry.target, + targetPath: hierarchyGeometry.targetPath, + timing, + runtime: context.runtime, + finish() {}, + cancel() {}, + }), + ) + } + const targetOpacity = element.getAttribute('opacity') - const opacity = Number(targetOpacity ?? 1) + const opacity = finiteOpacity(targetOpacity) element.setAttribute('opacity', '0') const states = elementValueStates(context.runtime, element, 'opacity', [0]) tracks.push({ @@ -1524,6 +1644,36 @@ function addExitMotionTrack( }) return } + const hierarchyGeometry = hierarchyRelatedGeometry( + element, + context.previousScene, + context.scene, + 'exit', + ) + if (hierarchyGeometry) { + const targetPath = hierarchyGeometry.target.project( + hierarchyGeometry.target.values, + ) + if (targetPath) { + const sourceValues = livePathGeometryValues( + element, + hierarchyGeometry.source, + context.runtime, + ) + tracks.push( + semanticPathTrack({ + element, + sourceValues, + target: hierarchyGeometry.target, + targetPath, + timing: context.timingFor(timingContext), + runtime: context.runtime, + finish() {}, + cancel() {}, + }), + ) + } + } const target = Number(element.getAttribute('opacity') ?? 1) const opacity = Number.isFinite(target) ? target : 1 element.setAttribute('data-ts-motion-role', timingContext.role) @@ -1541,6 +1691,143 @@ function addExitMotionTrack( }) } +function hierarchyRelatedGeometry( + element: Element, + ownerScene: ChartScene | undefined, + relatedScene: ChartScene | undefined, + phase: 'enter' | 'exit', +) { + const relation = hierarchyMotionRelation(element, ownerScene, relatedScene) + if (!relation) return undefined + const source = + phase === 'enter' + ? relation.related.metadata.path + : relation.owner.metadata.path + const target = + phase === 'enter' + ? relation.owner.metadata.path + : relation.related.metadata.path + const geometry = compatiblePathGeometry(source, target) + if (!geometry) return undefined + return { + ...geometry, + relatedElement: relation.relatedElement, + targetPath: phase === 'enter' ? relation.ownerPath : relation.relatedPath, + } +} + +function hierarchyMotionRelation( + element: Element, + ownerScene: ChartScene | undefined, + relatedScene: ChartScene | undefined, +) { + if (element.localName !== 'path' || !ownerScene || !relatedScene) { + return undefined + } + const key = element.getAttribute('data-ts-key') + const ownerPath = element.getAttribute('d') + if (!key || !ownerPath) return undefined + const owner = sceneMotionEntry(ownerScene, key) + const hierarchy = owner?.metadata.hierarchy + if (!owner || !hierarchy) return undefined + const related = sceneMotionEntries(relatedScene) + let ancestor: SceneMotionEntry | undefined + for (let index = hierarchy.ancestorIds.length - 1; index >= 0; index -= 1) { + const ancestorId = hierarchy.ancestorIds[index] + ancestor = related.find( + (entry) => + entry.metadata.hierarchy?.markId === hierarchy.markId && + entry.metadata.hierarchy.id === ancestorId, + ) + if (ancestor) break + } + if (!ancestor) return undefined + const root = element.closest('svg') + const relatedElement = root + ? [...root.querySelectorAll('path[data-ts-key]')].find( + (candidate) => + candidate.getAttribute('data-ts-key') === ancestor.node.key, + ) + : undefined + const relatedPath = relatedElement?.getAttribute('d') + if (!relatedElement || !relatedPath) return undefined + return { + owner, + related: ancestor, + relatedElement, + ownerPath, + relatedPath, + } +} + +interface SceneMotionEntry { + node: SceneNode + metadata: SceneMotionMetadata +} + +const sceneMotionEntriesCache = new WeakMap< + ChartScene, + readonly SceneMotionEntry[] +>() + +function sceneMotionEntries(scene: ChartScene) { + const cached = sceneMotionEntriesCache.get(scene) + if (cached) return cached + const entries: SceneMotionEntry[] = [] + const visit = (nodes: readonly SceneNode[]) => { + for (const node of nodes) { + const metadata = (node as SceneMotionNode)[sceneMotionNode] + if (metadata) entries.push({ node, metadata }) + if (node.kind === 'group') visit(node.children) + } + } + visit(scene.nodes) + sceneMotionEntriesCache.set(scene, entries) + return entries +} + +function sceneMotionEntry(scene: ChartScene, key: string) { + return sceneMotionEntries(scene).find((entry) => entry.node.key === key) +} + +function compatiblePathGeometry( + source: SceneMotionPathGeometry | undefined, + target: SceneMotionPathGeometry | undefined, +) { + if ( + !source || + !target || + source.project !== target.project || + source.values.length !== target.values.length || + source.values.length === 0 + ) { + return undefined + } + return { source, target } +} + +function livePathGeometryValues( + element: Element, + geometry: SceneMotionPathGeometry, + runtime: MotionRuntime, +) { + const states = elementValueStates( + runtime, + element, + 'semantic-path', + geometry.values, + ) + const staticPath = geometry.project(geometry.values) + return staticPath && element.getAttribute('d') === staticPath + ? geometry.values + : states.map((state) => state.value) +} + +function finiteOpacity(value: string | null) { + const opacity = Number(value ?? 1) + return Number.isFinite(opacity) ? opacity : 1 +} + function elementTimingContext( element: Element, phase: ChartMotionPhase, diff --git a/packages/charts-core/src/scene-motion-internal.ts b/packages/charts-core/src/scene-motion-internal.ts new file mode 100644 index 00000000..c5c46cf6 --- /dev/null +++ b/packages/charts-core/src/scene-motion-internal.ts @@ -0,0 +1,23 @@ +import type { SceneNode } from './types' + +export const sceneMotionNode = Symbol('scene-motion-node') + +export interface SceneMotionPathGeometry { + readonly values: readonly number[] + readonly project: (values: readonly number[]) => string | undefined +} + +export interface SceneMotionHierarchy { + readonly markId: string + readonly id: string + readonly ancestorIds: readonly string[] +} + +export interface SceneMotionMetadata { + readonly path?: SceneMotionPathGeometry + readonly hierarchy?: SceneMotionHierarchy +} + +export type SceneMotionNode = SceneNode & { + readonly [sceneMotionNode]?: SceneMotionMetadata +} diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 31126505..3ee3123e 100644 --- a/scripts/catalog-definition-shapes.test.mjs +++ b/scripts/catalog-definition-shapes.test.mjs @@ -57,9 +57,9 @@ describe('catalog definition shapes', () => { ) expect(classification.parameterless).toEqual([]) - expect(classification.static).toBe(115) + expect(classification.static).toBe(117) expect(classification.responsive.sort()).toEqual(responsiveDefinitions) - expect(classification.static + classification.responsive.length).toBe(120) + expect(classification.static + classification.responsive.length).toBe(122) }) it('classifies the base definition once when options are added', () => { diff --git a/scripts/catalog-preview.mjs b/scripts/catalog-preview.mjs index cdb60c23..28494304 100644 --- a/scripts/catalog-preview.mjs +++ b/scripts/catalog-preview.mjs @@ -117,6 +117,7 @@ export const catalogTextPreviewCaseIds = [ '121-active-bar-dashboard', '123-active-donut-metric', '125-sales-funnel', + '126-drillable-sunburst', 'bar-horizontal-ranking', 'heatmap-labeled', ] diff --git a/scripts/catalog-preview.test.mjs b/scripts/catalog-preview.test.mjs index dae6831b..afe9d814 100644 --- a/scripts/catalog-preview.test.mjs +++ b/scripts/catalog-preview.test.mjs @@ -139,6 +139,8 @@ describe('catalog previews', () => { '120-themed-interactive-area', '121-active-bar-dashboard', '123-active-donut-metric', + '125-sales-funnel', + '126-drillable-sunburst', 'bar-horizontal-ranking', 'heatmap-labeled', ]) diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index feb0b2f6..c2dc56f9 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -88,6 +88,9 @@ const retainedInputGroups = { tooltipExtension: [/(?:^|\/)packages\/charts-core\/src\/tooltip\.ts$/u], tooltipPortal: [/(?:^|\/)packages\/charts-core\/src\/tooltip-portal\.ts$/u], motionRuntime: [/(?:^|\/)packages\/charts-core\/src\/motion\.ts$/u], + sceneMotionContract: [ + /(?:^|\/)packages\/charts-core\/src\/scene-motion-internal\.ts$/u, + ], springRuntime: [/(?:^|\/)packages\/charts-core\/src\/spring\.ts$/u], focusGuide: [/(?:^|\/)packages\/charts-core\/src\/focus-guide\.ts$/u], focusMark: [/(?:^|\/)packages\/charts-core\/src\/focus-mark\.ts$/u], @@ -572,12 +575,13 @@ const entries = [ 'Hierarchy sunburst mark', 'benchmarks/entries/charts-hierarchy-sunburst.ts', 'D3 hierarchy partition kernel', - 5.1, + 5.4, { inputBoundary: { require: [ 'hierarchyFlat', 'hierarchySunburst', + 'sceneMotionContract', 'polarMarkInfrastructure', 'polarSector', 'd3Hierarchy', @@ -588,6 +592,7 @@ const entries = [ allowAdded: [ 'hierarchyFlat', 'hierarchySunburst', + 'sceneMotionContract', 'polarMarkInfrastructure', 'polarSector', 'markInfrastructure', @@ -1660,17 +1665,21 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 17.2, + 18.1, { rendererBoundary: 'svg', inputBoundary: { - require: ['motionRuntime', 'springRuntime'], + require: ['motionRuntime', 'sceneMotionContract', 'springRuntime'], forbid: [ 'tooltipExtension', 'tooltipPortal', 'transformRuntime', 'd3Array', 'd3ScaleRuntime', + 'd3Shape', + 'd3Path', + 'polarSector', + ...optionalHierarchyInputGroups, ], }, }, From a338d11ee8e651c4a4c09651dcbb661597154668 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 10 Aug 2026 18:55:48 -0600 Subject: [PATCH 3/3] Fix stress benchmark timeout handling --- API-FRICTION.md | 22 +++++++++++++++++++++- scripts/benchmark/cell-timeout.mjs | 6 ++++++ scripts/benchmark/cell-timeout.test.mjs | 13 +++++++++++++ scripts/stress-chart-libraries.mjs | 8 +------- 4 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 scripts/benchmark/cell-timeout.mjs create mode 100644 scripts/benchmark/cell-timeout.test.mjs diff --git a/API-FRICTION.md b/API-FRICTION.md index 09e83631..3355ea55 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-10 +Last updated: 2026-08-11 ## Triage rule @@ -303,6 +303,7 @@ Each entry records: | F-264 | Drillable sunbursts required rebuilding hierarchy rows | API/Documentation | resolved | | F-265 | Sunburst motion lost hierarchy across enter and exit | API | resolved | | F-266 | Path-token motion distorted polar sectors | API/Tooling | resolved | +| F-267 | Stress timeouts entered a class temporal dead zone | Tooling | resolved | ## Findings @@ -7872,3 +7873,22 @@ Each entry records: reduced motion snaps, and retained-input bundle gates require only the small scene-motion contract while forbidding sunburst, hierarchy, polar-sector, d3-shape, and d3-path inputs from the isolated motion bundle. + +### F-267 — Stress timeouts entered a class temporal dead zone + +- Status: resolved +- Severity: high +- Owner: Tooling +- Observed in: release pull request stress partition 1 +- Friction: the stress runner began its top-level browser workload before + evaluating a later `CellTimeoutError` class declaration. When one cell + reached the intended 120-second outer limit, the timeout callback threw a + `ReferenceError` instead of returning the retryable timeout result, aborting + the complete partition before its fresh-context retry. +- Decision: define the timeout error in an imported benchmark module. Module + dependencies finish evaluation before the stress runner starts top-level + work, so the timeout path cannot observe an uninitialized class. +- Verification: the focused timeout regression constructs the imported error + with the expected prototype, name, and duration message. The retry suite, + stress-runner syntax check, full repository validation, and rerun GitHub + stress partition pass. diff --git a/scripts/benchmark/cell-timeout.mjs b/scripts/benchmark/cell-timeout.mjs new file mode 100644 index 00000000..8bdeeac5 --- /dev/null +++ b/scripts/benchmark/cell-timeout.mjs @@ -0,0 +1,6 @@ +export class CellTimeoutError extends Error { + constructor(timeoutMs) { + super(`Cell exceeded ${timeoutMs} ms.`) + this.name = 'CellTimeoutError' + } +} diff --git a/scripts/benchmark/cell-timeout.test.mjs b/scripts/benchmark/cell-timeout.test.mjs new file mode 100644 index 00000000..a15c00f6 --- /dev/null +++ b/scripts/benchmark/cell-timeout.test.mjs @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { CellTimeoutError } from './cell-timeout.mjs' + +describe('CellTimeoutError', () => { + it('is initialized before benchmark top-level execution can time out', () => { + const error = new CellTimeoutError(120_000) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(CellTimeoutError) + expect(error.name).toBe('CellTimeoutError') + expect(error.message).toBe('Cell exceeded 120000 ms.') + }) +}) diff --git a/scripts/stress-chart-libraries.mjs b/scripts/stress-chart-libraries.mjs index 7c2ada6c..a208b549 100644 --- a/scripts/stress-chart-libraries.mjs +++ b/scripts/stress-chart-libraries.mjs @@ -7,6 +7,7 @@ import { launchBenchmarkBrowser, startBenchmarkServer, } from './benchmark/browser.mjs' +import { CellTimeoutError } from './benchmark/cell-timeout.mjs' import { chartLibraries } from './benchmark/chart-libraries.mjs' import { assertKnownFilterValues, @@ -340,13 +341,6 @@ async function runIsolated( } } -class CellTimeoutError extends Error { - constructor(timeoutMs) { - super(`Cell exceeded ${timeoutMs} ms.`) - this.name = 'CellTimeoutError' - } -} - function isRetryableCellInfrastructureError(error, stage) { if (stage === 'context' || error instanceof CellTimeoutError) return true if (!(error instanceof Error) || error.message.startsWith('Page errors:')) {