Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-viewport-bounds.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 8 additions & 8 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
122 changes: 61 additions & 61 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "cd2a8a7fcab201ca32db60ca6d6c8e92c4dd47b8",
"inputDigest": "sha256:6c5dc96470c53874d724ca231e9e5a552800d04834e584260cc2eac4b504974e"
"revision": "28a0626e3859d2e0ebd33772bf81ebc2c402b09a",
"inputDigest": "sha256:b0e66aa78ec68bebdd7a9305693d559fd68f0946b82cb1909a851c3704895eb6"
},
"chartjs": {
"kind": "package",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/conformance/previews/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
"sourceHash": "9bbb0210cda37cf492ff11d793f0e23d493f9aebc849609518fa4abc1ce7a26a",
"sourceHash": "b503388bda59296b32222df9ec499915bd86d0c8d93852609d596973cfd21f71",
"assets": [
{
"id": "01-line-gaps",
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/charts-core/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions packages/charts-core/src/scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, number, number>(() => ({
id: 'fixed-annotation',
Expand Down
15 changes: 8 additions & 7 deletions packages/charts-core/src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
)
})
}
Expand Down
Loading