diff --git a/.changeset/quick-viewport-bounds.md b/.changeset/quick-viewport-bounds.md new file mode 100644 index 00000000..fbaafbb2 --- /dev/null +++ b/.changeset/quick-viewport-bounds.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Speed up viewport interaction filtering by skipping ownership lookups for visible points and checking axis channels directly for offscreen points. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index d7b15a06..c3a99cd8 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 30414 }, "TanStack DOM host": { - "bytes": 82961, - "gzip": 29116 + "bytes": 82994, + "gzip": 29119 }, "React adapter": { - "bytes": 85286, - "gzip": 30070 + "bytes": 85319, + "gzip": 30074 }, "React line consumer": { - "bytes": 108945, - "gzip": 39420 + "bytes": 108978, + "gzip": 39424 }, "Compact-scale line scene": { "bytes": 35859, "gzip": 12797 }, "React compact-scale line consumer": { - "bytes": 91443, - "gzip": 32305 + "bytes": 91476, + "gzip": 32307 }, "Custom-scale line scene": { "bytes": 34043, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 258ab06f..7b499ddb 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-14T02:03:21.704Z", + "generatedAt": "2026-10-14T04:50:28.604Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "cd2a8a7fcab201ca32db60ca6d6c8e92c4dd47b8", - "inputDigest": "sha256:6c5dc96470c53874d724ca231e9e5a552800d04834e584260cc2eac4b504974e" + "revision": "28a0626e3859d2e0ebd33772bf81ebc2c402b09a", + "inputDigest": "sha256:b0e66aa78ec68bebdd7a9305693d559fd68f0946b82cb1909a851c3704895eb6" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 119233, - "gzipBytes": 43211, - "brotliBytes": 38200, - "incrementalGzipBytes": 43211, - "incrementalBrotliBytes": 38200 + "minifiedBytes": 119266, + "gzipBytes": 43213, + "brotliBytes": 38182, + "incrementalGzipBytes": 43213, + "incrementalBrotliBytes": 38182 }, "tanstack-line-interactive": { - "minifiedBytes": 124980, + "minifiedBytes": 125013, "gzipBytes": 45122, - "brotliBytes": 39727, + "brotliBytes": 39724, "incrementalGzipBytes": 45122, - "incrementalBrotliBytes": 39727 + "incrementalBrotliBytes": 39724 }, "tanstack-line-advanced": { - "minifiedBytes": 132176, - "gzipBytes": 47454, - "brotliBytes": 41771, - "incrementalGzipBytes": 47454, - "incrementalBrotliBytes": 41771 + "minifiedBytes": 132209, + "gzipBytes": 47460, + "brotliBytes": 41713, + "incrementalGzipBytes": 47460, + "incrementalBrotliBytes": 41713 }, "tanstack-bar-basic": { - "minifiedBytes": 131670, - "gzipBytes": 47913, - "brotliBytes": 42147, - "incrementalGzipBytes": 47913, - "incrementalBrotliBytes": 42147 + "minifiedBytes": 131703, + "gzipBytes": 47916, + "brotliBytes": 42081, + "incrementalGzipBytes": 47916, + "incrementalBrotliBytes": 42081 }, "tanstack-bar-interactive": { - "minifiedBytes": 136276, - "gzipBytes": 49307, - "brotliBytes": 43250, - "incrementalGzipBytes": 49307, - "incrementalBrotliBytes": 43250 + "minifiedBytes": 136309, + "gzipBytes": 49311, + "brotliBytes": 43248, + "incrementalGzipBytes": 49311, + "incrementalBrotliBytes": 43248 }, "tanstack-bar-advanced": { - "minifiedBytes": 136615, - "gzipBytes": 49453, - "brotliBytes": 43349, - "incrementalGzipBytes": 49453, - "incrementalBrotliBytes": 43349 + "minifiedBytes": 136648, + "gzipBytes": 49455, + "brotliBytes": 43379, + "incrementalGzipBytes": 49455, + "incrementalBrotliBytes": 43379 }, "tanstack-area-basic": { - "minifiedBytes": 124413, - "gzipBytes": 45239, - "brotliBytes": 39996, - "incrementalGzipBytes": 45239, - "incrementalBrotliBytes": 39996 + "minifiedBytes": 124446, + "gzipBytes": 45243, + "brotliBytes": 39958, + "incrementalGzipBytes": 45243, + "incrementalBrotliBytes": 39958 }, "tanstack-area-interactive": { - "minifiedBytes": 130168, - "gzipBytes": 47088, - "brotliBytes": 41480, - "incrementalGzipBytes": 47088, - "incrementalBrotliBytes": 41480 + "minifiedBytes": 130201, + "gzipBytes": 47092, + "brotliBytes": 41523, + "incrementalGzipBytes": 47092, + "incrementalBrotliBytes": 41523 }, "tanstack-area-advanced": { - "minifiedBytes": 137540, - "gzipBytes": 49496, - "brotliBytes": 43606, - "incrementalGzipBytes": 49496, - "incrementalBrotliBytes": 43606 + "minifiedBytes": 137573, + "gzipBytes": 49501, + "brotliBytes": 43577, + "incrementalGzipBytes": 49501, + "incrementalBrotliBytes": 43577 }, "tanstack-scatter-basic": { - "minifiedBytes": 120286, - "gzipBytes": 43572, - "brotliBytes": 38499, - "incrementalGzipBytes": 43572, - "incrementalBrotliBytes": 38499 + "minifiedBytes": 120319, + "gzipBytes": 43574, + "brotliBytes": 38528, + "incrementalGzipBytes": 43574, + "incrementalBrotliBytes": 38528 }, "tanstack-scatter-interactive": { - "minifiedBytes": 126037, - "gzipBytes": 45469, - "brotliBytes": 40055, - "incrementalGzipBytes": 45469, - "incrementalBrotliBytes": 40055 + "minifiedBytes": 126070, + "gzipBytes": 45472, + "brotliBytes": 40050, + "incrementalGzipBytes": 45472, + "incrementalBrotliBytes": 40050 }, "tanstack-scatter-advanced": { - "minifiedBytes": 126053, - "gzipBytes": 45476, - "brotliBytes": 40107, - "incrementalGzipBytes": 45476, - "incrementalBrotliBytes": 40107 + "minifiedBytes": 126086, + "gzipBytes": 45479, + "brotliBytes": 40072, + "incrementalGzipBytes": 45479, + "incrementalBrotliBytes": 40072 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 3cd78530..d38b3b7c 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "9bbb0210cda37cf492ff11d793f0e23d493f9aebc849609518fa4abc1ce7a26a", + "sourceHash": "b503388bda59296b32222df9ec499915bd86d0c8d93852609d596973cfd21f71", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index deaec90b..369542af 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 `cd2a8a7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `28a0626` | | [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 `cd2a8a7`. +at page render time. The measured TanStack workspace revision is `28a0626`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index deaec90b..369542af 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 `cd2a8a7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `28a0626` | | [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 `cd2a8a7`. +at page render time. The measured TanStack workspace revision is `28a0626`. ## Capability matrix diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 9dae9242..618fc488 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -1090,6 +1090,110 @@ describe('native mark and channel scene', () => { expect(viewport.translateX).toBe(20) }) + it('skips mark ownership lookup for points inside the viewport bounds', () => { + const resolved = createChartScene( + defineChart({ + marks: [lineY([{ x: 5, y: 5 }], { x: 'x', y: 'y' })], + scales: { + x: { + scale: linearAxes([0, 10], [0, 10]).scales.x.scale, + viewport: { domain: [4, 6] }, + }, + y: linearAxes([0, 10], [0, 10]).scales.y, + }, + guides: false, + }), + { width: 400, height: 200 }, + ) + const point = resolved.points[0] + if (!point) throw new Error('Expected viewport point') + const readMarkId = vi.fn(() => point.markId) + const { x, y, width, height } = resolved.chart + const points = [ + { x, y }, + { x: x + width, y }, + { x, y: y + height }, + { x: x + width, y: y + height }, + { x: x + width / 2, y: y + height / 2 }, + ].map((position) => ({ + ...point, + ...position, + get markId() { + return readMarkId() + }, + })) + + expect(viewportInteractionPoints(resolved, points)).toBe(points) + expect(readMarkId).not.toHaveBeenCalled() + expect( + viewportInteractionPoints(resolved, [ + { ...point, markId: 'unknown', x: x - 1 }, + { ...point, markId: 'unknown', x: Number.NaN }, + { ...point, markId: 'unknown', y: Infinity }, + ]), + ).toEqual([]) + }) + + it('does not enumerate unrelated channels while checking viewport ownership', () => { + const readUnrelated = vi.fn(() => ({ values: [1] })) + const history = lineY([{ x: 0, y: 5 }], { x: 'x', y: 'y' }) + const mark = createMark((context) => { + const initialized = history.initialize(context) + return { + ...initialized, + channels: { + ...initialized.channels, + get unrelated() { + return readUnrelated() + }, + }, + } + }) + const resolved = createChartScene( + defineChart({ + marks: [mark], + scales: { + x: { + scale: linearAxes([0, 10], [0, 10]).scales.x.scale, + viewport: { domain: [4, 6] }, + }, + y: linearAxes([0, 10], [0, 10]).scales.y, + }, + guides: false, + }), + { width: 400, height: 200 }, + ) + readUnrelated.mockClear() + + expect(viewportInteractionPoints(resolved)).toEqual([]) + expect(readUnrelated).not.toHaveBeenCalled() + }) + + it('ignores non-enumerable axis channels when checking viewport ownership', () => { + const history = lineY([{ x: 0, y: 5 }], { x: 'x', y: 'y' }) + const mark = createMark((context) => { + const initialized = history.initialize(context) + Object.defineProperty(initialized.channels, 'x', { enumerable: false }) + return initialized + }) + const resolved = createChartScene( + defineChart({ + marks: [mark], + scales: { + x: { + scale: linearAxes([0, 10], [0, 10]).scales.x.scale, + viewport: { domain: [4, 6] }, + }, + y: linearAxes([0, 10], [0, 10]).scales.y, + }, + guides: false, + }), + { width: 400, height: 200 }, + ) + expect(resolved.points).toHaveLength(1) + expect(viewportInteractionPoints(resolved)).toBe(resolved.points) + }) + it('keeps fixed mark points interactive outside another mark viewport', () => { const fixed = createMark(() => ({ id: 'fixed-annotation', diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 40d80b7e..172d43db 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -746,8 +746,8 @@ export function viewportInteractionPoints< const bottom = y + height const visible = points.filter( (point) => - !pointUsesViewportClip(scene, point) || - (point.x >= x && point.x <= right && point.y >= y && point.y <= bottom), + (point.x >= x && point.x <= right && point.y >= y && point.y <= bottom) || + !pointUsesViewportClip(scene, point), ) return visible.length === points.length ? points : visible } @@ -767,11 +767,12 @@ function pointUsesViewportClip(scene: ChartScene, point: ChartPoint) { const ownership = mark.viewport?.[axis] if (ownership === 'fixed') return false if (ownership === 'content' && scene.scales[axis]?.viewport) return true - return Object.entries(mark.channels).some( - ([channelName, channel]) => - channelName === axis && - channel.scale !== undefined && - scene.scales[channel.scale]?.viewport !== undefined, + const channel = Object.keys(mark.channels).includes(axis) + ? mark.channels[axis] + : undefined + return ( + channel?.scale !== undefined && + scene.scales[channel.scale]?.viewport !== undefined ) }) } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 93578ebc..3026af4a 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1167,7 +1167,7 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 22.4925546875, + 22.4931640625, ), budgeted( 'D3-scale vectors + static SVG', @@ -1206,7 +1206,7 @@ const entries = [ budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 23.74225, + 23.7431640625, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( @@ -1628,7 +1628,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.547984375, + 45.548828125, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1727,7 +1727,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.547984375, + 31.5498046875, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1946,12 +1946,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.847984375, + 39.853515625, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 45.1320546875, + 45.1396484375, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2155,7 +2155,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 58.46075, + 58.4619140625, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot minimal line', 'benchmarks/entries/minimal-line.ts'),