diff --git a/.changeset/visual-tooltip-order.md b/.changeset/visual-tooltip-order.md new file mode 100644 index 00000000..3ba1b75c --- /dev/null +++ b/.changeset/visual-tooltip-order.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': minor +--- + +Order grouped tooltip rows by rendered mark position by default: top-to-bottom +for x groups and left-to-right for y groups. Add `visual` as an explicit sort +policy while preserving color-domain, focus, and custom comparator ordering. diff --git a/API-FRICTION.md b/API-FRICTION.md index 10343d2f..f942b5a4 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -201,6 +201,7 @@ Each entry records: | F-163 | Cross-row transforms lacked a public ownership boundary | API | resolved | | F-164 | Sankey widths required a custom scene renderer | API | resolved | | F-165 | Incidental D3 utilities leaked into core paths | API/Tooling | resolved | +| F-166 | Grouped tooltip order diverged from mark position | API | resolved | ## Findings @@ -3986,3 +3987,17 @@ Each entry records: every `d3-*` module plus `internmap` from compact consumers, while selected transform, polar, geo, and curve features retain their owned D3 implementation. + +### F-166 — Grouped tooltip order diverged from mark position + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: reviewing the default grouped-tooltip row order +- Friction: the default followed the color domain, so a tooltip could list + series in an order unrelated to the marks under the pointer. +- Decision: default to visual order. X-grouped rows follow y position from top + to bottom; y-grouped rows follow x position from left to right. Preserve + `color-domain`, `focus`, and custom comparators as explicit policies. +- Verification: runtime tests cover both axes with input and color-domain order + opposed to the rendered mark order. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 584297c9..633c24a4 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, - "generatedAt": "2026-07-31T21:48:07.702Z", + "generatedAt": "2026-08-01T17:40:25.541Z", "packageVersions": { - "tanstack": "0.3.0", + "tanstack": "0.3.1", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "c422a2ce45799d4edd63fdbde7ecb31daa3dae31" + "revision": "d563eba5d295dc3ddf60eecd2d8a9bd418aaf635" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 72114, - "gzipBytes": 27318, - "brotliBytes": 24182, - "incrementalGzipBytes": 27318, - "incrementalBrotliBytes": 24182 + "minifiedBytes": 72327, + "gzipBytes": 27384, + "brotliBytes": 24255, + "incrementalGzipBytes": 27384, + "incrementalBrotliBytes": 24255 }, "tanstack-line-interactive": { - "minifiedBytes": 77285, - "gzipBytes": 28970, - "brotliBytes": 25539, - "incrementalGzipBytes": 28970, - "incrementalBrotliBytes": 25539 + "minifiedBytes": 77498, + "gzipBytes": 29036, + "brotliBytes": 25644, + "incrementalGzipBytes": 29036, + "incrementalBrotliBytes": 25644 }, "tanstack-line-advanced": { - "minifiedBytes": 84466, - "gzipBytes": 31307, - "brotliBytes": 27553, - "incrementalGzipBytes": 31307, - "incrementalBrotliBytes": 27553 + "minifiedBytes": 84679, + "gzipBytes": 31379, + "brotliBytes": 27617, + "incrementalGzipBytes": 31379, + "incrementalBrotliBytes": 27617 }, "tanstack-bar-basic": { - "minifiedBytes": 79117, - "gzipBytes": 29968, - "brotliBytes": 26390, - "incrementalGzipBytes": 29968, - "incrementalBrotliBytes": 26390 + "minifiedBytes": 79330, + "gzipBytes": 30040, + "brotliBytes": 26485, + "incrementalGzipBytes": 30040, + "incrementalBrotliBytes": 26485 }, "tanstack-bar-interactive": { - "minifiedBytes": 83147, - "gzipBytes": 31184, - "brotliBytes": 27466, - "incrementalGzipBytes": 31184, - "incrementalBrotliBytes": 27466 + "minifiedBytes": 83360, + "gzipBytes": 31257, + "brotliBytes": 27474, + "incrementalGzipBytes": 31257, + "incrementalBrotliBytes": 27474 }, "tanstack-bar-advanced": { - "minifiedBytes": 83486, - "gzipBytes": 31333, - "brotliBytes": 27583, - "incrementalGzipBytes": 31333, - "incrementalBrotliBytes": 27583 + "minifiedBytes": 83699, + "gzipBytes": 31398, + "brotliBytes": 27656, + "incrementalGzipBytes": 31398, + "incrementalBrotliBytes": 27656 }, "tanstack-area-basic": { - "minifiedBytes": 75923, - "gzipBytes": 28763, - "brotliBytes": 25453, - "incrementalGzipBytes": 28763, - "incrementalBrotliBytes": 25453 + "minifiedBytes": 76136, + "gzipBytes": 28832, + "brotliBytes": 25473, + "incrementalGzipBytes": 28832, + "incrementalBrotliBytes": 25473 }, "tanstack-area-interactive": { - "minifiedBytes": 81098, - "gzipBytes": 30458, - "brotliBytes": 26812, - "incrementalGzipBytes": 30458, - "incrementalBrotliBytes": 26812 + "minifiedBytes": 81311, + "gzipBytes": 30514, + "brotliBytes": 26891, + "incrementalGzipBytes": 30514, + "incrementalBrotliBytes": 26891 }, "tanstack-area-advanced": { - "minifiedBytes": 88465, - "gzipBytes": 32852, - "brotliBytes": 28903, - "incrementalGzipBytes": 32852, - "incrementalBrotliBytes": 28903 + "minifiedBytes": 88678, + "gzipBytes": 32908, + "brotliBytes": 29053, + "incrementalGzipBytes": 32908, + "incrementalBrotliBytes": 29053 }, "tanstack-scatter-basic": { - "minifiedBytes": 71819, - "gzipBytes": 27220, - "brotliBytes": 24141, - "incrementalGzipBytes": 27220, - "incrementalBrotliBytes": 24141 + "minifiedBytes": 72032, + "gzipBytes": 27293, + "brotliBytes": 24225, + "incrementalGzipBytes": 27293, + "incrementalBrotliBytes": 24225 }, "tanstack-scatter-interactive": { - "minifiedBytes": 76990, - "gzipBytes": 28892, - "brotliBytes": 25445, - "incrementalGzipBytes": 28892, - "incrementalBrotliBytes": 25445 + "minifiedBytes": 77203, + "gzipBytes": 28957, + "brotliBytes": 25489, + "incrementalGzipBytes": 28957, + "incrementalBrotliBytes": 25489 }, "tanstack-scatter-advanced": { - "minifiedBytes": 77006, - "gzipBytes": 28897, - "brotliBytes": 25468, - "incrementalGzipBytes": 28897, - "incrementalBrotliBytes": 25468 + "minifiedBytes": 77219, + "gzipBytes": 28962, + "brotliBytes": 25534, + "incrementalGzipBytes": 28962, + "incrementalBrotliBytes": 25534 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 1f1e5647..bd7db61f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c422a2c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `d563eba` | | [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 `c422a2c`. +at page render time. The measured TanStack workspace revision is `d563eba`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-07-31`. +Baseline date: `2026-08-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 | 26.58–32.08 KiB | — | Controlled suite | +| TanStack Charts | 26.65–32.14 KiB | — | 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/guides/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index a3131f96..07b58783 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -99,10 +99,12 @@ const definition = defineChart({ ``` Array order is row order. A nullish field or `text` result omits the row. -Grouped focus keeps its shared-axis heading and series rows; order those rows -with `sort: 'color-domain'`, `sort: 'focus'`, or a typed comparator. Use -channel items to format their heading, series names, and values. Use `content` -when a grouped tooltip needs additional columns or nested sections. +Grouped focus keeps its shared-axis heading and series rows. By default, rows +follow the marks top-to-bottom for an x-group and left-to-right for a y-group. +Override that with `sort: 'color-domain'`, `sort: 'focus'`, or a typed +comparator. Use channel items to format their heading, series names, and values. +Use `content` when a grouped tooltip needs additional columns or nested +sections. Customize plaintext content with typed formatters: diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 0f105da0..fac5fbcf 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -138,7 +138,7 @@ interface ChartTooltipOptions< | `className` | None | Class appended after `ts-chart-tooltip` | | `portal` | None | Optional top-layer or fixed-position transport | | `items` | Automatic x/y | Ordered rows for a single focused point | -| `sort` | `color-domain` | Grouped row order | +| `sort` | `visual` | Grouped row order | | `anchor` | `point` | Preset, per-axis coordinates, or coordinate resolver | | `placement` | `auto` | Fixed or ordered fallback box placements | | `offset` | `10` | Scene-pixel gap between anchor and box | @@ -191,7 +191,9 @@ label and text, the opposite-axis item formats values, and the group item formats series names. `sort` orders those generated series rows. Additional grouped structure belongs in `content`. -`sort` accepts `color-domain`, `focus`, or a typed point comparator. +`sort` accepts `visual`, `color-domain`, `focus`, or a typed point comparator. +Visual order follows the marks across the screen: top-to-bottom for an x-group +and left-to-right for a y-group. ### Anchor and placement diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 1f1e5647..bd7db61f 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c422a2c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `d563eba` | | [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 `c422a2c`. +at page render time. The measured TanStack workspace revision is `d563eba`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-07-31`. +Baseline date: `2026-08-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 | 26.58–32.08 KiB | — | Controlled suite | +| TanStack Charts | 26.65–32.14 KiB | — | 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/guides/tooltips-and-focus.md b/packages/charts-core/docs/guides/tooltips-and-focus.md index a3131f96..07b58783 100644 --- a/packages/charts-core/docs/guides/tooltips-and-focus.md +++ b/packages/charts-core/docs/guides/tooltips-and-focus.md @@ -99,10 +99,12 @@ const definition = defineChart({ ``` Array order is row order. A nullish field or `text` result omits the row. -Grouped focus keeps its shared-axis heading and series rows; order those rows -with `sort: 'color-domain'`, `sort: 'focus'`, or a typed comparator. Use -channel items to format their heading, series names, and values. Use `content` -when a grouped tooltip needs additional columns or nested sections. +Grouped focus keeps its shared-axis heading and series rows. By default, rows +follow the marks top-to-bottom for an x-group and left-to-right for a y-group. +Override that with `sort: 'color-domain'`, `sort: 'focus'`, or a typed +comparator. Use channel items to format their heading, series names, and values. +Use `content` when a grouped tooltip needs additional columns or nested +sections. Customize plaintext content with typed formatters: diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 0f105da0..fac5fbcf 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -138,7 +138,7 @@ interface ChartTooltipOptions< | `className` | None | Class appended after `ts-chart-tooltip` | | `portal` | None | Optional top-layer or fixed-position transport | | `items` | Automatic x/y | Ordered rows for a single focused point | -| `sort` | `color-domain` | Grouped row order | +| `sort` | `visual` | Grouped row order | | `anchor` | `point` | Preset, per-axis coordinates, or coordinate resolver | | `placement` | `auto` | Fixed or ordered fallback box placements | | `offset` | `10` | Scene-pixel gap between anchor and box | @@ -191,7 +191,9 @@ label and text, the opposite-axis item formats values, and the group item formats series names. `sort` orders those generated series rows. Additional grouped structure belongs in `content`. -`sort` accepts `color-domain`, `focus`, or a typed point comparator. +`sort` accepts `visual`, `color-domain`, `focus`, or a typed point comparator. +Visual order follows the marks across the screen: top-to-bottom for an x-group +and left-to-right for a y-group. ### Anchor and placement diff --git a/packages/charts-core/src/runtime.test.ts b/packages/charts-core/src/runtime.test.ts index 7e8082f2..fb28fb14 100644 --- a/packages/charts-core/src/runtime.test.ts +++ b/packages/charts-core/src/runtime.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { mountChart } from './dom' -import { barY } from './bar' +import { barX, barY } from './bar' import { lineY } from './line' import { rect } from './rect' import { createChartRuntime } from './runtime' import { defineChart } from './scene' import { renderChartSvgWithResources } from './svg-resources' import { focusX } from './focus' -import { bandXAxes, linearAxes, utcXAxes } from './test-scales' +import { bandXAxes, bandYAxes, linearAxes, utcXAxes } from './test-scales' import { tooltip as tooltipExtension } from './tooltip' import { portal as portalExtension } from './tooltip-portal' import type { @@ -518,8 +518,8 @@ describe('dynamic chart runtime', () => { it('renders grouped tooltip rows with scale labels, formatting, and swatches', () => { const data = [ - { id: 'a:query', period: 'A', series: 'Query', value: 12 }, - { id: 'a:router', period: 'A', series: 'Router', value: 8 }, + { id: 'a:query', period: 'A', series: 'Query', value: 8 }, + { id: 'a:router', period: 'A', series: 'Router', value: 12 }, ] const container = document.createElement('div') const host = mountChart(container, { @@ -546,7 +546,6 @@ describe('dynamic chart runtime', () => { anchor: 'group-center', placement: 'right', offset: 0, - sort: (left, right) => left.yValue - right.yValue, }, }), width: 480, @@ -563,14 +562,14 @@ describe('dynamic chart runtime', () => { expect(tooltip?.querySelector('div')?.textContent).toBe('Period: A') expect(rows).toHaveLength(2) expect(rows?.[0]?.textContent).toContain('Router') - expect(rows?.[0]?.textContent).toContain('8') + expect(rows?.[0]?.textContent).toContain('12') expect(rows?.[1]?.textContent).toContain('Query') - expect(rows?.[1]?.textContent).toContain('12') + expect(rows?.[1]?.textContent).toContain('8') expect(tooltip?.querySelectorAll('.ts-chart-tooltip__swatch')).toHaveLength( 2, ) expect(tooltip?.getAttribute('aria-label')).toBe( - 'Period: A\nRouter: 8\nQuery: 12', + 'Period: A\nRouter: 12\nQuery: 8', ) const focusedPoints = host.getScene().points expect(Number.parseFloat(tooltip?.style.left ?? '')).toBeCloseTo( @@ -582,6 +581,45 @@ describe('dynamic chart runtime', () => { host.destroy() }) + it('orders y-grouped tooltip rows left-to-right by default', () => { + const data = [ + { id: 'a:long', category: 'A', series: 'Long', value: 12 }, + { id: 'a:short', category: 'A', series: 'Short', value: 4 }, + ] + const container = document.createElement('div') + const host = mountChart(container, { + definition: defineChart({ + marks: [ + barX(data, { + x: 'value', + y: 'category', + z: 'series', + key: 'id', + layout: { type: 'group' }, + }), + ], + ...bandYAxes([0, 12], ['A']), + focus: 'group-y', + tooltip: { use: tooltipExtension }, + }), + width: 480, + height: 260, + ariaLabel: 'Grouped horizontal bars', + }) + const svg = container.querySelector('svg') + if (!svg) throw new Error('Expected SVG') + + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + + const rows = container.querySelectorAll('.ts-chart-tooltip__row') + expect(rows).toHaveLength(2) + expect(rows[0]?.textContent).toContain('Short') + expect(rows[0]?.textContent).toContain('4') + expect(rows[1]?.textContent).toContain('Long') + expect(rows[1]?.textContent).toContain('12') + host.destroy() + }) + it('formats interval ranges and stacked lengths automatically', () => { const rangeContainer = document.createElement('div') const rangeHost = mountChart(rangeContainer, { diff --git a/packages/charts-core/src/tooltip.ts b/packages/charts-core/src/tooltip.ts index f66edb0c..3d89e712 100644 --- a/packages/charts-core/src/tooltip.ts +++ b/packages/charts-core/src/tooltip.ts @@ -478,6 +478,20 @@ function orderTooltipPoints< ) { if (sort === 'focus') return [...points] if (typeof sort === 'function') return [...points].sort(sort) + if (sort !== 'color-domain') { + const first = points[0] + const sharedX = + first !== undefined && + points.every((point) => chartValueEqual(point.xValue, first.xValue)) + const sharedY = + first !== undefined && + points.every((point) => chartValueEqual(point.yValue, first.yValue)) + return [...points].sort((left, right) => + sharedY && !sharedX + ? left.x - right.x || left.y - right.y + : left.y - right.y || left.x - right.x, + ) + } return [...points].sort( (left, right) => colorOrder(scene, left.group) - colorOrder(scene, right.group), diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 4a74f9da..b035d581 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -1017,6 +1017,7 @@ export type ChartTooltipSort< TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, > = + | 'visual' | 'color-domain' | 'focus' | ((