From 08a3a5ab6b57ba0667d962ce7cedab6591b5e264 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 14:06:08 -1000 Subject: [PATCH 1/8] feat(interaction): let brushX and zoomX pass idle hover to point focus Add a shared hover: 'capture' | 'passthrough' option to brushX and zoomX. With passthrough, idle hover over the zoom surface or the blank brush area resolves chart point focus and tooltips through the host's control containment check. Brush selections and handles, active gestures, clicks, wheel, and keyboard input stay with the control. The default keeps the current behavior. --- .changeset/control-hover-passthrough.md | 5 + API-FRICTION.md | 29 +++++ .../bundle-size/universal-baseline.json | 16 +-- benchmarks/conformance/previews/manifest.json | 2 +- docs/guides/interactions-and-selections.md | 107 +++++++++++++++++- docs/reference/focus-and-interaction.md | 20 +++- docs/reference/index.md | 4 +- docs/reference/types.md | 8 +- .../guides/interactions-and-selections.md | 107 +++++++++++++++++- .../docs/reference/focus-and-interaction.md | 20 +++- packages/charts-core/docs/reference/index.md | 4 +- packages/charts-core/docs/reference/types.md | 8 +- packages/charts-core/src/dom-types.ts | 6 + .../charts-core/src/interaction-brush.test.ts | 73 +++++++++++- packages/charts-core/src/interaction-brush.ts | 21 ++++ .../src/interaction-range-internal.ts | 17 +++ .../charts-core/src/interaction-zoom.test.ts | 67 +++++++++++ packages/charts-core/src/interaction-zoom.ts | 16 +++ packages/charts-core/src/renderer.ts | 13 ++- scripts/measure-bundles.mjs | 4 +- 20 files changed, 513 insertions(+), 34 deletions(-) create mode 100644 .changeset/control-hover-passthrough.md diff --git a/.changeset/control-hover-passthrough.md b/.changeset/control-hover-passthrough.md new file mode 100644 index 00000000..2523fe71 --- /dev/null +++ b/.changeset/control-hover-passthrough.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add a shared `hover: 'capture' | 'passthrough'` option to `brushX` and `zoomX`. With `passthrough`, idle hover over the plot still focuses points and shows the tooltip, so brush or zoom can live on the same plot as point tooltips. Drags, clicks, wheel, keyboard input, and the brush selection and handles stay with the control. The default `capture` keeps the current behavior. diff --git a/API-FRICTION.md b/API-FRICTION.md index 1c43acbb..17d5c5a8 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-309 | Brush and zoom hid point focus on their own plot | API | resolved | ## Findings @@ -9045,3 +9046,31 @@ 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-309 - Brush and zoom hid point focus on their own plot + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: a consumer dashboard on 0.18.0 that combined `brushX` or + `zoomX` with point tooltips on one time-series plot +- Friction: with either control on the main plot, hovering the plot showed no + point focus or tooltip. The zoom hit rectangle and the brush overlay sit + above the plot and capture every pointer event, and the DOM host clears + point focus whenever the pointer target is inside a control. The + application moved brush and zoom into a separate overview chart to keep + tooltips working. +- Decision: add one shared `hover: 'capture' | 'passthrough'` option to both + controls, typed `ChartControlHover`. The default `capture` keeps the old + behavior. `passthrough` lets idle hover over the zoom surface, or over the + blank brush overlay, resolve point focus and the tooltip through the host's + control-containment check. Brush selections and handles, active drags and + touch gestures, clicks, wheel input, and keyboard input stay with the + control. Hover is the only forwarded input, so one click never both selects + a point and starts a brush. +- Verification: table-driven zoom and brush tests cover the default, explicit + `capture`, and `passthrough`: idle hover focuses the nearest point only with + `passthrough`, a brush selection keeps capturing, and focus clears during + an active pan or brush drag and returns on the next idle hover. The + interaction guide has a runnable one-plot `zoomX` and `brushX` example with + point tooltips. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index f0ebd0b4..b2dd2500 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": 81901, + "gzip": 28596 }, "React adapter": { - "bytes": 84195, - "gzip": 29518 + "bytes": 84226, + "gzip": 29532 }, "React line consumer": { - "bytes": 107854, - "gzip": 38849 + "bytes": 107885, + "gzip": 38866 }, "Compact-scale line scene": { "bytes": 35865, "gzip": 12732 }, "React compact-scale line consumer": { - "bytes": 90354, - "gzip": 31746 + "bytes": 90385, + "gzip": 31763 }, "Custom-scale line scene": { "bytes": 34047, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index bc8af86a..b3583265 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": "8095dbd858da499381f18ec5380da182dbe46b3c7950c965a299beaad2cee62a", "assets": [ { "id": "01-line-gaps", diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index a40c7383..7de30cdd 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -442,7 +442,9 @@ mountChart(element, { This prevents an application-owned gesture from competing with the host's point marker and tooltip. First-party host controls such as `brushX` and -`zoomX` isolate their own events. +`zoomX` isolate their own events by default. Set `hover: 'passthrough'` on +either control to keep point focus and tooltips on the same plot; see +[Tooltips with brush or zoom](#tooltips-with-brush-or-zoom). Use `pointer: false` plus the interaction controller when the application owns the gesture but the chart should still own datum focus. `focusDisabled` does @@ -684,6 +686,109 @@ recovery controls, follow-latest behavior, and persistence in the application. A reset updates the same controlled window. Import `d3-zoom` directly only when the application needs a different gesture policy. +## Tooltips with brush or zoom + +`brushX` and `zoomX` share one `hover` option. The default, `capture`, keeps +every pointer event over the control, so the plot shows no point focus or +tooltip while the pointer is over it. Set `hover: 'passthrough'` when the same +plot should also show point tooltips: + +- Idle hover over the zoom surface, or over the blank brush area, focuses the + nearest point and shows the tooltip. +- The brush selection and its handles keep capturing hover. +- During a pan, brush drag, or touch gesture, point focus clears. It returns on + the next idle hover. +- Presses, clicks, wheel input, and keyboard input still belong to the + control. A click on the plot pans or brushes; it never also selects a point. + After an accepted wheel zoom, the host re-resolves focus at the last pointer + position. + +```tsx group=zoom-with-tooltips env=charts-react file=/src/App.tsx entry +import { useMemo, useState } from 'react' +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { + zoomX, + type ZoomXChange, + type ZoomXWindow, +} from '@tanstack/charts/interaction/zoom' +import { controlledSignal } from '@tanstack/charts/interaction/signal' +import { tooltip } from '@tanstack/charts/tooltip' +import { Chart } from '@tanstack/charts/react' +import { rows } from './data' + +const extent = [1, 12] as const + +export default function App() { + const [visible, setVisible] = useState>({ + start: 3, + end: 9, + }) + const definition = useMemo( + () => + defineChart({ + marks: [ + lineY(rows, { + x: 'week', + y: 'signups', + points: true, + stroke: '#2563eb', + strokeWidth: 2.5, + }), + ], + scales: { + x: { scale: scaleLinear().domain([visible.start, visible.end]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ + zoomX({ + window: controlledSignal, ZoomXChange>( + visible, + (next) => setVisible(next), + ), + extent, + scaleExtent: [1, 4], + hover: 'passthrough', + ariaLabel: 'Zoomable signup weeks', + format: (week) => `Week ${Math.round(week)}`, + }), + ], + tooltip, + }), + [visible], + ) + + return ( + + ) +} +``` + +```ts group=zoom-with-tooltips file=/src/data.ts collapsed +export const rows = [ + { week: 1, signups: 18 }, + { week: 2, signups: 24 }, + { week: 3, signups: 31 }, + { week: 4, signups: 29 }, + { week: 5, signups: 42 }, + { week: 6, signups: 48 }, + { week: 7, signups: 46 }, + { week: 8, signups: 57 }, + { week: 9, signups: 61 }, + { week: 10, signups: 58 }, + { week: 11, signups: 66 }, + { week: 12, signups: 72 }, +] +``` + +`brushX({ range, values, hover: 'passthrough' })` works the same way. Put +only one of the two controls on a plot. Both cover the plot, so the one +mounted last takes every press over it. + ## Linked views Store one semantic cursor, selection, or domain and derive every view from it. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 1c33c30a..2056efb0 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -620,7 +620,14 @@ drawn region. The behavior resolves against the final x scale and plot bounds. SVG and Canvas DOM hosts mount the same D3-backed overlay and contain its events before normal chart focus or selection. Static SVG and React Native paint the -renderer-neutral range and handles without interactive host controls. The +renderer-neutral range and handles without interactive host controls. + +`hover` is a `ChartControlHover`: `capture` (the default) or `passthrough`. +With `passthrough`, idle hover over the blank plot area resolves chart point +focus and the tooltip. The selection and handles keep capturing hover, and an +active pointer or touch drag clears point focus until the next idle hover. +Presses, clicks, and keyboard input stay with the brush, so a click never +also selects a point. `zoomX` accepts the same option. The application still owns fixed-window expansion, validation, linked-view layout, status text, persistence, and any native semantic control. @@ -706,6 +713,14 @@ controls. `ariaLabel` names the plot control, `ariaDescription` can replace its generated instructions, `format` formats values in those instructions, and `onActiveChange` observes focus without moving accepted state into Charts. +`hover` is the same `ChartControlHover` option that `brushX` accepts. The +default `capture` keeps all pointer input on the zoom surface, so the plot +shows no point focus underneath. `passthrough` lets idle hover resolve chart +point focus and the tooltip. An active pan or touch gesture clears point focus +until the next idle hover. Presses, clicks, wheel, and keyboard input stay +with zoom; after an accepted wheel zoom, the host re-resolves focus at the +last pointer position. + Charts does not mutate the controlled snapshot. Keep visible-row filtering or clipping, y-domain policy, status, reset and recovery controls, follow-latest behavior, and persistence in the application. Static SVG and React Native @@ -721,7 +736,8 @@ The public zoom types are: | `ZoomXAction` | Zoom, pan, or reset action | | `ZoomXWheelActivation` | Focus, modifier-gated, or unconditional wheel capture | | `ZoomXChange` | Preview, commit, and cancel reason union | -| `ZoomXOptions` | Controlled window, extent, limits, wheel, accessibility, and key options | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, hover, accessibility, and keys | +| `ChartControlHover` | `capture` or `passthrough` idle hover; shared with `brushX` | ## Disabling chart-owned focus diff --git a/docs/reference/index.md b/docs/reference/index.md index aa294b54..6cdb8935 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -150,10 +150,10 @@ aggregate `/scales` export. | `@tanstack/charts/hierarchy/treemap` | `treemap`, built-in and callable tile types, immutable node values, comparators, and path/parent option types | | `@tanstack/charts/hierarchy/sunburst` | `sunburst`, `SunburstNode`, `SunburstNodeComparator`, `SunburstPathOptions`, `SunburstParentOptions`, `SunburstOptions` | | `@tanstack/charts/hexagon` | `hexagon` | -| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | +| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, hover, and option types | | `@tanstack/charts/interaction/cursor` | `continuousCursor` and continuous-cursor position, change, guide, label, and option types | | `@tanstack/charts/interaction/signal` | `controlledSignal`, `ControlledSignal`, `ControlledSignalChangeContext` | -| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, `ZoomXOptions`, and `ChartControlHover` | | `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend`, `interactiveColorLegend`, `InteractiveColorLegendItemContext`, and legend option/change types | | `@tanstack/charts/line` | `lineY`, `lineX`, `LineYOptions`, and `LineXOptions` | | `@tanstack/charts/regression` | `linearRegressionRowsY`, `linearRegressionRowsX`, `linearRegressionY`, `linearRegressionX`, derived datum types, and option types | diff --git a/docs/reference/types.md b/docs/reference/types.md index fb81f80e..7c20d1b7 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -480,8 +480,8 @@ their behavior: and [Interactive categorical legend](./scales-guides-and-color.md#interactive-categorical-legend). - `@tanstack/charts/interaction/brush`: `BrushRange`, `BrushXChange`, - `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, and - `BrushXContinuousOptions`. See + `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, + `BrushXContinuousOptions`, and `ChartControlHover`. See [Horizontal brush](./focus-and-interaction.md#horizontal-brush). - `@tanstack/charts/interaction/cursor`: `ContinuousCursorValue`, `ContinuousCursorPosition`, `ContinuousCursorPointerSource`, @@ -490,8 +490,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and - `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, + `ZoomXOptions`, and `ChartControlHover`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). - `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, diff --git a/packages/charts-core/docs/guides/interactions-and-selections.md b/packages/charts-core/docs/guides/interactions-and-selections.md index a40c7383..7de30cdd 100644 --- a/packages/charts-core/docs/guides/interactions-and-selections.md +++ b/packages/charts-core/docs/guides/interactions-and-selections.md @@ -442,7 +442,9 @@ mountChart(element, { This prevents an application-owned gesture from competing with the host's point marker and tooltip. First-party host controls such as `brushX` and -`zoomX` isolate their own events. +`zoomX` isolate their own events by default. Set `hover: 'passthrough'` on +either control to keep point focus and tooltips on the same plot; see +[Tooltips with brush or zoom](#tooltips-with-brush-or-zoom). Use `pointer: false` plus the interaction controller when the application owns the gesture but the chart should still own datum focus. `focusDisabled` does @@ -684,6 +686,109 @@ recovery controls, follow-latest behavior, and persistence in the application. A reset updates the same controlled window. Import `d3-zoom` directly only when the application needs a different gesture policy. +## Tooltips with brush or zoom + +`brushX` and `zoomX` share one `hover` option. The default, `capture`, keeps +every pointer event over the control, so the plot shows no point focus or +tooltip while the pointer is over it. Set `hover: 'passthrough'` when the same +plot should also show point tooltips: + +- Idle hover over the zoom surface, or over the blank brush area, focuses the + nearest point and shows the tooltip. +- The brush selection and its handles keep capturing hover. +- During a pan, brush drag, or touch gesture, point focus clears. It returns on + the next idle hover. +- Presses, clicks, wheel input, and keyboard input still belong to the + control. A click on the plot pans or brushes; it never also selects a point. + After an accepted wheel zoom, the host re-resolves focus at the last pointer + position. + +```tsx group=zoom-with-tooltips env=charts-react file=/src/App.tsx entry +import { useMemo, useState } from 'react' +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { + zoomX, + type ZoomXChange, + type ZoomXWindow, +} from '@tanstack/charts/interaction/zoom' +import { controlledSignal } from '@tanstack/charts/interaction/signal' +import { tooltip } from '@tanstack/charts/tooltip' +import { Chart } from '@tanstack/charts/react' +import { rows } from './data' + +const extent = [1, 12] as const + +export default function App() { + const [visible, setVisible] = useState>({ + start: 3, + end: 9, + }) + const definition = useMemo( + () => + defineChart({ + marks: [ + lineY(rows, { + x: 'week', + y: 'signups', + points: true, + stroke: '#2563eb', + strokeWidth: 2.5, + }), + ], + scales: { + x: { scale: scaleLinear().domain([visible.start, visible.end]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ + zoomX({ + window: controlledSignal, ZoomXChange>( + visible, + (next) => setVisible(next), + ), + extent, + scaleExtent: [1, 4], + hover: 'passthrough', + ariaLabel: 'Zoomable signup weeks', + format: (week) => `Week ${Math.round(week)}`, + }), + ], + tooltip, + }), + [visible], + ) + + return ( + + ) +} +``` + +```ts group=zoom-with-tooltips file=/src/data.ts collapsed +export const rows = [ + { week: 1, signups: 18 }, + { week: 2, signups: 24 }, + { week: 3, signups: 31 }, + { week: 4, signups: 29 }, + { week: 5, signups: 42 }, + { week: 6, signups: 48 }, + { week: 7, signups: 46 }, + { week: 8, signups: 57 }, + { week: 9, signups: 61 }, + { week: 10, signups: 58 }, + { week: 11, signups: 66 }, + { week: 12, signups: 72 }, +] +``` + +`brushX({ range, values, hover: 'passthrough' })` works the same way. Put +only one of the two controls on a plot. Both cover the plot, so the one +mounted last takes every press over it. + ## Linked views Store one semantic cursor, selection, or domain and derive every view from it. diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 1c33c30a..2056efb0 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -620,7 +620,14 @@ drawn region. The behavior resolves against the final x scale and plot bounds. SVG and Canvas DOM hosts mount the same D3-backed overlay and contain its events before normal chart focus or selection. Static SVG and React Native paint the -renderer-neutral range and handles without interactive host controls. The +renderer-neutral range and handles without interactive host controls. + +`hover` is a `ChartControlHover`: `capture` (the default) or `passthrough`. +With `passthrough`, idle hover over the blank plot area resolves chart point +focus and the tooltip. The selection and handles keep capturing hover, and an +active pointer or touch drag clears point focus until the next idle hover. +Presses, clicks, and keyboard input stay with the brush, so a click never +also selects a point. `zoomX` accepts the same option. The application still owns fixed-window expansion, validation, linked-view layout, status text, persistence, and any native semantic control. @@ -706,6 +713,14 @@ controls. `ariaLabel` names the plot control, `ariaDescription` can replace its generated instructions, `format` formats values in those instructions, and `onActiveChange` observes focus without moving accepted state into Charts. +`hover` is the same `ChartControlHover` option that `brushX` accepts. The +default `capture` keeps all pointer input on the zoom surface, so the plot +shows no point focus underneath. `passthrough` lets idle hover resolve chart +point focus and the tooltip. An active pan or touch gesture clears point focus +until the next idle hover. Presses, clicks, wheel, and keyboard input stay +with zoom; after an accepted wheel zoom, the host re-resolves focus at the +last pointer position. + Charts does not mutate the controlled snapshot. Keep visible-row filtering or clipping, y-domain policy, status, reset and recovery controls, follow-latest behavior, and persistence in the application. Static SVG and React Native @@ -721,7 +736,8 @@ The public zoom types are: | `ZoomXAction` | Zoom, pan, or reset action | | `ZoomXWheelActivation` | Focus, modifier-gated, or unconditional wheel capture | | `ZoomXChange` | Preview, commit, and cancel reason union | -| `ZoomXOptions` | Controlled window, extent, limits, wheel, accessibility, and key options | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, hover, accessibility, and keys | +| `ChartControlHover` | `capture` or `passthrough` idle hover; shared with `brushX` | ## Disabling chart-owned focus diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index aa294b54..6cdb8935 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -150,10 +150,10 @@ aggregate `/scales` export. | `@tanstack/charts/hierarchy/treemap` | `treemap`, built-in and callable tile types, immutable node values, comparators, and path/parent option types | | `@tanstack/charts/hierarchy/sunburst` | `sunburst`, `SunburstNode`, `SunburstNodeComparator`, `SunburstPathOptions`, `SunburstParentOptions`, `SunburstOptions` | | `@tanstack/charts/hexagon` | `hexagon` | -| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | +| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, hover, and option types | | `@tanstack/charts/interaction/cursor` | `continuousCursor` and continuous-cursor position, change, guide, label, and option types | | `@tanstack/charts/interaction/signal` | `controlledSignal`, `ControlledSignal`, `ControlledSignalChangeContext` | -| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, `ZoomXOptions`, and `ChartControlHover` | | `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend`, `interactiveColorLegend`, `InteractiveColorLegendItemContext`, and legend option/change types | | `@tanstack/charts/line` | `lineY`, `lineX`, `LineYOptions`, and `LineXOptions` | | `@tanstack/charts/regression` | `linearRegressionRowsY`, `linearRegressionRowsX`, `linearRegressionY`, `linearRegressionX`, derived datum types, and option types | diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index fb81f80e..7c20d1b7 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -480,8 +480,8 @@ their behavior: and [Interactive categorical legend](./scales-guides-and-color.md#interactive-categorical-legend). - `@tanstack/charts/interaction/brush`: `BrushRange`, `BrushXChange`, - `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, and - `BrushXContinuousOptions`. See + `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, + `BrushXContinuousOptions`, and `ChartControlHover`. See [Horizontal brush](./focus-and-interaction.md#horizontal-brush). - `@tanstack/charts/interaction/cursor`: `ContinuousCursorValue`, `ContinuousCursorPosition`, `ContinuousCursorPointerSource`, @@ -490,8 +490,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and - `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, + `ZoomXOptions`, and `ChartControlHover`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). - `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, diff --git a/packages/charts-core/src/dom-types.ts b/packages/charts-core/src/dom-types.ts index b572a78d..f8e9a3d4 100644 --- a/packages/charts-core/src/dom-types.ts +++ b/packages/charts-core/src/dom-types.ts @@ -37,6 +37,12 @@ export interface ChartHostControlExtensionContext { export interface ChartHostControlInstance { update: (control: ChartHostControl, scene: ChartScene) => void contains?: (target: EventTarget | null) => boolean + /** + * Returns true when an idle pointer move over a contained `target` should + * still resolve chart point focus. Presses, clicks, wheel, and keyboard + * input stay with the control. + */ + passesHover?: (target: EventTarget | null) => boolean destroy: () => void } diff --git a/packages/charts-core/src/interaction-brush.test.ts b/packages/charts-core/src/interaction-brush.test.ts index 759c9aea..e447fdad 100644 --- a/packages/charts-core/src/interaction-brush.test.ts +++ b/packages/charts-core/src/interaction-brush.test.ts @@ -2,7 +2,12 @@ import { scaleLinear, scaleUtc } from 'd3-scale' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { dot } from './dot' import { mountChart } from './dom' -import { brushX, type BrushRange, type BrushXChange } from './interaction-brush' +import { + brushX, + type BrushRange, + type BrushXChange, + type ChartControlHover, +} from './interaction-brush' import { controlledSignal, type ControlledSignalChangeContext, @@ -268,6 +273,52 @@ describe('brushX', () => { container.remove() }) + it.each([ + [undefined, false], + ['capture', false], + ['passthrough', true], + ] as const)( + 'with hover %s, forwards idle blank-plot hover to point focus: %s', + (hover, forwards) => { + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + const host = mountChart(container, { + definition: definition( + range(dates[1], dates[2]), + () => {}, + dates, + false, + hover, + ), + width: 480, + height: 240, + ariaLabel: 'Hover date range', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + const root = container.querySelector('[data-chart-brush="window"]')! + const overlay = root.querySelector('.overlay')! + const selection = root.querySelector('.selection')! + const [first, , last] = host.getScene().points + const focused = () => focusChanges.at(-1) ?? null + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBe(forwards ? first!.key : null) + + selection.dispatchEvent(pointerMove(last!.x, last!.y)) + expect(focused()).toBeNull() + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + beginMouseBrush(container) + root.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBeNull() + + host.destroy() + container.remove() + }, + ) + it('rejects duplicate and nonmonotone authored values at scene resolution', () => { const value = range(dates[0], dates[2]) expect(() => @@ -313,6 +364,7 @@ function definition( onChange: (value: BrushRange, reason: BrushXChange) => void, values: readonly Date[] = dates, reverse = false, + hover?: ChartControlHover, ) { return defineChart({ marks: [dot(rows, { x: 'date', y: 'value' })], @@ -328,6 +380,7 @@ function definition( (next, { reason }) => onChange(next, reason), ), values, + hover, ariaLabel: 'Visible date range', format: (date) => date.toLocaleDateString('en-US', { @@ -388,6 +441,24 @@ function dispatchTouch( target.dispatchEvent(event) } +function pointerMove(clientX: number, clientY: number) { + return new MouseEvent('pointermove', { bubbles: true, clientX, clientY }) +} + +function mockBounds(element: Element, width: number, height: number) { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: width, + bottom: height, + left: 0, + width, + height, + toJSON: () => ({}), + }) +} + function rectGeometry(element: SVGRectElement) { return { x: element.getAttribute('x'), diff --git a/packages/charts-core/src/interaction-brush.ts b/packages/charts-core/src/interaction-brush.ts index 72aea517..27043e43 100644 --- a/packages/charts-core/src/interaction-brush.ts +++ b/packages/charts-core/src/interaction-brush.ts @@ -5,8 +5,10 @@ import type { InteractionAxis } from './interaction-axis-internal' import { cloneInteractionRange as cloneRange, normalizeInteractionRange as normalizeRange, + resolveControlHover, sameInteractionRange as sameRange, } from './interaction-range-internal' +import type { ChartControlHover } from './interaction-range-internal' import type { ControlledSignal } from './interaction-signal' import type { ChartControl, @@ -23,6 +25,8 @@ import type { } from './dom-types' import type { BrushSelection, D3BrushEvent } from 'd3-brush' +export type { ChartControlHover } from './interaction-range-internal' + export interface BrushRange { readonly start: TValue readonly end: TValue @@ -56,6 +60,12 @@ interface BrushXBaseOptions { endAriaLabel?: string format?: (value: TValue) => string handleSize?: number + /** + * `passthrough` lets idle hover over the blank plot focus points and show + * the tooltip. The selection, handles, drags, clicks, and keyboard input + * stay with the brush. Defaults to `capture`. + */ + hover?: ChartControlHover selectionStyle?: SceneStyle handleStyle?: SceneStyle } @@ -88,6 +98,7 @@ interface BrushXControl extends ChartHostControl { readonly endAriaLabel: string readonly format: (value: TValue) => string readonly handleSize: number + readonly hover: ChartControlHover readonly selectionStyle: SceneStyle readonly handleStyle: SceneStyle readonly change: ( @@ -112,6 +123,7 @@ export function brushX( ): ChartControl { const id = options.id ?? defaultId const handleSize = finitePositive(options.handleSize ?? 24, 'handleSize') + const hover = resolveControlHover(options.hover, 'brushX') const keyboard = 'values' in options && options.values !== undefined ? options.keyboard !== false @@ -163,6 +175,7 @@ export function brushX( endAriaLabel: options.endAriaLabel ?? 'Range end', format, handleSize, + hover, selectionStyle, handleStyle, change(value, reason) { @@ -327,6 +340,14 @@ function createBrushXControl({ contains(eventTarget) { return Boolean(eventTarget && root.contains(eventTarget as Node)) }, + passesHover(eventTarget) { + return ( + control?.hover === 'passthrough' && + !active && + (eventTarget === root || + eventTarget === group.querySelector('.overlay')) + ) + }, destroy() { if (active) { active = false diff --git a/packages/charts-core/src/interaction-range-internal.ts b/packages/charts-core/src/interaction-range-internal.ts index 6beb53fd..2c0cf06c 100644 --- a/packages/charts-core/src/interaction-range-internal.ts +++ b/packages/charts-core/src/interaction-range-internal.ts @@ -40,3 +40,20 @@ export function cloneInteractionValue( ): TValue { return (value instanceof Date ? new Date(value.getTime()) : value) as TValue } + +/** + * Whether idle pointer hover over a first-party host control reaches chart + * point focus and tooltips. `capture` keeps hover inside the control; + * `passthrough` forwards idle hover while presses, drags, wheel, clicks, and + * keyboard input stay with the control. + */ +export type ChartControlHover = 'capture' | 'passthrough' + +export function resolveControlHover( + hover: ChartControlHover | undefined, + owner: string, +): ChartControlHover { + if (hover === undefined || hover === 'capture') return 'capture' + if (hover === 'passthrough') return hover + throw new TypeError(`${owner} hover must be "capture" or "passthrough"`) +} diff --git a/packages/charts-core/src/interaction-zoom.test.ts b/packages/charts-core/src/interaction-zoom.test.ts index d05984da..e346b069 100644 --- a/packages/charts-core/src/interaction-zoom.test.ts +++ b/packages/charts-core/src/interaction-zoom.test.ts @@ -51,6 +51,15 @@ describe('zoomX', () => { 'zoomX wheelActivation must be "focus", "modifier", or "always"', ), ) + expect(() => + zoomX({ + window: controlledSignal({ start: 0, end: 1 }, () => {}), + extent: [0, 1], + hover: 'always' as never, + }), + ).toThrowError( + new TypeError('zoomX hover must be "capture" or "passthrough"'), + ) }) it('owns focus, keyboard navigation, reset, controlled updates, and teardown', () => { @@ -697,6 +706,64 @@ describe('zoomX', () => { } }) + it.each([ + [undefined, false], + ['capture', false], + ['passthrough', true], + ] as const)( + 'with hover %s, forwards idle plot hover to point focus: %s', + (hover, forwards) => { + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + const host = mountChart(container, { + definition: defineChart({ + marks: [dot(numericRows, { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 10]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + controls: [ + zoomX({ + id: 'window', + window: controlledSignal< + ZoomXWindow, + ZoomXChange + >({ start: 0, end: 10 }, () => {}), + extent: [0, 10], + hover, + }), + ], + }), + width: 480, + height: 240, + ariaLabel: 'Hover zoom', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + const target = zoomTarget(container) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + mockBounds(target, 480, 240) + const [point] = host.getScene().points + const hoverPoint = () => + target.dispatchEvent(mouse('pointermove', point!.x, point!.y)) + + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBe(forwards ? point!.key : null) + + target.focus() + target.dispatchEvent(mouse('mousedown', point!.x, point!.y)) + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBeNull() + window.dispatchEvent(mouse('mouseup', point!.x, point!.y)) + + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBe(forwards ? point!.key : null) + + host.destroy() + container.remove() + }, + ) + it('previews and commits mouse pan, then rolls a later pan back on Escape', () => { const initial = { start: 2.5, end: 7.5 } const calls: Array<{ diff --git a/packages/charts-core/src/interaction-zoom.ts b/packages/charts-core/src/interaction-zoom.ts index bf3da3b3..6b932e6a 100644 --- a/packages/charts-core/src/interaction-zoom.ts +++ b/packages/charts-core/src/interaction-zoom.ts @@ -5,8 +5,10 @@ import type { InteractionAxis } from './interaction-axis-internal' import { cloneInteractionRange, normalizeInteractionRange, + resolveControlHover, sameInteractionRange, } from './interaction-range-internal' +import type { ChartControlHover } from './interaction-range-internal' import type { ControlledSignal } from './interaction-signal' import type { ChartControl, @@ -20,6 +22,8 @@ import type { } from './dom-types' import type { D3ZoomEvent, ZoomTransform } from 'd3-zoom' +export type { ChartControlHover } from './interaction-range-internal' + export type ZoomXValue = number | Date export interface ZoomXWindow { @@ -63,6 +67,12 @@ export interface ZoomXOptions { scaleExtent?: readonly [number, number] /** When wheel input may zoom or pan. Defaults to `focus`. */ wheelActivation?: ZoomXWheelActivation + /** + * `passthrough` lets idle hover over the plot focus points and show the + * tooltip. Drag, wheel, click, and keyboard input stay with zoom. Defaults + * to `capture`. + */ + hover?: ChartControlHover keyboard?: boolean ariaLabel?: string ariaDescription?: string @@ -81,6 +91,7 @@ interface ZoomXControl extends ChartHostControl { readonly extent: ZoomXWindow readonly scaleExtent: readonly [number, number] readonly wheelActivation: ZoomXWheelActivation + readonly hover: ChartControlHover readonly keyboard: boolean readonly ariaLabel: string readonly ariaDescription?: string @@ -110,6 +121,7 @@ export function zoomX( } const scaleExtent = resolveScaleExtent(options.scaleExtent) const wheelActivation = resolveWheelActivation(options.wheelActivation) + const hover = resolveControlHover(options.hover, 'zoomX') return { id, @@ -146,6 +158,7 @@ export function zoomX( extent, scaleExtent, wheelActivation, + hover, keyboard: options.keyboard !== false, ariaLabel: options.ariaLabel ?? defaultAriaLabel, ariaDescription: options.ariaDescription, @@ -301,6 +314,9 @@ function createZoomXControl({ contains(target) { return Boolean(target && root.contains(target as Node)) }, + passesHover() { + return control?.hover === 'passthrough' && !gestureActive + }, destroy() { destroying = true clearWheel() diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index d9d78e55..d49704b9 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -684,7 +684,7 @@ export function mountChartRenderer< } const handlePointerMove = (event: PointerEvent) => { - if (controlContains(event.target)) { + if (controlContains(event.target, true)) { if (!interactionIsPinned()) { pointerPosition = null updateFocus([]) @@ -1083,9 +1083,14 @@ export function mountChartRenderer< controlInstances.clear() } - function controlContains(target: EventTarget | null) { - for (const current of controlInstances.values()) { - if (current.instance.contains?.(target)) return true + function controlContains(target: EventTarget | null, hover?: boolean) { + for (const { instance } of controlInstances.values()) { + if ( + instance.contains?.(target) && + !(hover && instance.passesHover?.(target)) + ) { + return true + } } return false } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..c222cea2 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1555,7 +1555,7 @@ const entries = [ 'Horizontal zoom + DOM host', 'benchmarks/entries/charts-zoom-x.ts', 'TanStack DOM host', - 20.55, + 20.57, { rendererBoundary: 'svg', inputBoundary: { @@ -1630,7 +1630,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 44.98, + 44.99, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', From 7fddcc4da85e517e30707911fcbff7586ca9ce88 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 14:07:54 -1000 Subject: [PATCH 2/8] chore(benchmarks): refresh comparison bundle baseline for control hover --- benchmarks/comparison/bundle-baseline.json | 126 ++++++++++----------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 37d5af0a..3d6ea879 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:48.290Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "4bbe1c5690789064f2c525226321baa5817af39a", - "inputDigest": "sha256:b07d6531a6ef39c0c4e9295f2b3d5335e67a38f9255517426f757a3efedecaad" + "revision": "08a3a5ab6b57ba0667d962ce7cedab6591b5e264", + "inputDigest": "sha256:87291e75c5a3c6f969b750079cd351fdff80ba6a3a1f514b54bd8dfb832b9d4c" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117941, - "gzipBytes": 42555, - "brotliBytes": 37623, - "incrementalGzipBytes": 42555, - "incrementalBrotliBytes": 37623 + "minifiedBytes": 117972, + "gzipBytes": 42569, + "brotliBytes": 37725, + "incrementalGzipBytes": 42569, + "incrementalBrotliBytes": 37725 }, "tanstack-line-interactive": { - "minifiedBytes": 123686, - "gzipBytes": 44460, - "brotliBytes": 39218, - "incrementalGzipBytes": 44460, - "incrementalBrotliBytes": 39218 + "minifiedBytes": 123717, + "gzipBytes": 44483, + "brotliBytes": 39231, + "incrementalGzipBytes": 44483, + "incrementalBrotliBytes": 39231 }, "tanstack-line-advanced": { - "minifiedBytes": 130878, - "gzipBytes": 46798, - "brotliBytes": 41196, - "incrementalGzipBytes": 46798, - "incrementalBrotliBytes": 41196 + "minifiedBytes": 130909, + "gzipBytes": 46817, + "brotliBytes": 41224, + "incrementalGzipBytes": 46817, + "incrementalBrotliBytes": 41224 }, "tanstack-bar-basic": { - "minifiedBytes": 130379, - "gzipBytes": 47284, - "brotliBytes": 41635, - "incrementalGzipBytes": 47284, - "incrementalBrotliBytes": 41635 + "minifiedBytes": 130410, + "gzipBytes": 47299, + "brotliBytes": 41597, + "incrementalGzipBytes": 47299, + "incrementalBrotliBytes": 41597 }, "tanstack-bar-interactive": { - "minifiedBytes": 134983, - "gzipBytes": 48679, - "brotliBytes": 42750, - "incrementalGzipBytes": 48679, - "incrementalBrotliBytes": 42750 + "minifiedBytes": 135014, + "gzipBytes": 48699, + "brotliBytes": 42817, + "incrementalGzipBytes": 48699, + "incrementalBrotliBytes": 42817 }, "tanstack-bar-advanced": { - "minifiedBytes": 135322, - "gzipBytes": 48828, - "brotliBytes": 42851, - "incrementalGzipBytes": 48828, - "incrementalBrotliBytes": 42851 + "minifiedBytes": 135353, + "gzipBytes": 48843, + "brotliBytes": 42928, + "incrementalGzipBytes": 48843, + "incrementalBrotliBytes": 42928 }, "tanstack-area-basic": { - "minifiedBytes": 123121, - "gzipBytes": 44596, - "brotliBytes": 39388, - "incrementalGzipBytes": 44596, - "incrementalBrotliBytes": 39388 + "minifiedBytes": 123152, + "gzipBytes": 44615, + "brotliBytes": 39416, + "incrementalGzipBytes": 44615, + "incrementalBrotliBytes": 39416 }, "tanstack-area-interactive": { - "minifiedBytes": 128876, - "gzipBytes": 46452, - "brotliBytes": 40950, - "incrementalGzipBytes": 46452, - "incrementalBrotliBytes": 40950 + "minifiedBytes": 128907, + "gzipBytes": 46469, + "brotliBytes": 40907, + "incrementalGzipBytes": 46469, + "incrementalBrotliBytes": 40907 }, "tanstack-area-advanced": { - "minifiedBytes": 136250, - "gzipBytes": 48807, - "brotliBytes": 43047, - "incrementalGzipBytes": 48807, - "incrementalBrotliBytes": 43047 + "minifiedBytes": 136281, + "gzipBytes": 48823, + "brotliBytes": 43019, + "incrementalGzipBytes": 48823, + "incrementalBrotliBytes": 43019 }, "tanstack-scatter-basic": { - "minifiedBytes": 118947, - "gzipBytes": 42908, - "brotliBytes": 37974, - "incrementalGzipBytes": 42908, - "incrementalBrotliBytes": 37974 + "minifiedBytes": 118978, + "gzipBytes": 42930, + "brotliBytes": 37967, + "incrementalGzipBytes": 42930, + "incrementalBrotliBytes": 37967 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124696, - "gzipBytes": 44826, - "brotliBytes": 39490, - "incrementalGzipBytes": 44826, - "incrementalBrotliBytes": 39490 + "minifiedBytes": 124727, + "gzipBytes": 44847, + "brotliBytes": 39504, + "incrementalGzipBytes": 44847, + "incrementalBrotliBytes": 39504 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124712, - "gzipBytes": 44832, - "brotliBytes": 39527, - "incrementalGzipBytes": 44832, - "incrementalBrotliBytes": 39527 + "minifiedBytes": 124743, + "gzipBytes": 44854, + "brotliBytes": 39464, + "incrementalGzipBytes": 44854, + "incrementalBrotliBytes": 39464 }, "chartjs-line-basic": { "minifiedBytes": 137909, From c7c5450ff70ac72aa01560c258271e3785d15e28 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 14:10:23 -1000 Subject: [PATCH 3/8] docs: align comparison evidence with the control hover revision --- docs/comparison.md | 8 ++++---- packages/charts-core/docs/comparison.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/comparison.md b/docs/comparison.md index 1e349fe5..998f94bb 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 `08a3a5a` | | [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 `08a3a5a`. ## 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.57–47.70 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/comparison.md b/packages/charts-core/docs/comparison.md index 1e349fe5..998f94bb 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 `08a3a5a` | | [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 `08a3a5a`. ## 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.57–47.70 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 | From 61fff47451953a22f86044c3e68700c5a52f99e0 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:11:04 -1000 Subject: [PATCH 4/8] docs(api-friction): number the control hover entry F-307 --- API-FRICTION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 17d5c5a8..dec9a35a 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -343,7 +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-309 | Brush and zoom hid point focus on their own plot | API | resolved | +| F-307 | Brush and zoom hid point focus on their own plot | API | resolved | ## Findings @@ -9047,7 +9047,7 @@ Each entry records: as a downstream consumer. Packed runtime, React Native, and seven framework adapter gates pass. All 60 comparison bundle measurements are unchanged. -### F-309 - Brush and zoom hid point focus on their own plot +### F-307 - Brush and zoom hid point focus on their own plot - Status: resolved - Severity: medium From ceccb9261078c40103dea3b509740610497a2642 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:17:28 -1000 Subject: [PATCH 5/8] fix(interaction): keep passthrough hover focus when a control takes focus A passthrough zoom surface needs focus for default wheel activation. Taking that focus no longer clears the resting pointer focus, so an accepted wheel zoom re-resolves the tooltip at the last pointer position. Cache the brush overlay lookup and cover focus returning after a brush drag ends. --- .../bundle-size/universal-baseline.json | 10 +-- .../charts-core/src/interaction-brush.test.ts | 10 +++ packages/charts-core/src/interaction-brush.ts | 5 +- .../charts-core/src/interaction-zoom.test.ts | 62 +++++++++++++++++++ packages/charts-core/src/renderer.ts | 2 +- 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index b2dd2500..248a8fb4 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,23 +15,23 @@ "gzip": 30340 }, "TanStack DOM host": { - "bytes": 81901, + "bytes": 81904, "gzip": 28596 }, "React adapter": { - "bytes": 84226, + "bytes": 84229, "gzip": 29532 }, "React line consumer": { - "bytes": 107885, - "gzip": 38866 + "bytes": 107888, + "gzip": 38865 }, "Compact-scale line scene": { "bytes": 35865, "gzip": 12732 }, "React compact-scale line consumer": { - "bytes": 90385, + "bytes": 90388, "gzip": 31763 }, "Custom-scale line scene": { diff --git a/packages/charts-core/src/interaction-brush.test.ts b/packages/charts-core/src/interaction-brush.test.ts index e447fdad..8fc5f09f 100644 --- a/packages/charts-core/src/interaction-brush.test.ts +++ b/packages/charts-core/src/interaction-brush.test.ts @@ -313,6 +313,16 @@ describe('brushX', () => { beginMouseBrush(container) root.dispatchEvent(pointerMove(first!.x, first!.y)) expect(focused()).toBeNull() + const mouseup = new MouseEvent('mouseup', { + bubbles: true, + clientX: 120, + clientY: 100, + }) + Object.defineProperty(mouseup, 'view', { value: window }) + window.dispatchEvent(mouseup) + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBe(forwards ? first!.key : null) host.destroy() container.remove() diff --git a/packages/charts-core/src/interaction-brush.ts b/packages/charts-core/src/interaction-brush.ts index 27043e43..787604b0 100644 --- a/packages/charts-core/src/interaction-brush.ts +++ b/packages/charts-core/src/interaction-brush.ts @@ -282,6 +282,7 @@ function createBrushXControl({ let activeInput: 'mouse' | 'touch' | undefined let activeView: (Window & typeof globalThis) | undefined let cancelledTouchRange: BrushRange | undefined + let overlay: Element | null = null const brush = createD3BrushX() .touchable(true) .on('start.chart-brush-x', handleStart) @@ -317,6 +318,7 @@ function createBrushXControl({ ]) .handleSize(next.handleSize) select(group).call(brush) + overlay = group.querySelector('.overlay') if ( active && @@ -344,8 +346,7 @@ function createBrushXControl({ return ( control?.hover === 'passthrough' && !active && - (eventTarget === root || - eventTarget === group.querySelector('.overlay')) + (eventTarget === root || eventTarget === overlay) ) }, destroy() { diff --git a/packages/charts-core/src/interaction-zoom.test.ts b/packages/charts-core/src/interaction-zoom.test.ts index e346b069..57efc5ae 100644 --- a/packages/charts-core/src/interaction-zoom.test.ts +++ b/packages/charts-core/src/interaction-zoom.test.ts @@ -764,6 +764,68 @@ describe('zoomX', () => { }, ) + it('keeps passthrough hover focus through surface focus and re-resolves it after wheel zoom', () => { + const rows = [ + { x: 2, y: 5 }, + { x: 8, y: 5 }, + ] + let accepted: ZoomXWindow = { start: 0, end: 10 } + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + let host: ChartHost<(typeof rows)[number], number, number> + const options = (): ChartHostOptions< + (typeof rows)[number], + number, + number + > => ({ + definition: defineChart({ + marks: [dot(rows, { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([accepted.start, accepted.end]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + controls: [ + zoomX({ + id: 'window', + window: controlledSignal, ZoomXChange>( + accepted, + (next) => { + accepted = copyWindow(next) + host.update(options()) + }, + ), + extent: [0, 10], + hover: 'passthrough', + }), + ], + }), + width: 480, + height: 240, + ariaLabel: 'Wheel zoom with tooltips', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + host = mountChart(container, options()) + const target = zoomTarget(container) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + const [point] = host.getScene().points + target.dispatchEvent(mouse('pointermove', point!.x, point!.y)) + expect(focusChanges.at(-1)).toBe(point!.key) + + target.dispatchEvent(mouse('pointerdown', point!.x, point!.y)) + expect(document.activeElement).toBe(target) + expect(focusChanges.at(-1)).toBe(point!.key) + + focusChanges.length = 0 + target.dispatchEvent(wheel(point!.x, point!.y, { deltaY: -240 })) + expect(accepted.end - accepted.start).toBeLessThan(10) + expect(focusChanges.length).toBeGreaterThan(0) + expect(focusChanges.at(-1)).toBe(point!.key) + + host.destroy() + container.remove() + }) + it('previews and commits mouse pan, then rolls a later pan back on Escape', () => { const initial = { start: 2.5, end: 7.5 } const calls: Array<{ diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index d49704b9..6c7e95df 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -878,7 +878,7 @@ export function mountChartRenderer< updateFocus(point ? focusPointsForPoint(point) : []) } const handleFocus = (event: FocusEvent) => { - if (controlContains(event.target)) { + if (controlContains(event.target, true)) { if (!pinnedKey) { pointerPosition = null updateFocus([]) From b32fb23ebf525127a2821e759fe27e87e945d40e Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:20:08 -1000 Subject: [PATCH 6/8] chore(benchmarks): refresh comparison evidence for the focus fix --- benchmarks/comparison/bundle-baseline.json | 94 +++++++++++----------- docs/comparison.md | 4 +- packages/charts-core/docs/comparison.md | 4 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 3d6ea879..954636aa 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-01T00:07:48.290Z", + "generatedAt": "2026-10-01T01:18:11.164Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "08a3a5ab6b57ba0667d962ce7cedab6591b5e264", - "inputDigest": "sha256:87291e75c5a3c6f969b750079cd351fdff80ba6a3a1f514b54bd8dfb832b9d4c" + "revision": "ceccb9261078c40103dea3b509740610497a2642", + "inputDigest": "sha256:d25829666635782c4139842be3d34bd867ad33bfec416d31e4ba722b4ddfbb5e" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117972, + "minifiedBytes": 117975, "gzipBytes": 42569, - "brotliBytes": 37725, + "brotliBytes": 37671, "incrementalGzipBytes": 42569, - "incrementalBrotliBytes": 37725 + "incrementalBrotliBytes": 37671 }, "tanstack-line-interactive": { - "minifiedBytes": 123717, + "minifiedBytes": 123720, "gzipBytes": 44483, - "brotliBytes": 39231, + "brotliBytes": 39175, "incrementalGzipBytes": 44483, - "incrementalBrotliBytes": 39231 + "incrementalBrotliBytes": 39175 }, "tanstack-line-advanced": { - "minifiedBytes": 130909, + "minifiedBytes": 130912, "gzipBytes": 46817, - "brotliBytes": 41224, + "brotliBytes": 41261, "incrementalGzipBytes": 46817, - "incrementalBrotliBytes": 41224 + "incrementalBrotliBytes": 41261 }, "tanstack-bar-basic": { - "minifiedBytes": 130410, - "gzipBytes": 47299, - "brotliBytes": 41597, - "incrementalGzipBytes": 47299, - "incrementalBrotliBytes": 41597 + "minifiedBytes": 130413, + "gzipBytes": 47298, + "brotliBytes": 41623, + "incrementalGzipBytes": 47298, + "incrementalBrotliBytes": 41623 }, "tanstack-bar-interactive": { - "minifiedBytes": 135014, - "gzipBytes": 48699, + "minifiedBytes": 135017, + "gzipBytes": 48698, "brotliBytes": 42817, - "incrementalGzipBytes": 48699, + "incrementalGzipBytes": 48698, "incrementalBrotliBytes": 42817 }, "tanstack-bar-advanced": { - "minifiedBytes": 135353, + "minifiedBytes": 135356, "gzipBytes": 48843, - "brotliBytes": 42928, + "brotliBytes": 42883, "incrementalGzipBytes": 48843, - "incrementalBrotliBytes": 42928 + "incrementalBrotliBytes": 42883 }, "tanstack-area-basic": { - "minifiedBytes": 123152, - "gzipBytes": 44615, - "brotliBytes": 39416, - "incrementalGzipBytes": 44615, - "incrementalBrotliBytes": 39416 + "minifiedBytes": 123155, + "gzipBytes": 44614, + "brotliBytes": 39423, + "incrementalGzipBytes": 44614, + "incrementalBrotliBytes": 39423 }, "tanstack-area-interactive": { - "minifiedBytes": 128907, - "gzipBytes": 46469, - "brotliBytes": 40907, - "incrementalGzipBytes": 46469, - "incrementalBrotliBytes": 40907 + "minifiedBytes": 128910, + "gzipBytes": 46471, + "brotliBytes": 40998, + "incrementalGzipBytes": 46471, + "incrementalBrotliBytes": 40998 }, "tanstack-area-advanced": { - "minifiedBytes": 136281, - "gzipBytes": 48823, - "brotliBytes": 43019, - "incrementalGzipBytes": 48823, - "incrementalBrotliBytes": 43019 + "minifiedBytes": 136284, + "gzipBytes": 48821, + "brotliBytes": 43044, + "incrementalGzipBytes": 48821, + "incrementalBrotliBytes": 43044 }, "tanstack-scatter-basic": { - "minifiedBytes": 118978, + "minifiedBytes": 118981, "gzipBytes": 42930, - "brotliBytes": 37967, + "brotliBytes": 37989, "incrementalGzipBytes": 42930, - "incrementalBrotliBytes": 37967 + "incrementalBrotliBytes": 37989 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124727, + "minifiedBytes": 124730, "gzipBytes": 44847, - "brotliBytes": 39504, + "brotliBytes": 39517, "incrementalGzipBytes": 44847, - "incrementalBrotliBytes": 39504 + "incrementalBrotliBytes": 39517 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124743, + "minifiedBytes": 124746, "gzipBytes": 44854, - "brotliBytes": 39464, + "brotliBytes": 39508, "incrementalGzipBytes": 44854, - "incrementalBrotliBytes": 39464 + "incrementalBrotliBytes": 39508 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 998f94bb..78eb6c60 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 `08a3a5a` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `ceccb92` | | [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 `08a3a5a`. +at page render time. The measured TanStack workspace revision is `ceccb92`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 998f94bb..78eb6c60 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 `08a3a5a` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `ceccb92` | | [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 `08a3a5a`. +at page render time. The measured TanStack workspace revision is `ceccb92`. ## Capability matrix From dfa89acac3fb95e86b6158f5aa12094ffcf9c8de Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:47:36 -1000 Subject: [PATCH 7/8] chore: align hover callback contract and comparison provenance --- benchmarks/comparison/bundle-baseline.json | 15 +++------------ docs/comparison.md | 12 ++++++------ packages/charts-core/docs/comparison.md | 12 ++++++------ scripts/public-callback-contract.mjs | 2 +- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 8dc46813..687befbc 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "ceccb9261078c40103dea3b509740610497a2642", + "revision": "cad4bc90aff2ca2de61dc15f0eb573d64f33b451", "inputDigest": "sha256:b29a3bd6185c2ffd4c8d840dabc66096d2348d40d2b9ec3dd335fa957d453a58" }, "chartjs": { @@ -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, diff --git a/docs/comparison.md b/docs/comparison.md index e2687c22..46a39d0d 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 `3b8e03c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cad4bc9` | | [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 `3b8e03c`. +at page render time. The measured TanStack workspace revision is `cad4bc9`. ## Capability matrix @@ -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.23–49.52 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 e2687c22..46a39d0d 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 `3b8e03c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cad4bc9` | | [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 `3b8e03c`. +at page render time. The measured TanStack workspace revision is `cad4bc9`. ## Capability matrix @@ -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.23–49.52 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/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 048a75fa..cb9864ec 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -606,7 +606,7 @@ const callbackInventory = { ['@tanstack/charts:src/dom-types.ts:ChartHostControlExtension', 'create'], [ '@tanstack/charts:src/dom-types.ts:ChartHostControlInstance', - 'update contains destroy', + 'update contains passesHover destroy', ], [ '@tanstack/angular-charts:src/Chart.ts:Chart', From 313107cd1771c6e845c0b15bdd777ddd36f48cde Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 14:02:42 -1000 Subject: [PATCH 8/8] chore: refresh hover comparison evidence on latest main --- benchmarks/comparison/bundle-baseline.json | 6 +++--- docs/comparison.md | 6 +++--- packages/charts-core/docs/comparison.md | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 687befbc..e26e4ae8 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:37:46.512Z", + "generatedAt": "2026-10-14T00:02:00.663Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "cad4bc90aff2ca2de61dc15f0eb573d64f33b451", - "inputDigest": "sha256:b29a3bd6185c2ffd4c8d840dabc66096d2348d40d2b9ec3dd335fa957d453a58" + "revision": "3f579fd6a4057b444eb35baf7d3eb1d95347f66f", + "inputDigest": "sha256:a407dcfc0ad7cdda7e2869906d33cda8af55faa4a93d4a5512cd9b1ac1f1f337" }, "chartjs": { "kind": "package", diff --git a/docs/comparison.md b/docs/comparison.md index 46a39d0d..0ab3ec4f 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 `cad4bc9` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3f579fd` | | [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 `cad4bc9`. +at page render time. The measured TanStack workspace revision is `3f579fd`. ## 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 diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 46a39d0d..0ab3ec4f 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 `cad4bc9` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3f579fd` | | [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 `cad4bc9`. +at page render time. The measured TanStack workspace revision is `3f579fd`. ## 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