From 0868e804263c9102838a8dca82f5aa29ffc2e4f6 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 14:10:02 -1000 Subject: [PATCH 1/5] feat(focus): add a grid focus strategy with directional keyboard steps Add an optional step(points, context) method to ChartFocusStrategy so a strategy can choose the next point for a navigation key. Strategies without step keep their navigation order. Ship focusGrid from @tanstack/charts/focus/grid for row-and-column movement in heatmaps, calendars, and matrices. --- .changeset/grid-focus-strategy.md | 5 + API-FRICTION.md | 25 ++ .../bundle-size/universal-baseline.json | 16 +- benchmarks/comparison/bundle-baseline.json | 126 +++++----- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 8 +- docs/reference/focus-and-interaction.md | 98 ++++++++ docs/reference/index.md | 1 + packages/charts-core/README.md | 2 + packages/charts-core/docs/comparison.md | 8 +- .../docs/reference/focus-and-interaction.md | 98 ++++++++ packages/charts-core/docs/reference/index.md | 1 + packages/charts-core/package.json | 5 + packages/charts-core/src/focus-grid.test.ts | 146 ++++++++++++ packages/charts-core/src/focus-grid.ts | 219 ++++++++++++++++++ packages/charts-core/src/index.ts | 1 + packages/charts-core/src/renderer.ts | 20 +- packages/charts-core/src/types.ts | 21 ++ packages/charts-core/src/universal-types.ts | 1 + scripts/measure-bundles.mjs | 12 +- scripts/public-callback-contract.mjs | 6 +- 21 files changed, 727 insertions(+), 94 deletions(-) create mode 100644 .changeset/grid-focus-strategy.md create mode 100644 packages/charts-core/src/focus-grid.test.ts create mode 100644 packages/charts-core/src/focus-grid.ts diff --git a/.changeset/grid-focus-strategy.md b/.changeset/grid-focus-strategy.md new file mode 100644 index 00000000..5e362636 --- /dev/null +++ b/.changeset/grid-focus-strategy.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add `focusGrid` from `@tanstack/charts/focus/grid` for row-and-column keyboard focus in heatmaps, calendars, and matrices, and an optional `step` method on `ChartFocusStrategy` for directional keyboard moves. Strategies without `step` keep their existing navigation order. diff --git a/API-FRICTION.md b/API-FRICTION.md index 1c43acbb..faec0bbb 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -343,6 +343,7 @@ Each entry records: | F-304 | Gradient resources hid cross-renderer ownership | API | resolved | | F-305 | Categorical legend styling required a replacement renderer | API | resolved | | F-306 | Inferred consumer declarations could not name core types | Tooling | resolved | +| F-307 | Heatmap keyboard focus moved through one flat list | API | open | ## Findings @@ -9045,3 +9046,27 @@ Each entry records: with the pinned TypeScript compiler and typechecks the generated declarations as a downstream consumer. Packed runtime, React Native, and seven framework adapter gates pass. All 60 comparison bundle measurements are unchanged. + +### F-307 - Heatmap keyboard focus moved through one flat list + +- Status: open +- Severity: medium +- Owner: API +- Observed in: PMDB, a consumer application, which renders its person-by-week + capacity heatmap as an HTML table with one button per cell instead of a + chart. Chart keyboard focus was one reason: every strategy exposes one + ordered `navigation` list, and the DOM host maps ArrowDown to ArrowRight and + ArrowUp to ArrowLeft over it. +- Friction: in a matrix, a keyboard reader could not move to the same week for + the next person, or to the start of a row, without stepping through every + cell between. Reproducing grid movement meant leaving chart focus and + building an application-owned widget. +- Decision (draft): add an optional `step(points, context)` method to + `ChartFocusStrategy` for directional keys, with `navigation` as the fallback + and the default for existing strategies. Ship `focusGrid` from the separate + `@tanstack/charts/focus/grid` entry point so charts that do not import it do + not grow. The final contract shape is left to maintainer review. +- Verification: unit tests cover row and column moves, sparse skips, row and + grid extremes, the row-major fallback order, and a mounted heatmap whose + tooltip names both the row and column after keyboard moves. The built-in + axis strategies have no `step` and keep their existing tests. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index f0ebd0b4..b215e97e 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 30340 }, "TanStack DOM host": { - "bytes": 81870, - "gzip": 28574 + "bytes": 81936, + "gzip": 28615 }, "React adapter": { - "bytes": 84195, - "gzip": 29518 + "bytes": 84261, + "gzip": 29560 }, "React line consumer": { - "bytes": 107854, - "gzip": 38849 + "bytes": 107920, + "gzip": 38889 }, "Compact-scale line scene": { "bytes": 35865, "gzip": 12732 }, "React compact-scale line consumer": { - "bytes": 90354, - "gzip": 31746 + "bytes": 90420, + "gzip": 31787 }, "Custom-scale line scene": { "bytes": 34047, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 37d5af0a..151c022f 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-30T14:33:25.271Z", + "generatedAt": "2026-10-01T00:07:33.301Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "4bbe1c5690789064f2c525226321baa5817af39a", - "inputDigest": "sha256:b07d6531a6ef39c0c4e9295f2b3d5335e67a38f9255517426f757a3efedecaad" + "revision": "b215626e4730ff1edd82995cdf9d73253b0fd1f1", + "inputDigest": "sha256:158485c88d1cabd11bbfe24a4f35a7c36702a8b2ea2fced9e31e6eadc68befb9" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117941, - "gzipBytes": 42555, - "brotliBytes": 37623, - "incrementalGzipBytes": 42555, - "incrementalBrotliBytes": 37623 + "minifiedBytes": 118007, + "gzipBytes": 42594, + "brotliBytes": 37802, + "incrementalGzipBytes": 42594, + "incrementalBrotliBytes": 37802 }, "tanstack-line-interactive": { - "minifiedBytes": 123686, - "gzipBytes": 44460, - "brotliBytes": 39218, - "incrementalGzipBytes": 44460, - "incrementalBrotliBytes": 39218 + "minifiedBytes": 123752, + "gzipBytes": 44502, + "brotliBytes": 39247, + "incrementalGzipBytes": 44502, + "incrementalBrotliBytes": 39247 }, "tanstack-line-advanced": { - "minifiedBytes": 130878, - "gzipBytes": 46798, - "brotliBytes": 41196, - "incrementalGzipBytes": 46798, - "incrementalBrotliBytes": 41196 + "minifiedBytes": 130944, + "gzipBytes": 46835, + "brotliBytes": 41207, + "incrementalGzipBytes": 46835, + "incrementalBrotliBytes": 41207 }, "tanstack-bar-basic": { - "minifiedBytes": 130379, - "gzipBytes": 47284, - "brotliBytes": 41635, - "incrementalGzipBytes": 47284, - "incrementalBrotliBytes": 41635 + "minifiedBytes": 130445, + "gzipBytes": 47327, + "brotliBytes": 41592, + "incrementalGzipBytes": 47327, + "incrementalBrotliBytes": 41592 }, "tanstack-bar-interactive": { - "minifiedBytes": 134983, - "gzipBytes": 48679, - "brotliBytes": 42750, - "incrementalGzipBytes": 48679, - "incrementalBrotliBytes": 42750 + "minifiedBytes": 135049, + "gzipBytes": 48719, + "brotliBytes": 42765, + "incrementalGzipBytes": 48719, + "incrementalBrotliBytes": 42765 }, "tanstack-bar-advanced": { - "minifiedBytes": 135322, - "gzipBytes": 48828, - "brotliBytes": 42851, - "incrementalGzipBytes": 48828, - "incrementalBrotliBytes": 42851 + "minifiedBytes": 135388, + "gzipBytes": 48868, + "brotliBytes": 42895, + "incrementalGzipBytes": 48868, + "incrementalBrotliBytes": 42895 }, "tanstack-area-basic": { - "minifiedBytes": 123121, - "gzipBytes": 44596, - "brotliBytes": 39388, - "incrementalGzipBytes": 44596, - "incrementalBrotliBytes": 39388 + "minifiedBytes": 123187, + "gzipBytes": 44643, + "brotliBytes": 39532, + "incrementalGzipBytes": 44643, + "incrementalBrotliBytes": 39532 }, "tanstack-area-interactive": { - "minifiedBytes": 128876, - "gzipBytes": 46452, - "brotliBytes": 40950, - "incrementalGzipBytes": 46452, - "incrementalBrotliBytes": 40950 + "minifiedBytes": 128942, + "gzipBytes": 46493, + "brotliBytes": 41026, + "incrementalGzipBytes": 46493, + "incrementalBrotliBytes": 41026 }, "tanstack-area-advanced": { - "minifiedBytes": 136250, - "gzipBytes": 48807, - "brotliBytes": 43047, - "incrementalGzipBytes": 48807, - "incrementalBrotliBytes": 43047 + "minifiedBytes": 136316, + "gzipBytes": 48847, + "brotliBytes": 43045, + "incrementalGzipBytes": 48847, + "incrementalBrotliBytes": 43045 }, "tanstack-scatter-basic": { - "minifiedBytes": 118947, - "gzipBytes": 42908, - "brotliBytes": 37974, - "incrementalGzipBytes": 42908, - "incrementalBrotliBytes": 37974 + "minifiedBytes": 119013, + "gzipBytes": 42944, + "brotliBytes": 37986, + "incrementalGzipBytes": 42944, + "incrementalBrotliBytes": 37986 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124696, - "gzipBytes": 44826, - "brotliBytes": 39490, - "incrementalGzipBytes": 44826, - "incrementalBrotliBytes": 39490 + "minifiedBytes": 124762, + "gzipBytes": 44869, + "brotliBytes": 39561, + "incrementalGzipBytes": 44869, + "incrementalBrotliBytes": 39561 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124712, - "gzipBytes": 44832, - "brotliBytes": 39527, - "incrementalGzipBytes": 44832, - "incrementalBrotliBytes": 39527 + "minifiedBytes": 124778, + "gzipBytes": 44876, + "brotliBytes": 39532, + "incrementalGzipBytes": 44876, + "incrementalBrotliBytes": 39532 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index bc8af86a..30dba0d9 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "4b5e1de199530168188592df376662b15aa5c570dc24bc742e761d312e5fe141", + "sourceHash": "c18620388c28905214c746dcb91069f332112e778c4b5d1920f1ab78bdb1bca0", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 1e349fe5..72236666 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 `4bbe1c5` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b215626` | | [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 `4bbe1c5`. +at page render time. The measured TanStack workspace revision is `b215626`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-30`. +Baseline date: `2026-10-01`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.60–47.72 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 1c33c30a..513b69aa 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -177,6 +177,93 @@ or direct strategy use. The exact exported objects receive the same host-level containment behavior as their presets. A strategy that wraps or copies one of them is custom and owns its complete pointer resolution. +### Grid focus + +Heatmaps, calendars, and other matrices read by row and column. Import +`focusGrid` from `@tanstack/charts/focus/grid` to move keyboard focus in two +dimensions: + +| Key | Moves to | +| ------------------------------------ | -------------------------------------------- | +| Left and Right arrows | The previous or next cell in the same row | +| Up and Down arrows | The previous or next cell in the same column | +| `Home` and `End` | The first or last cell in the current row | +| Control or Command with `Home`/`End` | The first or last cell in the grid | + +Rows are points that share a scene-pixel y coordinate and columns share a +scene-pixel x coordinate, so band-scale cells line up without configuration. +Missing cells are skipped in the direction of travel, and focus stays on the +edge cell when there is nothing further. Moves follow the painted layout, so +Right always moves toward the right of the plot, including in right-to-left +documents. A label drawn over a cell shares that cell's position and does not +add a second stop. Pointer focus picks the cell whose center is nearest, and +any pointer inside a cell's grid pitch targets that cell. + +The focused point still carries its `xValue` and `yValue`, so the built-in +tooltip, which is a polite status region, announces the column and row labels +for each move. + +```ts group=grid-focus env=charts file=/src/chart.ts entry +import { cell, defineChart } from '@tanstack/charts' +import { focusGrid } from '@tanstack/charts/focus/grid' +import { scaleBand } from '@tanstack/charts/scales/band' +import { tooltip } from '@tanstack/charts/tooltip' +import { scaleLinear } from 'd3-scale' +import { capacity, people, weeks } from './data' + +export default defineChart({ + marks: [ + cell(capacity, { + x: 'week', + y: 'person', + color: 'percent', + key: (row) => `${row.person}-${row.week}`, + inset: 1, + }), + ], + scales: { + x: { + scale: () => scaleBand().domain(weeks).padding(0.04), + axis: { label: 'Week' }, + }, + y: { + scale: () => scaleBand().domain(people).padding(0.04), + axis: { label: 'Person' }, + }, + }, + color: { + scale: () => + scaleLinear().domain([0, 125]).range(['#f8fafc', '#2563eb']), + }, + focus: focusGrid, + tooltip, +}) +``` + +```ts group=grid-focus file=/src/data.ts collapsed +export const weeks = ['Sep 1', 'Sep 8', 'Sep 15', 'Sep 22'] +export const people = ['Avery', 'Blake', 'Casey'] + +// Blake has no allocation in the week of Sep 8, so that cell is skipped. +export const capacity = [ + { person: 'Avery', week: 'Sep 1', percent: 80 }, + { person: 'Avery', week: 'Sep 8', percent: 100 }, + { person: 'Avery', week: 'Sep 15', percent: 110 }, + { person: 'Avery', week: 'Sep 22', percent: 90 }, + { person: 'Blake', week: 'Sep 1', percent: 40 }, + { person: 'Blake', week: 'Sep 15', percent: 60 }, + { person: 'Blake', week: 'Sep 22', percent: 75 }, + { person: 'Casey', week: 'Sep 1', percent: 120 }, + { person: 'Casey', week: 'Sep 8', percent: 95 }, + { person: 'Casey', week: 'Sep 15', percent: 100 }, + { person: 'Casey', week: 'Sep 22', percent: 70 }, +] +``` + +The DOM host and framework adapters use the directional moves. The D3 +companion host and React Native `Chart` use `navigation` only, which +`focusGrid` returns row by row. + `focusGroupAngle` is available from `@tanstack/charts/polar`. It resolves the nearest radial ray, groups points with the same semantic angle value, and orders keyboard tasks by angle. Use it for grouped radar, polar-line, and @@ -761,6 +848,11 @@ interface ChartFocusStrategy< navigation( points: readonly ChartPoint[], ): readonly ChartPoint[] + + step?( + points: readonly ChartPoint[], + context: ChartFocusStepContext, + ): ChartPoint | undefined } ``` @@ -769,6 +861,12 @@ interface ChartFocusStrategy< the point restored or reached through keyboard navigation. `navigation` returns the ordered keyboard task set. +The optional `step` handles directional keys. `ChartFocusStepContext` +contains the focused `point` (or `null`), the DOM `key`, and `modifier`, which +is true when Control or Command is held. Return the next point, or +`undefined` to use `navigation` order for that key. A strategy +without `step` keeps linear navigation. + `ChartFocusMode` accepts a `ChartFocusPreset` string or a `ChartFocusStrategy`. diff --git a/docs/reference/index.md b/docs/reference/index.md index aa294b54..52dd2380 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -141,6 +141,7 @@ aggregate `/scales` export. | `@tanstack/charts/facet` | `facet`, `facetChart` | | `@tanstack/charts/focus` | `focusGroupX`, `focusGroupY`, `focusNearestX`, `focusNearestY` | | `@tanstack/charts/focus/disabled` | `focusDisabled` | +| `@tanstack/charts/focus/grid` | `focusGrid` | | `@tanstack/charts/focus/guide` | `focusGuideX`, `focusGuideY`, `FocusGuideLabelFormatContext`, and focus-guide option types | | `@tanstack/charts/focus/mark` | `whenFocused` | | `@tanstack/charts/frame` | `frame` | diff --git a/packages/charts-core/README.md b/packages/charts-core/README.md index 9e405767..8c1c6240 100644 --- a/packages/charts-core/README.md +++ b/packages/charts-core/README.md @@ -237,6 +237,8 @@ ordinary bundles. - Optional grouped pointer focus from `@tanstack/charts/focus` - Optional native-focus suppression for application-owned gestures from `@tanstack/charts/focus/disabled` +- Optional row-and-column keyboard focus for heatmaps and matrices from + `@tanstack/charts/focus/grid` - Optional gradients and clipping from `@tanstack/charts/svg/resources` Every built-in mark, renderer, and chart-owned optional capability has a diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 1e349fe5..72236666 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 `4bbe1c5` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b215626` | | [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 `4bbe1c5`. +at page render time. The measured TanStack workspace revision is `b215626`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-30`. +Baseline date: `2026-10-01`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.60–47.72 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 1c33c30a..513b69aa 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -177,6 +177,93 @@ or direct strategy use. The exact exported objects receive the same host-level containment behavior as their presets. A strategy that wraps or copies one of them is custom and owns its complete pointer resolution. +### Grid focus + +Heatmaps, calendars, and other matrices read by row and column. Import +`focusGrid` from `@tanstack/charts/focus/grid` to move keyboard focus in two +dimensions: + +| Key | Moves to | +| ------------------------------------ | -------------------------------------------- | +| Left and Right arrows | The previous or next cell in the same row | +| Up and Down arrows | The previous or next cell in the same column | +| `Home` and `End` | The first or last cell in the current row | +| Control or Command with `Home`/`End` | The first or last cell in the grid | + +Rows are points that share a scene-pixel y coordinate and columns share a +scene-pixel x coordinate, so band-scale cells line up without configuration. +Missing cells are skipped in the direction of travel, and focus stays on the +edge cell when there is nothing further. Moves follow the painted layout, so +Right always moves toward the right of the plot, including in right-to-left +documents. A label drawn over a cell shares that cell's position and does not +add a second stop. Pointer focus picks the cell whose center is nearest, and +any pointer inside a cell's grid pitch targets that cell. + +The focused point still carries its `xValue` and `yValue`, so the built-in +tooltip, which is a polite status region, announces the column and row labels +for each move. + +```ts group=grid-focus env=charts file=/src/chart.ts entry +import { cell, defineChart } from '@tanstack/charts' +import { focusGrid } from '@tanstack/charts/focus/grid' +import { scaleBand } from '@tanstack/charts/scales/band' +import { tooltip } from '@tanstack/charts/tooltip' +import { scaleLinear } from 'd3-scale' +import { capacity, people, weeks } from './data' + +export default defineChart({ + marks: [ + cell(capacity, { + x: 'week', + y: 'person', + color: 'percent', + key: (row) => `${row.person}-${row.week}`, + inset: 1, + }), + ], + scales: { + x: { + scale: () => scaleBand().domain(weeks).padding(0.04), + axis: { label: 'Week' }, + }, + y: { + scale: () => scaleBand().domain(people).padding(0.04), + axis: { label: 'Person' }, + }, + }, + color: { + scale: () => + scaleLinear().domain([0, 125]).range(['#f8fafc', '#2563eb']), + }, + focus: focusGrid, + tooltip, +}) +``` + +```ts group=grid-focus file=/src/data.ts collapsed +export const weeks = ['Sep 1', 'Sep 8', 'Sep 15', 'Sep 22'] +export const people = ['Avery', 'Blake', 'Casey'] + +// Blake has no allocation in the week of Sep 8, so that cell is skipped. +export const capacity = [ + { person: 'Avery', week: 'Sep 1', percent: 80 }, + { person: 'Avery', week: 'Sep 8', percent: 100 }, + { person: 'Avery', week: 'Sep 15', percent: 110 }, + { person: 'Avery', week: 'Sep 22', percent: 90 }, + { person: 'Blake', week: 'Sep 1', percent: 40 }, + { person: 'Blake', week: 'Sep 15', percent: 60 }, + { person: 'Blake', week: 'Sep 22', percent: 75 }, + { person: 'Casey', week: 'Sep 1', percent: 120 }, + { person: 'Casey', week: 'Sep 8', percent: 95 }, + { person: 'Casey', week: 'Sep 15', percent: 100 }, + { person: 'Casey', week: 'Sep 22', percent: 70 }, +] +``` + +The DOM host and framework adapters use the directional moves. The D3 +companion host and React Native `Chart` use `navigation` only, which +`focusGrid` returns row by row. + `focusGroupAngle` is available from `@tanstack/charts/polar`. It resolves the nearest radial ray, groups points with the same semantic angle value, and orders keyboard tasks by angle. Use it for grouped radar, polar-line, and @@ -761,6 +848,11 @@ interface ChartFocusStrategy< navigation( points: readonly ChartPoint[], ): readonly ChartPoint[] + + step?( + points: readonly ChartPoint[], + context: ChartFocusStepContext, + ): ChartPoint | undefined } ``` @@ -769,6 +861,12 @@ interface ChartFocusStrategy< the point restored or reached through keyboard navigation. `navigation` returns the ordered keyboard task set. +The optional `step` handles directional keys. `ChartFocusStepContext` +contains the focused `point` (or `null`), the DOM `key`, and `modifier`, which +is true when Control or Command is held. Return the next point, or +`undefined` to use `navigation` order for that key. A strategy +without `step` keeps linear navigation. + `ChartFocusMode` accepts a `ChartFocusPreset` string or a `ChartFocusStrategy`. diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index aa294b54..52dd2380 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -141,6 +141,7 @@ aggregate `/scales` export. | `@tanstack/charts/facet` | `facet`, `facetChart` | | `@tanstack/charts/focus` | `focusGroupX`, `focusGroupY`, `focusNearestX`, `focusNearestY` | | `@tanstack/charts/focus/disabled` | `focusDisabled` | +| `@tanstack/charts/focus/grid` | `focusGrid` | | `@tanstack/charts/focus/guide` | `focusGuideX`, `focusGuideY`, `FocusGuideLabelFormatContext`, and focus-guide option types | | `@tanstack/charts/focus/mark` | `whenFocused` | | `@tanstack/charts/frame` | `frame` | diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index f93100f9..e35702a6 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -137,6 +137,7 @@ "./focus/guide": "./src/focus-guide.ts", "./focus/mark": "./src/focus-mark.ts", "./focus/disabled": "./src/focus-disabled.ts", + "./focus/grid": "./src/focus-grid.ts", "./frame": "./src/frame.ts", "./group": "./src/group.ts", "./geo": "./src/geo.ts", @@ -339,6 +340,10 @@ "types": "./dist/focus-disabled.d.ts", "import": "./dist/focus-disabled.js" }, + "./focus/grid": { + "types": "./dist/focus-grid.d.ts", + "import": "./dist/focus-grid.js" + }, "./frame": { "types": "./dist/frame.d.ts", "import": "./dist/frame.js" diff --git a/packages/charts-core/src/focus-grid.test.ts b/packages/charts-core/src/focus-grid.test.ts new file mode 100644 index 00000000..92272c26 --- /dev/null +++ b/packages/charts-core/src/focus-grid.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { mountChart } from './dom' +import { focusGroupX, focusGroupY, focusNearestX, focusNearestY } from './focus' +import { focusGrid } from './focus-grid' +import { cell } from './rect' +import { defineChart } from './scene' +import { bandAxes } from './test-scales' +import { tooltip } from './tooltip' +import type { ChartPoint } from './types' + +// A 3x3 grid with the center cell missing, plus a label point that shares the +// top-left cell. Rows are y = 0, 10, 20 and columns are x = 0, 10, 20. +const points: ChartPoint[] = [ + point('a1', 0, 0), + point('a1-label', 0, 0), + point('a2', 10, 0), + point('a3', 20, 0), + point('b1', 0, 10), + point('b3', 20, 10), + point('c1', 0, 20), + point('c2', 10, 20), + point('c3', 20, 20), +] + +function step(from: string | null, key: string, modifier = false) { + const current = from + ? (points.find((candidate) => candidate.key === from) ?? null) + : null + return focusGrid.step(points, { point: current, key, modifier })?.key +} + +describe('grid focus strategy', () => { + it('moves within rows and columns and skips missing cells', () => { + expect(step('a1', 'ArrowRight')).toBe('a2') + expect(step('a2', 'ArrowLeft')).toBe('a1') + expect(step('a2', 'ArrowDown')).toBe('c2') + expect(step('c2', 'ArrowUp')).toBe('a2') + expect(step('b1', 'ArrowRight')).toBe('b3') + expect(step('b3', 'ArrowLeft')).toBe('b1') + // A label sharing a cell moves like the cell it labels. + expect(step('a1-label', 'ArrowDown')).toBe('b1') + }) + + it('stays on the edge cell and handles row and grid extremes', () => { + expect(step('a3', 'ArrowRight')).toBe('a3') + expect(step('c1', 'ArrowDown')).toBe('c1') + expect(step('b3', 'Home')).toBe('b1') + expect(step('b1', 'End')).toBe('b3') + expect(step('b3', 'Home', true)).toBe('a1') + expect(step('a1', 'End', true)).toBe('c3') + expect(step(null, 'ArrowDown')).toBe('a1') + expect(step('a1', 'Enter')).toBeUndefined() + }) + + it('orders the fallback task list row by row and resolves inside a cell', () => { + expect(focusGrid.navigation(points).map((item) => item.key)).toEqual([ + 'a1', + 'a2', + 'a3', + 'b1', + 'b3', + 'c1', + 'c2', + 'c3', + ]) + expect( + focusGrid + .resolve(points, { x: 14, y: 16, maxDistance: 1 }) + .map((item) => item.key), + ).toEqual(['c2']) + }) + + it('leaves the built-in strategies on navigation order', () => { + for (const strategy of [ + focusGroupX, + focusGroupY, + focusNearestX, + focusNearestY, + ]) { + expect('step' in strategy).toBe(false) + } + }) + + it('drives mounted keyboard focus and keeps row and column in the tooltip', () => { + const rows = [ + { team: 'Core', quarter: 'Q1', score: 72 }, + { team: 'Core', quarter: 'Q2', score: 78 }, + { team: 'Cloud', quarter: 'Q1', score: 66 }, + { team: 'Cloud', quarter: 'Q2', score: 74 }, + ] + const definition = defineChart( + defineChart({ + marks: [cell(rows, { x: 'quarter', y: 'team', color: 'score' })], + ...bandAxes(['Q1', 'Q2'], ['Core', 'Cloud']), + }), + { focus: focusGrid, tooltip }, + ) + const container = document.createElement('div') + const onFocusChange = vi.fn() + const host = mountChart(container, { + definition, + width: 320, + height: 200, + ariaLabel: 'Scores', + onFocusChange, + }) + const svg = container.querySelector('svg') + if (!svg) throw new Error('Expected SVG') + const focused = () => onFocusChange.mock.calls.at(-1)?.[0]?.datum + + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(focused()).toBe(rows[0]) + svg.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }), + ) + expect(focused()).toBe(rows[2]) + svg.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'End', + ctrlKey: true, + }), + ) + expect(focused()).toBe(rows[3]) + const text = container.querySelector('.ts-chart-tooltip')?.textContent + expect(text).toContain('Q2') + expect(text).toContain('Cloud') + host.destroy() + }) +}) + +function point(key: string, x: number, y: number): ChartPoint { + return { + color: 'currentColor', + datum: null, + datumIndex: 0, + group: null, + groupLabel: '', + key, + markId: 'test', + x, + xValue: x, + y, + yValue: y, + } +} diff --git a/packages/charts-core/src/focus-grid.ts b/packages/charts-core/src/focus-grid.ts new file mode 100644 index 00000000..6614407d --- /dev/null +++ b/packages/charts-core/src/focus-grid.ts @@ -0,0 +1,219 @@ +import { valueKey } from './scales' +import { mappedFocusCoordinate } from './focus-coordinate-internal' +import type { + ChartFocusGroupContext, + ChartFocusResolveContext, + ChartFocusStepContext, + ChartPoint, + ChartValue, +} from './types' + +/** + * Two-dimensional keyboard focus for matrices, heatmaps, and calendars. + * + * Rows share a scene-pixel y coordinate and columns share a scene-pixel x + * coordinate. Left and Right move within the row, Up and Down move within the + * column, Home and End move to the row ends, and Control or Command with Home + * or End moves to the first or last cell. Missing cells are skipped. Several + * points in one cell, such as a cell and its label, resolve to the first one + * in scene order. + */ +export const focusGrid: UniversalGridFocusStrategy = { + resolve(points, context) { + const grid = buildGrid(points) + const { x, y, maxDistance } = context + let nearest: (typeof points)[number] | undefined + let distance = Number.POSITIVE_INFINITY + for (const row of grid.rows) { + for (const point of row.cells) { + const nextDistance = Math.hypot( + mappedFocusCoordinate(point, 'x') - x, + mappedFocusCoordinate(point, 'y') - y, + ) + if (nextDistance >= distance) continue + nearest = point + distance = nextDistance + } + } + if (!nearest) return [] + if (distance < maxDistance) return [nearest] + // A pointer anywhere inside a cell's grid pitch targets that cell, so + // large cells do not have unfocusable corners. + const dx = Math.abs(mappedFocusCoordinate(nearest, 'x') - x) + const dy = Math.abs(mappedFocusCoordinate(nearest, 'y') - y) + return dx <= grid.columnPitch / 2 && dy <= grid.rowPitch / 2 + ? [nearest] + : [] + }, + group: (_points, context) => [context.point], + navigation: (points) => buildGrid(points).rows.flatMap((row) => row.cells), + step(points, context) { + const { point, key, modifier } = context + const grid = buildGrid(points) + const first = grid.rows[0]?.cells[0] + const lastRow = grid.rows[grid.rows.length - 1] + const last = lastRow?.cells[lastRow.cells.length - 1] + const position = point && locate(grid, point) + if (!point || !position) return isGridKey(key) ? first : undefined + const { row, column } = position + const cells = grid.rows[row]?.cells ?? [] + switch (key) { + case 'ArrowRight': + return cells[cellIndex(grid, row, column) + 1] ?? point + case 'ArrowLeft': + return cells[cellIndex(grid, row, column) - 1] ?? point + case 'ArrowDown': + return verticalNeighbor(grid, row, column, 1) ?? point + case 'ArrowUp': + return verticalNeighbor(grid, row, column, -1) ?? point + case 'Home': + return modifier ? first : (cells[0] ?? point) + case 'End': + return modifier ? last : (cells[cells.length - 1] ?? point) + default: + return undefined + } + }, +} + +interface GridRow { + /** Cells ordered by column coordinate; missing cells are absent. */ + cells: TPoint[] + /** Column index for each entry in `cells`. */ + columns: number[] +} + +interface Grid { + rows: GridRow[] + rowKeys: Map + columnKeys: Map + rowPitch: number + columnPitch: number +} + +function buildGrid( + points: readonly TPoint[], +): Grid { + const rowCoordinates = new Map() + const columnCoordinates = new Map() + for (const point of points) { + const y = mappedFocusCoordinate(point, 'y') + const x = mappedFocusCoordinate(point, 'x') + if (!Number.isFinite(x) || !Number.isFinite(y)) continue + rowCoordinates.set(valueKey(y), y) + columnCoordinates.set(valueKey(x), x) + } + const rowKeys = orderedKeys(rowCoordinates) + const columnKeys = orderedKeys(columnCoordinates) + const cells = new Map() + for (const point of points) { + const rowKey = valueKey(mappedFocusCoordinate(point, 'y')) + const columnKey = valueKey(mappedFocusCoordinate(point, 'x')) + if (!rowKeys.has(rowKey) || !columnKeys.has(columnKey)) continue + const cellKey = `${rowKey}|${columnKey}` + if (!cells.has(cellKey)) cells.set(cellKey, point) + } + const rows: GridRow[] = Array.from({ length: rowKeys.size }, () => ({ + cells: [], + columns: [], + })) + const entries = [...cells.entries()].map(([cellKey, point]) => { + const [rowKey = '', columnKey = ''] = cellKey.split('|') + return { + point, + row: rowKeys.get(rowKey) ?? 0, + column: columnKeys.get(columnKey) ?? 0, + } + }) + entries.sort((left, right) => left.column - right.column) + for (const entry of entries) { + rows[entry.row]?.cells.push(entry.point) + rows[entry.row]?.columns.push(entry.column) + } + return { + // Every row key comes from a placed point, so no row is empty. + rows, + rowKeys, + columnKeys, + rowPitch: pitch(rowCoordinates), + columnPitch: pitch(columnCoordinates), + } +} + +function orderedKeys(coordinates: Map) { + const ordered = [...coordinates.entries()].sort( + (left, right) => left[1] - right[1], + ) + return new Map(ordered.map(([key], index) => [key, index])) +} + +function pitch(coordinates: Map) { + const sorted = [...coordinates.values()].sort((left, right) => left - right) + let smallest = Number.POSITIVE_INFINITY + for (let index = 1; index < sorted.length; index += 1) { + smallest = Math.min( + smallest, + (sorted[index] ?? 0) - (sorted[index - 1] ?? 0), + ) + } + return Number.isFinite(smallest) ? smallest : 0 +} + +function locate(grid: Grid, point: TPoint) { + const row = grid.rowKeys.get(valueKey(mappedFocusCoordinate(point, 'y'))) + const column = grid.columnKeys.get( + valueKey(mappedFocusCoordinate(point, 'x')), + ) + return row === undefined || column === undefined ? undefined : { row, column } +} + +function cellIndex(grid: Grid, row: number, column: number) { + return grid.rows[row]?.columns.indexOf(column) ?? -1 +} + +function verticalNeighbor( + grid: Grid, + row: number, + column: number, + direction: 1 | -1, +) { + for ( + let index = row + direction; + index >= 0 && index < grid.rows.length; + index += direction + ) { + const candidate = grid.rows[index] + const at = candidate?.columns.indexOf(column) ?? -1 + if (candidate && at >= 0) return candidate.cells[at] + } + return undefined +} + +function isGridKey(key: string) { + return ( + key === 'ArrowRight' || + key === 'ArrowLeft' || + key === 'ArrowDown' || + key === 'ArrowUp' || + key === 'Home' || + key === 'End' + ) +} + +interface UniversalGridFocusStrategy { + resolve: ( + points: readonly ChartPoint[], + context: ChartFocusResolveContext, + ) => readonly ChartPoint[] + group: ( + points: readonly ChartPoint[], + context: ChartFocusGroupContext, + ) => readonly ChartPoint[] + navigation: ( + points: readonly ChartPoint[], + ) => readonly ChartPoint[] + step: ( + points: readonly ChartPoint[], + context: ChartFocusStepContext, + ) => ChartPoint | undefined +} diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index b2e3ea5c..50c969bf 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -358,6 +358,7 @@ export type { ChartFocusResolveContext, ChartFocusSource, ChartFocusState, + ChartFocusStepContext, ChartFocusStrategy, ChartFreeCursorBinding, ChartGradient, diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index d9d78e55..15ed4664 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -863,13 +863,19 @@ export function mountChartRenderer< return } const focus = resolveRendererFocusStrategy(options.definition.focus) - const point = focus - ? chartPointFromNavigationOrder( - focus.navigation(points), - focusedPoint, - event.key, - ) - : chartPointFromSceneOrder(points, focusedPoint, event.key) + const point = + focus?.step?.(points, { + point: focusedPoint, + key: event.key, + modifier: event.ctrlKey || event.metaKey, + }) ?? + (focus + ? chartPointFromNavigationOrder( + focus.navigation(points), + focusedPoint, + event.key, + ) + : chartPointFromSceneOrder(points, focusedPoint, event.key)) if (point === undefined) return event.preventDefault() pointerPosition = null diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 2596b110..78df95fd 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -2082,6 +2082,27 @@ export interface ChartFocusStrategy< navigation: ( points: readonly ChartPoint[], ) => readonly ChartPoint[] + /** + * Optional directional keyboard step. Return the next point for the key, + * or `undefined` to fall back to `navigation` order. + */ + step?: ( + points: readonly ChartPoint[], + context: ChartFocusStepContext, + ) => ChartPoint | undefined +} + +export interface ChartFocusStepContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + /** The focused point, or `null` before keyboard focus lands. */ + point: ChartPoint | null + /** The DOM `KeyboardEvent.key` value. */ + key: string + /** True when Control or Command is held. */ + modifier: boolean } export interface ChartFocusResolveContext { diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 13c4746a..31bea81a 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -244,6 +244,7 @@ export type { ChartFocusResolveContext, ChartFocusSource, ChartFocusState, + ChartFocusStepContext, ChartFocusStrategy, ChartFreeCursorBinding, ChartGradient, diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..0004c64b 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1630,7 +1630,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 44.98, + 45.02, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1729,7 +1729,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.03, + 31.05, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1897,7 +1897,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 56.77, + 56.79, ), locked( 'Custom-scale line scene', @@ -1948,12 +1948,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.31, + 39.33, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 44.61, + 44.63, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2157,7 +2157,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 57.81, + 57.84, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 11ee0a92..fa761a81 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -93,6 +93,10 @@ const callbackInventory = { 'group navigation resolve', ], ['@tanstack/charts:src/focus.ts:axisFocus', 'group navigation resolve'], + [ + '@tanstack/charts:src/focus-grid.ts:UniversalGridFocusStrategy', + 'group navigation resolve step', + ], [ '@tanstack/charts:src/polar-focus-internal.ts:focusGroupAngle', 'group navigation resolve', @@ -349,7 +353,7 @@ const callbackInventory = { ], [ '@tanstack/charts:src/types.ts:ChartFocusStrategy', - 'group navigation resolve', + 'group navigation resolve step', ], ['@tanstack/charts:src/types.ts:ChartLayoutOptions', 'measureText'], ['@tanstack/charts:src/types.ts:ChartMark', 'initialize motion'], From f6c3e54abeb2ba2bc5f9077d286fb923ec8e45b0 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:32:28 -1000 Subject: [PATCH 2/5] fix(focus): harden grid focus edges and document open contract choices Single-row or single-column grids no longer reject pointers off the center line. End with no focus moves to the last cell, matching linear navigation. A focused cell that no longer exists restarts from the grid edge instead of jumping inside the row. Document the offset-label caveat and name the open key and modifier contract choices. --- API-FRICTION.md | 9 ++++- benchmarks/comparison/bundle-baseline.json | 6 ++-- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 4 +-- docs/reference/focus-and-interaction.md | 9 +++-- packages/charts-core/docs/comparison.md | 4 +-- .../docs/reference/focus-and-interaction.md | 9 +++-- packages/charts-core/src/focus-grid.test.ts | 33 ++++++++++++------- packages/charts-core/src/focus-grid.ts | 23 +++++++------ 9 files changed, 62 insertions(+), 37 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index faec0bbb..18e8868d 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9065,7 +9065,14 @@ Each entry records: `ChartFocusStrategy` for directional keys, with `navigation` as the fallback and the default for existing strategies. Ship `focusGrid` from the separate `@tanstack/charts/focus/grid` entry point so charts that do not import it do - not grow. The final contract shape is left to maintainer review. + not grow. The rejected alternative was letting `navigation` return rows, + which changes a return type every host consumes. Two choices stay open for + maintainer review: (a) `key` is the raw `KeyboardEvent.key`, so only the DOM + host dispatches `step`; the D3 companion host and React Native call + `navigation` only and fall back to row-major linear order. A host-neutral + direction vocabulary (`right`, `left`, `up`, `down`, `rowStart`, `rowEnd`, + `first`, `last`) would let every host dispatch it. (b) `modifier` is one + boolean for Control or Command instead of explicit `ctrlKey` and `metaKey`. - Verification: unit tests cover row and column moves, sparse skips, row and grid extremes, the row-major fallback order, and a mounted heatmap whose tooltip names both the row and column after keyboard moves. The built-in diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 151c022f..93d7f22b 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-01T00:07:33.301Z", + "generatedAt": "2026-10-01T01:26:11.892Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "b215626e4730ff1edd82995cdf9d73253b0fd1f1", - "inputDigest": "sha256:158485c88d1cabd11bbfe24a4f35a7c36702a8b2ea2fced9e31e6eadc68befb9" + "revision": "0868e804263c9102838a8dca82f5aa29ffc2e4f6", + "inputDigest": "sha256:4445f344a28e7197f879e2efd7ea397f2152579c50321b1ac25aaf19f1320dd6" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 30dba0d9..0257093e 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "c18620388c28905214c746dcb91069f332112e778c4b5d1920f1ab78bdb1bca0", + "sourceHash": "7ad38d3ca35ec4e7022db56ed5169aa24b5094771a3658ddcf0225bfc6dbe20e", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 72236666..619361a4 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 `b215626` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `0868e80` | | [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 `b215626`. +at page render time. The measured TanStack workspace revision is `0868e80`. ## Capability matrix diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 513b69aa..e6b024c7 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -195,9 +195,12 @@ scene-pixel x coordinate, so band-scale cells line up without configuration. Missing cells are skipped in the direction of travel, and focus stays on the edge cell when there is nothing further. Moves follow the painted layout, so Right always moves toward the right of the plot, including in right-to-left -documents. A label drawn over a cell shares that cell's position and does not -add a second stop. Pointer focus picks the cell whose center is nearest, and -any pointer inside a cell's grid pitch targets that cell. +documents. A label centered on a cell shares that cell's position and does not +add a second stop. A label moved with `dx` or `dy` has its own coordinates, so +it forms a separate row or column; keep offset labels out of the focusable +marks or center them. Pointer focus picks the cell whose center is nearest. +Any pointer within half the row and column spacing of that center targets the +cell, and a grid with one row or one column applies only the spacing it has. The focused point still carries its `xValue` and `yValue`, so the built-in tooltip, which is a polite status region, announces the column and row labels diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 72236666..619361a4 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 `b215626` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `0868e80` | | [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 `b215626`. +at page render time. The measured TanStack workspace revision is `0868e80`. ## Capability matrix diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 513b69aa..e6b024c7 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -195,9 +195,12 @@ scene-pixel x coordinate, so band-scale cells line up without configuration. Missing cells are skipped in the direction of travel, and focus stays on the edge cell when there is nothing further. Moves follow the painted layout, so Right always moves toward the right of the plot, including in right-to-left -documents. A label drawn over a cell shares that cell's position and does not -add a second stop. Pointer focus picks the cell whose center is nearest, and -any pointer inside a cell's grid pitch targets that cell. +documents. A label centered on a cell shares that cell's position and does not +add a second stop. A label moved with `dx` or `dy` has its own coordinates, so +it forms a separate row or column; keep offset labels out of the focusable +marks or center them. Pointer focus picks the cell whose center is nearest. +Any pointer within half the row and column spacing of that center targets the +cell, and a grid with one row or one column applies only the spacing it has. The focused point still carries its `xValue` and `yValue`, so the built-in tooltip, which is a polite status region, announces the column and row labels diff --git a/packages/charts-core/src/focus-grid.test.ts b/packages/charts-core/src/focus-grid.test.ts index 92272c26..58532a1c 100644 --- a/packages/charts-core/src/focus-grid.test.ts +++ b/packages/charts-core/src/focus-grid.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { mountChart } from './dom' -import { focusGroupX, focusGroupY, focusNearestX, focusNearestY } from './focus' import { focusGrid } from './focus-grid' import { cell } from './rect' import { defineChart } from './scene' @@ -49,9 +48,30 @@ describe('grid focus strategy', () => { expect(step('b3', 'Home', true)).toBe('a1') expect(step('a1', 'End', true)).toBe('c3') expect(step(null, 'ArrowDown')).toBe('a1') + expect(step(null, 'End')).toBe('c3') + expect(step(null, 'End', true)).toBe('c3') expect(step('a1', 'Enter')).toBeUndefined() }) + it('restarts from the grid edge when the focused cell is gone', () => { + // b2 shares row b and column 2 with existing cells, but has no cell. + const stale = point('b2', 10, 10) + const move = (key: string) => + focusGrid.step(points, { point: stale, key, modifier: false })?.key + expect(move('ArrowRight')).toBe('a1') + expect(move('ArrowLeft')).toBe('a1') + expect(move('End')).toBe('c3') + }) + + it('targets a single-row strip anywhere inside a cell column', () => { + const strip = [point('s1', 0, 0), point('s2', 40, 0)] + expect( + focusGrid + .resolve(strip, { x: 35, y: 30, maxDistance: 1 }) + .map((item) => item.key), + ).toEqual(['s2']) + }) + it('orders the fallback task list row by row and resolves inside a cell', () => { expect(focusGrid.navigation(points).map((item) => item.key)).toEqual([ 'a1', @@ -70,17 +90,6 @@ describe('grid focus strategy', () => { ).toEqual(['c2']) }) - it('leaves the built-in strategies on navigation order', () => { - for (const strategy of [ - focusGroupX, - focusGroupY, - focusNearestX, - focusNearestY, - ]) { - expect('step' in strategy).toBe(false) - } - }) - it('drives mounted keyboard focus and keeps row and column in the tooltip', () => { const rows = [ { team: 'Core', quarter: 'Q1', score: 72 }, diff --git a/packages/charts-core/src/focus-grid.ts b/packages/charts-core/src/focus-grid.ts index 6614407d..ddbe5aff 100644 --- a/packages/charts-core/src/focus-grid.ts +++ b/packages/charts-core/src/focus-grid.ts @@ -54,14 +54,18 @@ export const focusGrid: UniversalGridFocusStrategy = { const lastRow = grid.rows[grid.rows.length - 1] const last = lastRow?.cells[lastRow.cells.length - 1] const position = point && locate(grid, point) - if (!point || !position) return isGridKey(key) ? first : undefined - const { row, column } = position + // Without a focused cell, or when that cell is gone, start from the edge + // that linear navigation would use. + if (!point || !position) { + return key === 'End' ? last : isGridKey(key) ? first : undefined + } + const { row, column, index } = position const cells = grid.rows[row]?.cells ?? [] switch (key) { case 'ArrowRight': - return cells[cellIndex(grid, row, column) + 1] ?? point + return cells[index + 1] ?? point case 'ArrowLeft': - return cells[cellIndex(grid, row, column) - 1] ?? point + return cells[index - 1] ?? point case 'ArrowDown': return verticalNeighbor(grid, row, column, 1) ?? point case 'ArrowUp': @@ -156,7 +160,8 @@ function pitch(coordinates: Map) { (sorted[index] ?? 0) - (sorted[index - 1] ?? 0), ) } - return Number.isFinite(smallest) ? smallest : 0 + // One row or column has no pitch, so that axis never rules a pointer out. + return smallest } function locate(grid: Grid, point: TPoint) { @@ -164,11 +169,9 @@ function locate(grid: Grid, point: TPoint) { const column = grid.columnKeys.get( valueKey(mappedFocusCoordinate(point, 'x')), ) - return row === undefined || column === undefined ? undefined : { row, column } -} - -function cellIndex(grid: Grid, row: number, column: number) { - return grid.rows[row]?.columns.indexOf(column) ?? -1 + if (row === undefined || column === undefined) return undefined + const index = grid.rows[row]?.columns.indexOf(column) ?? -1 + return index < 0 ? undefined : { row, column, index } } function verticalNeighbor( From c991792f18a707249ba80919d5fa5f8ded98dab3 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:40:01 -1000 Subject: [PATCH 3/5] Refresh comparison evidence for merged upstream source --- benchmarks/comparison/bundle-baseline.json | 4 ++-- docs/comparison.md | 4 ++-- packages/charts-core/docs/comparison.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 00274f3d..a402491d 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:32:47.405Z", + "generatedAt": "2026-10-13T23:39:21.273Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "f6c3e54abeb2ba2bc5f9077d286fb923ec8e45b0", + "revision": "6857b6fdebe65ac0315f345b5cfcdc4caedcab6d", "inputDigest": "sha256:02aa942c7adc840a709f23e6783e93125859ab8f90f1afe6f147ea33ea357851" }, "chartjs": { diff --git a/docs/comparison.md b/docs/comparison.md index d2ef4626..1f8c8461 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 `f6c3e54` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `6857b6f` | | [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 `f6c3e54`. +at page render time. The measured TanStack workspace revision is `6857b6f`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index d2ef4626..1f8c8461 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 `f6c3e54` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `6857b6f` | | [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 `f6c3e54`. +at page render time. The measured TanStack workspace revision is `6857b6f`. ## Capability matrix From 9c43ef7dfffdea2e25ac96cc632d68418040d26c Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:46:02 -1000 Subject: [PATCH 4/5] Format refreshed comparison baseline --- benchmarks/comparison/bundle-baseline.json | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a402491d..3e690dc9 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -36,17 +36,8 @@ } }, "matrix": { - "chartTypes": [ - "line", - "bar", - "area", - "scatter" - ], - "tiers": [ - "basic", - "interactive", - "advanced" - ] + "chartTypes": ["line", "bar", "area", "scatter"], + "tiers": ["basic", "interactive", "advanced"] }, "tolerance": { "relative": 0.03, From 507e0cac7445add8053aacbe4c86f18317616427 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 14:02:25 -1000 Subject: [PATCH 5/5] Refresh comparison and preview provenance after motion merge --- benchmarks/comparison/bundle-baseline.json | 126 +++++++++--------- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 14 +- packages/charts-core/docs/comparison.md | 14 +- 4 files changed, 78 insertions(+), 78 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0406421b..5697c298 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:35:26.997Z", + "generatedAt": "2026-10-14T00:00:31.426Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "a691036b07655adda97aa0dc3052428989e2b811", - "inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951" + "revision": "1d8892d8c8ca617f2ffc94e086ccd1db7669e336", + "inputDigest": "sha256:b4bdc595e1ba77a8875e12287431cb3fa921537829d4865870272bbf5c9fc501" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 119233, - "gzipBytes": 43211, - "brotliBytes": 38200, - "incrementalGzipBytes": 43211, - "incrementalBrotliBytes": 38200 + "minifiedBytes": 119299, + "gzipBytes": 43261, + "brotliBytes": 38290, + "incrementalGzipBytes": 43261, + "incrementalBrotliBytes": 38290 }, "tanstack-line-interactive": { - "minifiedBytes": 124980, - "gzipBytes": 45122, - "brotliBytes": 39727, - "incrementalGzipBytes": 45122, - "incrementalBrotliBytes": 39727 + "minifiedBytes": 125046, + "gzipBytes": 45171, + "brotliBytes": 39826, + "incrementalGzipBytes": 45171, + "incrementalBrotliBytes": 39826 }, "tanstack-line-advanced": { - "minifiedBytes": 132176, - "gzipBytes": 47454, - "brotliBytes": 41771, - "incrementalGzipBytes": 47454, - "incrementalBrotliBytes": 41771 + "minifiedBytes": 132242, + "gzipBytes": 47502, + "brotliBytes": 41813, + "incrementalGzipBytes": 47502, + "incrementalBrotliBytes": 41813 }, "tanstack-bar-basic": { - "minifiedBytes": 131670, - "gzipBytes": 47913, - "brotliBytes": 42147, - "incrementalGzipBytes": 47913, - "incrementalBrotliBytes": 42147 + "minifiedBytes": 131736, + "gzipBytes": 47956, + "brotliBytes": 42154, + "incrementalGzipBytes": 47956, + "incrementalBrotliBytes": 42154 }, "tanstack-bar-interactive": { - "minifiedBytes": 136276, - "gzipBytes": 49307, - "brotliBytes": 43250, - "incrementalGzipBytes": 49307, - "incrementalBrotliBytes": 43250 + "minifiedBytes": 136342, + "gzipBytes": 49357, + "brotliBytes": 43357, + "incrementalGzipBytes": 49357, + "incrementalBrotliBytes": 43357 }, "tanstack-bar-advanced": { - "minifiedBytes": 136615, - "gzipBytes": 49453, - "brotliBytes": 43349, - "incrementalGzipBytes": 49453, - "incrementalBrotliBytes": 43349 + "minifiedBytes": 136681, + "gzipBytes": 49504, + "brotliBytes": 43425, + "incrementalGzipBytes": 49504, + "incrementalBrotliBytes": 43425 }, "tanstack-area-basic": { - "minifiedBytes": 124413, - "gzipBytes": 45239, - "brotliBytes": 39996, - "incrementalGzipBytes": 45239, - "incrementalBrotliBytes": 39996 + "minifiedBytes": 124479, + "gzipBytes": 45290, + "brotliBytes": 40044, + "incrementalGzipBytes": 45290, + "incrementalBrotliBytes": 40044 }, "tanstack-area-interactive": { - "minifiedBytes": 130168, - "gzipBytes": 47088, - "brotliBytes": 41480, - "incrementalGzipBytes": 47088, - "incrementalBrotliBytes": 41480 + "minifiedBytes": 130234, + "gzipBytes": 47137, + "brotliBytes": 41521, + "incrementalGzipBytes": 47137, + "incrementalBrotliBytes": 41521 }, "tanstack-area-advanced": { - "minifiedBytes": 137540, - "gzipBytes": 49496, - "brotliBytes": 43606, - "incrementalGzipBytes": 49496, - "incrementalBrotliBytes": 43606 + "minifiedBytes": 137606, + "gzipBytes": 49547, + "brotliBytes": 43616, + "incrementalGzipBytes": 49547, + "incrementalBrotliBytes": 43616 }, "tanstack-scatter-basic": { - "minifiedBytes": 120286, - "gzipBytes": 43572, - "brotliBytes": 38499, - "incrementalGzipBytes": 43572, - "incrementalBrotliBytes": 38499 + "minifiedBytes": 120352, + "gzipBytes": 43622, + "brotliBytes": 38568, + "incrementalGzipBytes": 43622, + "incrementalBrotliBytes": 38568 }, "tanstack-scatter-interactive": { - "minifiedBytes": 126037, - "gzipBytes": 45469, - "brotliBytes": 40055, - "incrementalGzipBytes": 45469, - "incrementalBrotliBytes": 40055 + "minifiedBytes": 126103, + "gzipBytes": 45527, + "brotliBytes": 40085, + "incrementalGzipBytes": 45527, + "incrementalBrotliBytes": 40085 }, "tanstack-scatter-advanced": { - "minifiedBytes": 126053, - "gzipBytes": 45476, - "brotliBytes": 40107, - "incrementalGzipBytes": 45476, - "incrementalBrotliBytes": 40107 + "minifiedBytes": 126119, + "gzipBytes": 45533, + "brotliBytes": 40125, + "incrementalGzipBytes": 45533, + "incrementalBrotliBytes": 40125 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 5ad78811..8fc77cd1 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "5cb9f7c0512bf98f18bb88dbb7c2ec524b0239530a64d5a9b1d5a00ab5f9b3d3", + "sourceHash": "c8144502fefcf3f51081dd7beac8c1e6168b5816f6e31001997cb9225760ffe4", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index a6ab014c..0856373b 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 `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `1d8892d` | | [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 `a691036`. +at page render time. The measured TanStack workspace revision is `1d8892d`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,10 +106,10 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.26–49.55 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index a6ab014c..0856373b 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 `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `1d8892d` | | [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 `a691036`. +at page render time. The measured TanStack workspace revision is `1d8892d`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,10 +106,10 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.26–49.55 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export |