From 248333cab0977b19e8b6b9969ab3b21605e98e41 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 17:47:33 +0200 Subject: [PATCH 01/10] Record Vite bundle-size baseline Record starting TanStack-only measurements for a generic Vite application with line and pie charts. Baseline: 185,419 minified bytes; 60,868 gzip bytes; 52,085 Brotli bytes. Savings: 0 bytes (measurements only). --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 550ea8f4..c7c380fb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ coverage *.log tanstack.com-parity/ tanstack.com-charts-site/ + +output/playwright/ From 928dad16de9c8d46d1cd7d857f511d6383b586e8 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 17:50:01 +0200 Subject: [PATCH 02/10] Share Cartesian axis rendering Consolidate horizontal and vertical tick, axis-line, and title layout while keeping orientation-specific geometry, label anchors, and margin measurement. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 185,419 -> 184,459 B; saves 960 B (0.52%). gzip: 60,868 -> 60,788 B; saves 80 B (0.13%). Brotli: 52,085 -> 52,071 B; saves 14 B (0.03%). Total savings from baseline: 960 B (0.52%) minified, 80 B (0.13%) gzip, and 14 B (0.03%) Brotli. Validation: core tests pass; production Edge scenarios produce identical SVGs to the baseline, including updates, keyboard tooltips, resize, and empty data. --- packages/charts-core/src/scene.ts | 208 +++++++++--------------------- 1 file changed, 63 insertions(+), 145 deletions(-) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index e1165e13..41324c26 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1453,11 +1453,7 @@ function createAxes( } } - if (guide.channel === 'x') { - renderXAxis(guide, axisPosition, includeOutward, includeCoordinate) - } else { - renderYAxis(guide, axisPosition, includeOutward, includeCoordinate) - } + renderAxis(guide, axisPosition, includeOutward, includeCoordinate) const distance = guide.side === 'top' @@ -1481,29 +1477,40 @@ function createAxes( includeGuideStrokeMargins(margin, axes, chart) return { axes, margin } - function renderXAxis( + function renderAxis( guide: ResolvedPositionScale, - axisY: number, + axisPosition: number, includeOutward: (bounds: ChartBounds) => void, includeCoordinate: (coordinate: number) => void, ) { const presentation = axisPresentation(guide.options) - const bottom = guide.side === 'bottom' - const direction = bottom ? 1 : -1 + const horizontal = guide.channel === 'x' + const positive = guide.side === 'bottom' || guide.side === 'right' + const direction = positive ? 1 : -1 + const outerCoordinate = (bounds: ChartBounds) => { + const start = horizontal ? bounds.y : bounds.x + const size = horizontal ? bounds.height : bounds.width + + return positive ? start + size : start + } + const farther = (left: number, right: number) => + positive ? Math.max(left, right) : Math.min(left, right) + if (presentation?.line !== false) { children.push({ kind: 'rule', key: `${guide.id}-axis`, - x1: chart.x, - x2: chartRight, - y1: axisY, - y2: axisY, + x1: horizontal ? chart.x : axisPosition, + x2: horizontal ? chartRight : axisPosition, + y1: horizontal ? axisPosition : chart.y, + y2: horizontal ? axisPosition : chartBottom, style: axisStyle(presentation?.line), }) includeCoordinate( - axisY + direction * guideLineHalfWidth(presentation?.line), + axisPosition + direction * guideLineHalfWidth(presentation?.line), ) } + const ticks = presentation?.ticks === false ? [] : guide.scale.ticks const tickSize = finiteMargin( presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), @@ -1518,7 +1525,7 @@ function createAxes( : createTickLabelCandidates( guide, withKeptTicks(guide.scale, guide.options, tickLabels), - axisY, + axisPosition, tickSize, tickPadding, tickLabels, @@ -1530,31 +1537,32 @@ function createAxes( const visibleLabels = tickLabels === false ? [] - : thinTickLabels(candidates, tickLabels, guide.scale.type === 'band') - let tickOuter = axisY + : thinTickLabels( + candidates, + tickLabels, + horizontal && guide.scale.type === 'band', + ) + let tickOuter = axisPosition for (const tick of ticks) { if (tickSize <= 0) continue - const tickEnd = axisY + direction * tickSize + const tickEnd = axisPosition + direction * tickSize includeCoordinate(tickEnd) - tickOuter = bottom - ? Math.max(tickOuter, tickEnd) - : Math.min(tickOuter, tickEnd) + tickOuter = farther(tickOuter, tickEnd) children.push({ kind: 'rule', key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, - x1: tick.position, - x2: tick.position, - y1: axisY, - y2: tickEnd, + x1: horizontal ? tick.position : axisPosition, + x2: horizontal ? tick.position : tickEnd, + y1: horizontal ? axisPosition : tick.position, + y2: horizontal ? tickEnd : tick.position, style: axisStyle(), }) } + for (const candidate of visibleLabels) { includeOutward(candidate.bounds) - tickOuter = bottom - ? Math.max(tickOuter, candidate.bounds.y + candidate.bounds.height) - : Math.min(tickOuter, candidate.bounds.y) + tickOuter = farther(tickOuter, outerCoordinate(candidate.bounds)) children.push(candidate.label) } @@ -1562,20 +1570,27 @@ function createAxes( const labelText = typeof axisLabel === 'string' ? axisLabel : axisLabel?.text if (!labelText) return + const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined const labelOffset = labelOptions?.offset ?? 'auto' const explicitOffset = labelOffset !== 'auto' const label: SceneLabel = { kind: 'label', key: `${guide.id}-label`, - x: chart.x + chart.width / 2, - y: explicitOffset - ? axisY + direction * Math.max(0, finiteMargin(labelOffset)) - : tickOuter + direction * 8, + x: horizontal ? chart.x + chart.width / 2 : axisPosition, + y: horizontal + ? explicitOffset + ? axisPosition + direction * Math.max(0, finiteMargin(labelOffset)) + : tickOuter + direction * 8 + : chart.y + chart.height / 2, text: labelText, anchor: 'middle', - baseline: bottom && !explicitOffset ? 'hanging' : 'auto', - fontSize: labelOptions?.fontSize ?? (width < 360 ? 10 : 11), + baseline: horizontal + ? positive && !explicitOffset + ? 'hanging' + : 'auto' + : 'middle', + fontSize: labelOptions?.fontSize ?? (horizontal && width < 360 ? 10 : 11), fontWeight: labelOptions?.fontWeight ?? 600, style: { fill: labelOptions?.fill ?? theme.foreground, @@ -1584,120 +1599,23 @@ function createAxes( : { opacity: labelOptions.opacity }), }, } - includeOutward(measureSceneLabelBounds(label, measureText)) - children.push(label) - } - function renderYAxis( - guide: ResolvedPositionScale, - axisX: number, - includeOutward: (bounds: ChartBounds) => void, - includeCoordinate: (coordinate: number) => void, - ) { - const presentation = axisPresentation(guide.options) - const right = guide.side === 'right' - const direction = right ? 1 : -1 - if (presentation?.line !== false) { - children.push({ - kind: 'rule', - key: `${guide.id}-axis`, - x1: axisX, - x2: axisX, - y1: chart.y, - y2: chartBottom, - style: axisStyle(presentation?.line), - }) - includeCoordinate( - axisX + direction * guideLineHalfWidth(presentation?.line), - ) - } - const ticks = presentation?.ticks === false ? [] : guide.scale.ticks - const tickSize = finiteMargin( - presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), - ) - const tickPadding = finiteMargin( - presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), - ) - const tickLabels = tickLabelPresentation(presentation) - const candidates = - tickLabels === false - ? [] - : createTickLabelCandidates( - guide, - withKeptTicks(guide.scale, guide.options, tickLabels), - axisX, - tickSize, - tickPadding, - tickLabels, - width, - theme, - measureText, - rightToLeft, - ) - const visibleLabels = - tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false) - let tickOuter = axisX - - for (const tick of ticks) { - if (tickSize <= 0) continue - const tickEnd = axisX + direction * tickSize - includeCoordinate(tickEnd) - tickOuter = right - ? Math.max(tickOuter, tickEnd) - : Math.min(tickOuter, tickEnd) - children.push({ - kind: 'rule', - key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, - x1: axisX, - x2: tickEnd, - y1: tick.position, - y2: tick.position, - style: axisStyle(), - }) - } - for (const candidate of visibleLabels) { - includeOutward(candidate.bounds) - tickOuter = right - ? Math.max(tickOuter, candidate.bounds.x + candidate.bounds.width) - : Math.min(tickOuter, candidate.bounds.x) - children.push(candidate.label) + if (!horizontal) { + label.rotate = positive ? 90 : -90 + if (explicitOffset) { + label.x = + axisPosition + direction * Math.max(0, finiteMargin(labelOffset)) + } else { + const localBounds = measureSceneLabelBounds( + { ...label, x: 0, y: 0 }, + measureText, + ) + label.x = positive + ? tickOuter + 8 - localBounds.x + : tickOuter - 8 - (localBounds.x + localBounds.width) + } } - const axisLabel = presentation?.label - const labelText = - typeof axisLabel === 'string' ? axisLabel : axisLabel?.text - if (!labelText) return - const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined - const label: SceneLabel = { - kind: 'label', - key: `${guide.id}-label`, - x: axisX, - y: chart.y + chart.height / 2, - text: labelText, - anchor: 'middle', - baseline: 'middle', - rotate: right ? 90 : -90, - fontSize: labelOptions?.fontSize ?? 11, - fontWeight: labelOptions?.fontWeight ?? 600, - style: { - fill: labelOptions?.fill ?? theme.foreground, - ...(labelOptions?.opacity === undefined - ? { fillOpacity: 0.76 } - : { opacity: labelOptions.opacity }), - }, - } - const labelOffset = labelOptions?.offset ?? 'auto' - if (labelOffset !== 'auto') { - label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)) - } else { - const localBounds = measureSceneLabelBounds( - { ...label, x: 0, y: 0 }, - measureText, - ) - label.x = right - ? tickOuter + 8 - localBounds.x - : tickOuter - 8 - (localBounds.x + localBounds.width) - } includeOutward(measureSceneLabelBounds(label, measureText)) children.push(label) } From 283572187319b4aef667014b3a357928a50eb055 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 17:52:58 +0200 Subject: [PATCH 03/10] Share motion entrance tracks and attribute cleanup Use the same bar entrance track for initial rendering and inserted data, and share transform entrance tracks between Cartesian and radial paths. Centralize motion-role cleanup and keyed-element attribute reads. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 184,459 -> 182,279 B; saves 2,180 B (1.18%). gzip: 60,788 -> 60,565 B; saves 223 B (0.37%). Brotli: 52,071 -> 51,974 B; saves 97 B (0.19%). Total savings from baseline: 3,140 B (1.69%) minified, 303 B (0.50%) gzip, and 111 B (0.21%) Brotli. Validation: TypeScript and motion tests pass. Production Edge scenarios render identical SVGs to the baseline, with animation, updates, keyboard tooltips, resize, empty data, and reduced motion exercised. --- packages/charts-core/src/motion.ts | 360 +++++++++++++---------------- 1 file changed, 167 insertions(+), 193 deletions(-) diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 7b58aa04..8587e89f 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1229,12 +1229,10 @@ function createBarTracks( groups.forEach((group, seriesIndex) => { const horizontal = group.classList.contains('ts-chart__bar-x') const shapes = barShapeElements(group) - const seriesKey = - group.getAttribute('data-ts-key') ?? `series:${seriesIndex}` + const seriesKey = elementKey(group) ?? `series:${seriesIndex}` shapes.forEach((shape, datumIndex) => { - const key = - shape.getAttribute('data-ts-key') ?? `${seriesKey}:${datumIndex}` + const key = elementKey(shape) ?? `${seriesKey}:${datumIndex}` const point = points.get(key) const geometry = barShapeGeometry(shape, scene) if (!geometry) return @@ -1260,55 +1258,64 @@ function createBarTracks( point, }) - shape.dataset.tsMotionRole = 'bar' - if (shape.localName === 'path' && geometry.cornerRadii) { - const to = barPathGeometryValues(geometry) - const from = barPathEntranceValues(to, horizontal, baseline) - const states = elementValueStates(runtime, shape, 'bar-geometry', from) - const apply = (values: readonly number[]) => { - applyBarPathGeometry(shape, values) - } - const finish = () => { - finishBarShapeGeometry(shape, geometry) - delete shape.dataset.tsMotionRole - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish, - cancel: () => delete shape.dataset.tsMotionRole, - }) - return - } - const names = horizontal ? ['x', 'width'] : ['y', 'height'] - const from = [baseline, 0] - const to = horizontal ? [targetX, targetWidth] : [targetY, targetHeight] - const states = names.flatMap((name, index) => - elementValueStates(runtime, shape, name, [from[index] ?? 0]), + tracks.push( + createBarEntranceTrack( + shape, + geometry, + horizontal, + baseline, + timing, + runtime, + ), ) - const apply = (values: readonly number[]) => { - applyBarShapeGeometry(shape, geometry, horizontal, values) - } - const finish = () => { - finishBarShapeGeometry(shape, geometry) - delete shape.dataset.tsMotionRole - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish, - cancel: () => delete shape.dataset.tsMotionRole, - }) }) }) return tracks } +function createBarEntranceTrack( + element: BarShapeElement, + geometry: BarShapeGeometry, + horizontal: boolean, + baseline: number, + timing: ResolvedTiming, + runtime: MotionRuntime, +): MotionTrack { + setMotionRole(element, 'bar') + const pathGeometry = + element.localName === 'path' && geometry.cornerRadii + ? barPathGeometryValues(geometry) + : undefined + const to = + pathGeometry ?? + (horizontal ? [geometry.x, geometry.width] : [geometry.y, geometry.height]) + const from = pathGeometry + ? barPathEntranceValues(pathGeometry, horizontal, baseline) + : [baseline, 0] + const states = pathGeometry + ? elementValueStates(runtime, element, 'bar-geometry', from) + : (horizontal ? ['x', 'width'] : ['y', 'height']).flatMap((name, index) => + elementValueStates(runtime, element, name, [from[index] ?? 0]), + ) + const apply = pathGeometry + ? (values: readonly number[]) => applyBarPathGeometry(element, values) + : (values: readonly number[]) => + applyBarShapeGeometry(element, geometry, horizontal, values) + apply(from) + + return { + ...timing, + values: bindMotionValues(states, from, to), + apply, + finish() { + finishBarShapeGeometry(element, geometry) + clearMotionRole(element) + }, + cancel: () => clearMotionRole(element), + } +} + type BarShapeElement = SVGRectElement | SVGPathElement interface BarShapeGeometry { @@ -1408,7 +1415,7 @@ function barShapeGeometry( height: numberAttribute(element, 'height'), } } - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) const node = key ? sceneNodeContext(scene, key)?.node : undefined if (node?.kind === 'rect' && node.cornerRadii) { return { @@ -1490,8 +1497,7 @@ function createCartesianPathTracks( const role: ChartMotionRole = group.classList.contains('ts-chart__area') ? 'area' : 'line' - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` const timing = timingFor({ phase: 'enter', role, @@ -1506,31 +1512,11 @@ function createCartesianPathTracks( }) const horizontal = scenePathAffinity(scene, seriesKey) === 'y' const baseline = resolvePathBaseline(scene, horizontal) - const previousTransform = group.getAttribute('transform') - group.dataset.tsMotionRole = role - const apply = (values: readonly number[]) => { - const progress = values[0] ?? 0 - const transform = horizontal + return createTransformEntranceTrack(group, role, timing, (progress) => + horizontal ? `matrix(${formatNumber(progress)} 0 0 1 ${formatNumber(baseline * (1 - progress))} 0)` - : `matrix(1 0 0 ${formatNumber(progress)} 0 ${formatNumber(baseline * (1 - progress))})` - group.setAttribute( - 'transform', - previousTransform ? `${previousTransform} ${transform}` : transform, - ) - } - const cleanup = () => { - if (previousTransform === null) group.removeAttribute('transform') - else group.setAttribute('transform', previousTransform) - delete group.dataset.tsMotionRole - } - apply([0]) - return { - ...timing, - values: bindMotionValues(undefined, [0], [1]), - apply, - finish: cleanup, - cancel: cleanup, - } + : `matrix(1 0 0 ${formatNumber(progress)} 0 ${formatNumber(baseline * (1 - progress))})`, + ) }) } @@ -1552,8 +1538,7 @@ function createRadialPathTracks( : group.classList.contains('ts-chart__radial-dot') ? 'dot' : 'line' - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` const timing = timingFor({ phase: 'enter', role, @@ -1566,32 +1551,45 @@ function createRadialPathTracks( datum: undefined, point: undefined, }) - const previousTransform = group.getAttribute('transform') - group.dataset.tsMotionRole = role - const apply = (values: readonly number[]) => { - const progress = values[0] ?? 0 - const transform = `scale(${formatNumber(progress)})` - group.setAttribute( - 'transform', - previousTransform ? `${previousTransform} ${transform}` : transform, - ) - } - const cleanup = () => { - if (previousTransform === null) group.removeAttribute('transform') - else group.setAttribute('transform', previousTransform) - delete group.dataset.tsMotionRole - } - apply([0]) - return { - ...timing, - values: bindMotionValues(undefined, [0], [1]), - apply, - finish: cleanup, - cancel: cleanup, - } + return createTransformEntranceTrack( + group, + role, + timing, + (progress) => `scale(${formatNumber(progress)})`, + ) }) } +function createTransformEntranceTrack( + group: SVGGElement, + role: ChartMotionRole, + timing: ResolvedTiming, + transformAt: (progress: number) => string, +): MotionTrack { + const previousTransform = group.getAttribute('transform') + setMotionRole(group, role) + const apply = (values: readonly number[]) => { + const transform = transformAt(values[0] ?? 0) + group.setAttribute( + 'transform', + previousTransform ? `${previousTransform} ${transform}` : transform, + ) + } + const cleanup = () => { + restoreAttribute(group, 'transform', previousTransform) + clearMotionRole(group) + } + apply([0]) + + return { + ...timing, + values: bindMotionValues(undefined, [0], [1]), + apply, + finish: cleanup, + cancel: cleanup, + } +} + function createArcTracks( root: SVGSVGElement, scene: ChartScene, @@ -1599,7 +1597,7 @@ function createArcTracks( ): MotionTrack[] { const groups = [...root.querySelectorAll('g.ts-chart__arc')] return groups.flatMap((group, seriesIndex) => { - const seriesKey = group.getAttribute('data-ts-key') ?? `arc:${seriesIndex}` + const seriesKey = elementKey(group) ?? `arc:${seriesIndex}` const geometry = sceneArcGeometry(scene, seriesKey) if (!geometry) return [] const role: ChartMotionRole = group.classList.contains('ts-chart__bar') @@ -1638,7 +1636,7 @@ function createArcTracks( definitions.append(clip) const previousClip = group.getAttribute('clip-path') group.setAttribute('clip-path', `url(#${id})`) - group.dataset.tsMotionRole = role + setMotionRole(group, role) const apply = (values: readonly number[]) => { const progress = Math.max(0, Math.min(1, values[0] ?? 0)) path.setAttribute( @@ -1651,9 +1649,8 @@ function createArcTracks( ) } const cleanup = () => { - if (previousClip === null) group.removeAttribute('clip-path') - else group.setAttribute('clip-path', previousClip) - delete group.dataset.tsMotionRole + restoreAttribute(group, 'clip-path', previousClip) + clearMotionRole(group) clip.remove() if ( definitions?.dataset.tsMotionDefs !== undefined && @@ -2140,7 +2137,7 @@ function addUpdateTrack( ) { let timingContext = elementTimingContext(current, 'update', context.scene) let timing: ResolvedTiming | undefined - const pathKey = current.getAttribute('data-ts-key') + const pathKey = elementKey(current) const rolling = pathKey ? context.pathPlans.elements.get(pathKey) : undefined const rollingTransform = rolling?.outcome.kind === 'transform' @@ -2206,7 +2203,7 @@ function addUpdateTrack( } if (rollingTransform && timingContext && rolling) { - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) const apply = (values: readonly number[]) => { current.setAttribute( 'transform', @@ -2225,10 +2222,10 @@ function addUpdateTrack( apply, finish() { current.removeAttribute('transform') - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) } @@ -2244,7 +2241,7 @@ function addUpdateTrack( pointRolling?.outcome.kind === 'transform' ? pointRolling.timing : context.timingFor(timingContext) - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) const states = attributes.flatMap((attribute) => elementValueStates( context.runtime, @@ -2274,10 +2271,10 @@ function addUpdateTrack( }, finish() { finishMotionAttributes(current, attributes) - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) } @@ -2297,9 +2294,9 @@ function addBarPathUpdateTrack( ) { return false } - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) const targetPath = next.getAttribute('d') - if (!key || next.getAttribute('data-ts-key') !== key || !targetPath) { + if (!key || elementKey(next) !== key || !targetPath) { return false } const previous = sceneNodeContext(context.previousScene, key)?.node @@ -2327,7 +2324,7 @@ function addBarPathUpdateTrack( state.velocity === 0, ) if (current.getAttribute('d') === targetPath && settledAtTarget) return false - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) tracks.push({ ...context.timingFor(timingContext), values: bindMotionValues(states, sourceValues, targetValues), @@ -2336,10 +2333,10 @@ function addBarPathUpdateTrack( }, finish() { current.setAttribute('d', targetPath) - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) return true @@ -2353,7 +2350,7 @@ function addSemanticPathUpdateTrack( timingContext: ChartMotionContext | undefined, ) { if (current.localName !== 'path') return false - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) const targetPath = next.getAttribute('d') if (!key || !targetPath || !context.previousScene) return false const previous = sceneMotionEntry(context.previousScene, key)?.metadata.path @@ -2368,7 +2365,7 @@ function addSemanticPathUpdateTrack( geometry.source, context.runtime, ) - current.setAttribute('data-ts-motion-role', resolvedContext.role) + setMotionRole(current, resolvedContext.role) tracks.push( semanticPathTrack({ element: current, @@ -2378,10 +2375,10 @@ function addSemanticPathUpdateTrack( timing: context.timingFor(resolvedContext), runtime: context.runtime, finish() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }), ) @@ -2442,7 +2439,7 @@ function addEnterMotionTrack( const timingContext = elementTimingContext(element, 'enter', context.scene) if (!timingContext) return const timing = context.timingFor(timingContext) - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) const pointRolling = timingContext.point ? context.pathPlans.points.get(pointIdentity(timingContext.point)) @@ -2450,7 +2447,7 @@ function addEnterMotionTrack( if (element.localName === 'circle' && pointRolling) { if (pointRolling.outcome.kind === 'fallback') { if (pointRolling.outcome.fallback === 'snap') { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) return } } else { @@ -2470,10 +2467,10 @@ function addEnterMotionTrack( apply, finish() { apply(to) - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, cancel() { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, }) return @@ -2488,7 +2485,7 @@ function addEnterMotionTrack( const horizontal = Boolean(element.closest('g.ts-chart__bar-x')) const geometry = barShapeGeometry(element, context.scene) if (!geometry) { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) return } const { x: targetX, y: targetY } = geometry @@ -2500,55 +2497,16 @@ function addEnterMotionTrack( horizontal, horizontal ? targetX : targetY + targetHeight, ) - if (element.localName === 'path' && geometry.cornerRadii) { - const to = barPathGeometryValues(geometry) - const from = barPathEntranceValues(to, horizontal, baseline) - const states = elementValueStates( - context.runtime, + tracks.push( + createBarEntranceTrack( element, - 'bar-geometry', - from, - ) - const apply = (values: readonly number[]) => { - applyBarPathGeometry(element, values) - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish() { - finishBarShapeGeometry(element, geometry) - element.removeAttribute('data-ts-motion-role') - }, - cancel() { - element.removeAttribute('data-ts-motion-role') - }, - }) - return - } - const names = horizontal ? ['x', 'width'] : ['y', 'height'] - const from = [baseline, 0] - const to = horizontal ? [targetX, targetWidth] : [targetY, targetHeight] - const states = names.flatMap((name, index) => - elementValueStates(context.runtime, element, name, [from[index] ?? 0]), + geometry, + horizontal, + baseline, + timing, + context.runtime, + ), ) - const apply = (values: readonly number[]) => { - applyBarShapeGeometry(element, geometry, horizontal, values) - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish() { - finishBarShapeGeometry(element, geometry) - element.removeAttribute('data-ts-motion-role') - }, - cancel() { - element.removeAttribute('data-ts-motion-role') - }, - }) return } @@ -2591,12 +2549,11 @@ function addEnterMotionTrack( element.setAttribute('opacity', formatNumber(values[0] ?? 0)) }, finish() { - if (targetOpacity === null) element.removeAttribute('opacity') - else element.setAttribute('opacity', targetOpacity) - element.removeAttribute('data-ts-motion-role') + restoreAttribute(element, 'opacity', targetOpacity) + clearMotionRole(element) }, cancel() { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, }) } @@ -2647,7 +2604,7 @@ function addExitMotionTrack( element.setAttribute('cx', formatNumber(values[0] ?? targetX)) element.setAttribute('cy', formatNumber(values[1] ?? targetY)) } - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) tracks.push({ ...pointRolling.timing, values: bindMotionValues(undefined, [startX, startY], [targetX, targetY]), @@ -2689,7 +2646,7 @@ function addExitMotionTrack( } const target = Number(element.getAttribute('opacity') ?? 1) const opacity = Number.isFinite(target) ? target : 1 - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) const states = elementValueStates(context.runtime, element, 'opacity', [ opacity, ]) @@ -2737,7 +2694,7 @@ function hierarchyMotionRelation( if (element.localName !== 'path' || !ownerScene || !relatedScene) { return undefined } - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) const ownerPath = element.getAttribute('d') if (!key || !ownerPath) return undefined const owner = sceneMotionEntry(ownerScene, key) @@ -2758,8 +2715,7 @@ function hierarchyMotionRelation( const root = element.closest('svg') const relatedElement = root ? [...root.querySelectorAll('path[data-ts-key]')].find( - (candidate) => - candidate.getAttribute('data-ts-key') === ancestor.node.key, + (candidate) => elementKey(candidate) === ancestor.node.key, ) : undefined const relatedPath = relatedElement?.getAttribute('d') @@ -2882,11 +2838,9 @@ function elementTimingContext( ] : [group] const seriesIndex = Math.max(0, groups.indexOf(group)) - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` const key = - element.getAttribute('data-ts-key') ?? - (role === 'line' ? seriesKey : `${seriesKey}:0`) + elementKey(element) ?? (role === 'line' ? seriesKey : `${seriesKey}:0`) const point = scene.points.find( (candidate) => candidate.key === key || key === `${candidate.key}:dot`, ) @@ -2912,12 +2866,12 @@ function guideOrMarkTimingContext( phase: ChartMotionPhase, scene: ChartScene, ): ChartMotionContext | undefined { - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) if (!key) return undefined const focusGuide = element.closest('g.ts-chart__crosshair') if (focusGuide) { - const ownerKey = focusGuide.getAttribute('data-ts-key') ?? key + const ownerKey = elementKey(focusGuide) ?? key const markId = motionMarkId(scene, ownerKey) return { phase, @@ -2991,8 +2945,7 @@ function guideOrMarkTimingContext( : key const peers = parent ? [...parent.querySelectorAll('[data-ts-key]')].filter( - (candidate) => - candidate.getAttribute('data-ts-key')?.startsWith(prefix) ?? false, + (candidate) => elementKey(candidate)?.startsWith(prefix) ?? false, ) : [element] return { @@ -3027,7 +2980,7 @@ function guideOrMarkTimingContext( while (owner.parentElement && owner.parentNode !== ownerParent) { owner = owner.parentElement } - const ownerKey = owner.getAttribute('data-ts-key') ?? key + const ownerKey = elementKey(owner) ?? key const markId = point?.markId ?? focusContext?.markId ?? motionMarkId(scene, ownerKey) const role = markMotionRole(owner, element) @@ -3086,7 +3039,7 @@ function retargetFocusContext( point: ChartPoint | undefined } | undefined { - const layerKey = focusLayer.getAttribute('data-ts-key') + const layerKey = elementKey(focusLayer) if (!layerKey) return undefined const layer = findSceneGroup(scene.nodes, layerKey) if (!layer?.focus?.retarget) return undefined @@ -3103,7 +3056,7 @@ function retargetFocusContext( let slot: number | undefined let selectionKeySeen = false while (current && current !== focusLayer) { - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) if (key?.startsWith(prefix)) { selectionKeySeen = true const encoded = key.slice(prefix.length).split(':')[0] ?? '' @@ -3569,7 +3522,7 @@ function keyedElements(root: SVGSVGElement, selector: string) { function keyedElementMap(root: ParentNode, selector: string) { const result = new Map() for (const element of root.querySelectorAll(selector)) { - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) if (key && !result.has(key)) result.set(key, element) } return result @@ -3707,7 +3660,7 @@ function indexMotionChildren(children: readonly Element[]) { function motionIdentities(children: readonly Element[]) { const counts = new Map() return children.map((child) => { - const key = child.getAttribute('data-ts-key') + const key = elementKey(child) if (key) return `key:${key}` const count = counts.get(child.localName) ?? 0 counts.set(child.localName, count + 1) @@ -4137,3 +4090,24 @@ function nonNegative(value: number | undefined, fallback: number) { function formatNumber(value: number) { return String(Math.round(value * 1_000) / 1_000) } + +function elementKey(element: Element) { + return element.getAttribute('data-ts-key') +} + +function setMotionRole(element: Element, role: ChartMotionRole) { + element.setAttribute('data-ts-motion-role', role) +} + +function clearMotionRole(element: Element) { + element.removeAttribute('data-ts-motion-role') +} + +function restoreAttribute( + element: Element, + name: string, + value: string | null, +) { + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) +} From 238c2b3a735d5675b95c78eea197d7e19f183f2e Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 17:56:47 +0200 Subject: [PATCH 04/10] Separate keyed SVG reconciliation from tween animation Share the keyed SVG tree walk between static updates, SVG tweens, and motion tracks. Keep the public reconciliation API unchanged, while motion imports the static path directly and no longer retains the separate tween engine. Preserve the existing resource replacement policy for each renderer. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 182,279 -> 179,361 B; saves 2,918 B (1.60%). gzip: 60,565 -> 59,535 B; saves 1,030 B (1.70%). Brotli: 51,974 -> 51,378 B; saves 596 B (1.15%). Total savings from baseline: 6,058 B (3.27%) minified, 1,333 B (2.19%) gzip, and 707 B (1.36%) Brotli. Validation: TypeScript, reconciliation, motion, and SVG surface tests pass; production Edge SVG snapshots match the baseline. Bundle boundary checks pass; a few locked non-motion consumer ceilings need a final baseline refresh for small gzip changes (up to 70 bytes). --- packages/charts-core/src/motion.ts | 83 ++-------- .../charts-core/src/reconcile-internal.ts | 155 ++++++++++++++++++ packages/charts-core/src/reconcile.ts | 130 +++------------ 3 files changed, 191 insertions(+), 177 deletions(-) create mode 100644 packages/charts-core/src/reconcile-internal.ts diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 8587e89f..3e0ebaa7 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1,7 +1,11 @@ import { focusedNodeKeys, resolveFocusScene } from './focus-layer' import { resolveFocusGuides } from './focus-presentation' import { resolveMarkStateScene } from './mark-state' -import { reconcileChartSvg, reconcileChartSvgFragment } from './reconcile' +import { + reconcileSvgMarkup, + reconcileSvgFragment, + reconcileElement, +} from './reconcile-internal' import { chartSceneSource } from './scene-source' import { sceneMotionNode, @@ -903,7 +907,7 @@ function createMotionSvgChartRenderer< false) const markup = renderSvg(presented.scene, renderOptions) cancelAnimation = reduced - ? reconcileChartSvg(container, markup) + ? reconcileSvgMarkup(container, markup) : motion.animateSvg({ container, scene: presented.scene as ChartScene, @@ -995,7 +999,7 @@ function createMotionSvgChartRenderer< dataMotionActive = animate && !viewportMoved pendingStateFocus = dataMotionActive ? desiredStateFocus : undefined if (animate && !viewportMoved) { - if (initial) reconcileChartSvg(container, markup) + if (initial) reconcileSvgMarkup(container, markup) cancelAnimation = motion.animateSvg({ container, scene: nextScene as ChartScene, @@ -1018,7 +1022,7 @@ function createMotionSvgChartRenderer< }, }) } else { - reconcileChartSvg(container, markup) + reconcileSvgMarkup(container, markup) publishPresentationPoints(nextScene.points) dataMotionActive = false } @@ -1185,7 +1189,7 @@ function paintMotionSvgFocusGuides(options: { const markup = renderFocusGuideLayer(nodes, placement, idPrefix) if (reduced || !visible.has(placement)) { - reconcileChartSvgFragment(layer, markup) + reconcileSvgFragment(layer, markup) } else { cancellations.push( motion.animateSvgFragment({ @@ -2082,51 +2086,12 @@ function reconcileMotionElement( tracks: MotionTrack[], context: MotionReconcileContext, ) { - addUpdateTrack(current, next, tracks, context) - - if (!next.firstElementChild) { - if (current.firstElementChild) { - for (const child of [...current.children]) { - addExitMotionTrack(child, tracks, context) - } - } else if (current.textContent !== next.textContent) { - current.textContent = next.textContent - } - return - } - - const currentChildren = [...current.children] - const nextChildren = [...next.children] - const currentByIdentity = indexMotionChildren(currentChildren) - const nextIdentities = motionIdentities(nextChildren) - const retained = new Set() - let cursor = current.firstElementChild - - nextChildren.forEach((nextChild, index) => { - const matched = currentByIdentity.get(nextIdentities[index]) - let rendered: Element - if ( - matched && - matched.namespaceURI === nextChild.namespaceURI && - matched.localName === nextChild.localName - ) { - rendered = matched - retained.add(matched) - if (rendered !== cursor) current.insertBefore(rendered, cursor) - reconcileMotionElement(rendered, nextChild, tracks, context) - } else { - rendered = nextChild.cloneNode(true) as Element - current.insertBefore(rendered, cursor) - addEnterMotionTrack(rendered, tracks, context) - } - cursor = rendered.nextElementSibling + reconcileElement(current, next, { + update: (current, next) => addUpdateTrack(current, next, tracks, context), + enter: (element) => addEnterMotionTrack(element, tracks, context), + exit: (element) => addExitMotionTrack(element, tracks, context), + replaceDefinitions: false, }) - - for (const child of currentChildren) { - if (!retained.has(child) && child.parentElement === current) { - addExitMotionTrack(child, tracks, context) - } - } } function addUpdateTrack( @@ -3648,26 +3613,6 @@ function bindMotionValues( }) } -function indexMotionChildren(children: readonly Element[]) { - const result = new Map() - motionIdentities(children).forEach((identity, index) => { - const child = children[index] - if (child) result.set(identity, child) - }) - return result -} - -function motionIdentities(children: readonly Element[]) { - const counts = new Map() - return children.map((child) => { - const key = elementKey(child) - if (key) return `key:${key}` - const count = counts.get(child.localName) ?? 0 - counts.set(child.localName, count + 1) - return `tag:${child.localName}:${count}` - }) -} - function resolveTiming( options: ResolvedMotionOptions, context: ChartMotionContext, diff --git a/packages/charts-core/src/reconcile-internal.ts b/packages/charts-core/src/reconcile-internal.ts new file mode 100644 index 00000000..4ad80f57 --- /dev/null +++ b/packages/charts-core/src/reconcile-internal.ts @@ -0,0 +1,155 @@ +/** Optional animation callbacks around the shared keyed SVG tree walk. */ +export interface SvgReconcileHooks { + update: (current: Element, next: Element) => void + enter: (current: Element, next: Element) => void + exit: (current: Element) => void + /** Motion retains exiting resource nodes; ordinary reconciliation replaces them. */ + replaceDefinitions?: boolean +} + +export function reconcileSvgMarkup( + container: HTMLElement, + markup: string, + hooks?: SvgReconcileHooks, +): () => void { + const template = container.ownerDocument.createElement('template') + template.innerHTML = markup + const nextRoot = template.content.firstElementChild + if (!nextRoot) return () => {} + + const currentRoot = container.firstElementChild + if ( + !currentRoot || + currentRoot.namespaceURI !== nextRoot.namespaceURI || + currentRoot.localName !== nextRoot.localName + ) { + container.replaceChildren(nextRoot) + return () => {} + } + + reconcileElement(currentRoot, nextRoot, hooks) + return () => {} +} + +/** Reconciles one keyed SVG subtree without reparsing or walking the chart. */ +export function reconcileSvgFragment( + currentRoot: SVGElement, + markup: string, + hooks?: SvgReconcileHooks, +): () => void { + const template = currentRoot.ownerDocument.createElement('template') + template.innerHTML = `${markup}` + const wrapper = template.content.firstElementChild + const nextRoot = wrapper?.firstElementChild + if (!nextRoot) return () => {} + + if ( + currentRoot.namespaceURI !== nextRoot.namespaceURI || + currentRoot.localName !== nextRoot.localName + ) { + currentRoot.replaceWith(nextRoot) + return () => {} + } + + reconcileElement(currentRoot, nextRoot, hooks) + return () => {} +} + +export function reconcileElement( + current: Element, + next: Element, + hooks?: SvgReconcileHooks, +) { + if (hooks) hooks.update(current, next) + else syncAttributes(current, next) + + if (!next.firstElementChild) { + if (current.firstElementChild) { + for (const child of [...current.children]) { + if (hooks) hooks.exit(child) + else child.remove() + } + } else if (current.textContent !== next.textContent) { + current.textContent = next.textContent + } + return + } + + const currentChildren = [...current.children] + const nextChildren = [...next.children] + const currentByIdentity = indexChildren(currentChildren) + const nextIdentities = identities(nextChildren) + const retained = new Set() + let cursor = current.firstElementChild + + nextChildren.forEach((nextChild, index) => { + const identity = nextIdentities[index] + const matched = currentByIdentity.get(identity) + let rendered: Element + + if ( + matched && + matched.namespaceURI === nextChild.namespaceURI && + matched.localName === nextChild.localName + ) { + rendered = matched + retained.add(matched) + if (rendered !== cursor) current.insertBefore(rendered, cursor) + reconcileElement(rendered, nextChild, hooks) + } else if ( + matched && + current.localName === 'defs' && + hooks?.replaceDefinitions !== false + ) { + rendered = nextChild.cloneNode(true) as Element + matched.replaceWith(rendered) + if (matched !== cursor) current.insertBefore(rendered, cursor) + } else { + rendered = nextChild.cloneNode(true) as Element + current.insertBefore(rendered, cursor) + hooks?.enter(rendered, nextChild) + } + + cursor = rendered.nextElementSibling + }) + + for (const child of currentChildren) { + if (!retained.has(child) && child.parentElement === current) { + if (hooks) hooks.exit(child) + else child.remove() + } + } +} + +function syncAttributes(current: Element, next: Element) { + const nextNames = new Set(next.getAttributeNames()) + for (const name of current.getAttributeNames()) { + if (!nextNames.has(name)) current.removeAttribute(name) + } + + for (const name of nextNames) { + const target = next.getAttribute(name) + if (target !== null && target !== current.getAttribute(name)) { + current.setAttribute(name, target) + } + } +} + +function indexChildren(children: readonly Element[]) { + const result = new Map() + identities(children).forEach((identity, index) => { + result.set(identity, children[index]) + }) + return result +} + +function identities(children: readonly Element[]) { + const counts = new Map() + return children.map((child) => { + const explicit = child.getAttribute('data-ts-key') + if (explicit) return `key:${explicit}` + const count = counts.get(child.localName) ?? 0 + counts.set(child.localName, count + 1) + return `tag:${child.localName}:${count}` + }) +} diff --git a/packages/charts-core/src/reconcile.ts b/packages/charts-core/src/reconcile.ts index ff85c008..cd2b6f4f 100644 --- a/packages/charts-core/src/reconcile.ts +++ b/packages/charts-core/src/reconcile.ts @@ -1,3 +1,8 @@ +import { + reconcileSvgMarkup, + reconcileSvgFragment, + type SvgReconcileHooks, +} from './reconcile-internal' import type { ChartAnimationOptions } from './types' interface AttributeTween { @@ -38,23 +43,13 @@ export function reconcileChartSvg( markup: string, animation?: ChartAnimationOptions, ): () => void { - const template = container.ownerDocument.createElement('template') - template.innerHTML = markup - const nextRoot = template.content.firstElementChild - if (!nextRoot) return () => {} - - const currentRoot = container.firstElementChild - if ( - !currentRoot || - currentRoot.namespaceURI !== nextRoot.namespaceURI || - currentRoot.localName !== nextRoot.localName - ) { - container.replaceChildren(nextRoot) - return () => {} - } - const tweens: AttributeTween[] = [] - reconcileElement(currentRoot, nextRoot, animation ? tweens : undefined) + reconcileSvgMarkup( + container, + markup, + animation ? tweenHooks(tweens) : undefined, + ) + return animation ? runTweens(container, tweens, animation) : () => {} } @@ -64,83 +59,21 @@ export function reconcileChartSvgFragment( markup: string, animation?: ChartAnimationOptions, ): () => void { - const template = currentRoot.ownerDocument.createElement('template') - template.innerHTML = `${markup}` - const wrapper = template.content.firstElementChild - const nextRoot = wrapper?.firstElementChild - if (!nextRoot) return () => {} - - if ( - currentRoot.namespaceURI !== nextRoot.namespaceURI || - currentRoot.localName !== nextRoot.localName - ) { - currentRoot.replaceWith(nextRoot) - return () => {} - } - const tweens: AttributeTween[] = [] - reconcileElement(currentRoot, nextRoot, animation ? tweens : undefined) + reconcileSvgFragment( + currentRoot, + markup, + animation ? tweenHooks(tweens) : undefined, + ) + return animation ? runTweens(currentRoot, tweens, animation) : () => {} } -function reconcileElement( - current: Element, - next: Element, - tweens: AttributeTween[] | undefined, -) { - syncAttributes(current, next, tweens) - - if (!next.firstElementChild) { - if (current.firstElementChild) { - for (const child of [...current.children]) { - if (tweens) addExitTween(child, tweens) - else child.remove() - } - } else if (current.textContent !== next.textContent) { - current.textContent = next.textContent - } - return - } - - const currentChildren = [...current.children] - const nextChildren = [...next.children] - const currentByIdentity = indexChildren(currentChildren) - const nextIdentities = identities(nextChildren) - const retained = new Set() - let cursor = current.firstElementChild - - nextChildren.forEach((nextChild, index) => { - const identity = nextIdentities[index] - const matched = currentByIdentity.get(identity) - let rendered: Element - - if ( - matched && - matched.namespaceURI === nextChild.namespaceURI && - matched.localName === nextChild.localName - ) { - rendered = matched - retained.add(matched) - if (rendered !== cursor) current.insertBefore(rendered, cursor) - reconcileElement(rendered, nextChild, tweens) - } else if (matched && current.localName === 'defs') { - rendered = nextChild.cloneNode(true) as Element - matched.replaceWith(rendered) - if (matched !== cursor) current.insertBefore(rendered, cursor) - } else { - rendered = nextChild.cloneNode(true) as Element - current.insertBefore(rendered, cursor) - addEnterTween(rendered, nextChild, tweens) - } - - cursor = rendered.nextElementSibling - }) - - for (const child of currentChildren) { - if (!retained.has(child) && child.parentElement === current) { - if (tweens) addExitTween(child, tweens) - else child.remove() - } +function tweenHooks(tweens: AttributeTween[]): SvgReconcileHooks { + return { + update: (current, next) => syncAttributes(current, next, tweens), + enter: (current, next) => addEnterTween(current, next, tweens), + exit: (current) => addExitTween(current, tweens), } } @@ -318,25 +251,6 @@ function extractNumbers(value: string, path = false) { return { skeleton, values } } -function indexChildren(children: readonly Element[]) { - const result = new Map() - identities(children).forEach((identity, index) => { - result.set(identity, children[index]) - }) - return result -} - -function identities(children: readonly Element[]) { - const counts = new Map() - return children.map((child) => { - const explicit = child.getAttribute('data-ts-key') - if (explicit) return `key:${explicit}` - const count = counts.get(child.localName) ?? 0 - counts.set(child.localName, count + 1) - return `tag:${child.localName}:${count}` - }) -} - function easing(name: NonNullable) { if (typeof name === 'function') return name switch (name) { From 95662b2b0567fd0498b9ff3588d34833d852c24c Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 18:00:41 +0200 Subject: [PATCH 05/10] Share stack input validation and series ordering Reuse stack row normalization and explicit/value-based series ordering for stack extents and exposed bar ends. Keep inside-out ordering and its existing paint-envelope behavior, and use the shared chart-value predicates. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 179,361 -> 178,524 B; saves 837 B (0.47%). gzip: 59,535 -> 59,341 B; saves 194 B (0.33%). Brotli: 51,378 -> 51,279 B; saves 99 B (0.19%). Total savings from baseline: 6,895 B (3.72%) minified, 1,527 B (2.51%) gzip, and 806 B (1.55%) Brotli. Validation: TypeScript, mark and stack tests pass; production Edge SVG snapshots match the baseline for all application scenarios. --- .../charts-core/src/stack-ends-internal.ts | 81 ++++-------------- packages/charts-core/src/stack-internal.ts | 84 ++++++------------- .../charts-core/src/stack-order-internal.ts | 30 +++++++ 3 files changed, 73 insertions(+), 122 deletions(-) create mode 100644 packages/charts-core/src/stack-order-internal.ts diff --git a/packages/charts-core/src/stack-ends-internal.ts b/packages/charts-core/src/stack-ends-internal.ts index 921e1925..14d58250 100644 --- a/packages/charts-core/src/stack-ends-internal.ts +++ b/packages/charts-core/src/stack-ends-internal.ts @@ -2,13 +2,12 @@ import { stackOrderInsideOut } from 'd3-shape' import type { Series } from 'd3-shape' import { valueKey } from './scales' import type { StackOptions, StackOrder } from './stack' +import { createStackInput, type StackInput } from './stack-internal' +import { orderedSeries } from './stack-order-internal' +import { isFiniteNumber } from './mark' import type { ChartKey, ChartValue } from './types' -interface StackEndInput { - index: number - position: ChartValue - value: number - series: ChartKey +interface StackEndInput extends StackInput { start: number end: number } @@ -120,31 +119,17 @@ function stackEndInput( fallbackSeries: 'value' | 'index', ): StackEndInput[] { const input: StackEndInput[] = [] - for (let index = 0; index < positions.length; index += 1) { - const position = positions[index] - const value = values[index] - const start = starts[index] - const end = ends[index] - if ( - !isChartValue(position) || - !isFiniteNumber(value) || - !isFiniteNumber(start) || - !isFiniteNumber(end) - ) - continue - const seriesValue = series[index] - input.push({ - index, - position, - value, - start, - end, - series: isChartKey(seriesValue) - ? seriesValue - : fallbackSeries === 'index' - ? index - : 'value', - }) + for (const row of createStackInput( + positions, + values, + series, + fallbackSeries, + )) { + const start = starts[row.index] + const end = ends[row.index] + if (!isFiniteNumber(start) || !isFiniteNumber(end)) continue + + input.push({ ...row, start, end }) } return input } @@ -161,14 +146,6 @@ function resolveSeriesOrder( seen.add(identity) firstSeen.push(row.series) } - if (Array.isArray(order)) { - const explicit = [...order] - const explicitKeys = new Set(explicit.map(valueKey)) - return [ - ...explicit, - ...firstSeen.filter((value) => !explicitKeys.has(valueKey(value))), - ] - } if (order === 'inside-out') { const positions: ChartValue[] = [] const positionIndex = new Map() @@ -191,31 +168,5 @@ function resolveSeriesOrder( seriesValues as unknown as Series, string>, ).map((index) => firstSeen[index]!) } - if (order !== 'ascending' && order !== 'descending') return firstSeen - const totals = new Map(firstSeen.map((value) => [valueKey(value), 0])) - for (const row of input) { - const key = valueKey(row.series) - totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) - } - return firstSeen.sort((left, right) => { - const difference = - (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) - return order === 'ascending' ? difference : -difference - }) -} - -function isChartKey(value: unknown): value is ChartKey { - return typeof value === 'string' || typeof value === 'number' -} - -function isChartValue(value: unknown): value is ChartValue { - return ( - typeof value === 'string' || - isFiniteNumber(value) || - (value instanceof Date && Number.isFinite(value.getTime())) - ) -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) + return orderedSeries(input, firstSeen, order) } diff --git a/packages/charts-core/src/stack-internal.ts b/packages/charts-core/src/stack-internal.ts index f15abbaf..6373fbae 100644 --- a/packages/charts-core/src/stack-internal.ts +++ b/packages/charts-core/src/stack-internal.ts @@ -1,3 +1,5 @@ +import { isChartKey, isChartValue, isFiniteNumber } from './mark' +import { orderedSeries } from './stack-order-internal' import { stack as d3Stack, stackOffsetExpand, @@ -8,7 +10,7 @@ import { } from 'd3-shape' import type { Series } from 'd3-shape' import { valueKey } from './scales' -import type { StackOptions, StackOrder } from './stack' +import type { StackOptions } from './stack' import type { ChartKey, ChartValue } from './types' export interface StackInput { @@ -224,23 +226,7 @@ export function stackValues( options: Readonly = {}, fallbackSeries: 'value' | 'index' = 'value', ) { - const input: StackInput[] = [] - for (let index = 0; index < positions.length; index += 1) { - const position = positions[index] - const value = values[index] - if (!isChartValue(position) || !isFiniteNumber(value)) continue - const seriesValue = series[index] - input.push({ - index, - position, - value, - series: isChartKey(seriesValue) - ? seriesValue - : fallbackSeries === 'index' - ? index - : 'value', - }) - } + const input = createStackInput(positions, values, series, fallbackSeries) const extents = stackExtents(input, options) const starts: (number | undefined)[] = Array.from( { length: positions.length }, @@ -257,44 +243,28 @@ export function stackValues( return { starts, ends } } -function isChartKey(value: unknown): value is ChartKey { - return typeof value === 'string' || typeof value === 'number' -} - -function isChartValue(value: unknown): value is ChartValue { - return ( - typeof value === 'string' || - isFiniteNumber(value) || - (value instanceof Date && Number.isFinite(value.getTime())) - ) -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - -function orderedSeries( - rows: readonly StackInput[], - input: readonly ChartKey[], - order: StackOrder | undefined, -): ChartKey[] { - if (Array.isArray(order)) { - const explicit = [...order] - const explicitKeys = new Set(explicit.map(valueKey)) - return [ - ...explicit, - ...input.filter((value) => !explicitKeys.has(valueKey(value))), - ] - } - if (order !== 'ascending' && order !== 'descending') return [...input] - const totals = new Map(input.map((value) => [valueKey(value), 0])) - for (const row of rows) { - const key = valueKey(row.series) - totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) +export function createStackInput( + positions: readonly unknown[], + values: readonly unknown[], + series: readonly unknown[], + fallbackSeries: 'value' | 'index', +): StackInput[] { + const input: StackInput[] = [] + for (let index = 0; index < positions.length; index += 1) { + const position = positions[index] + const value = values[index] + if (!isChartValue(position) || !isFiniteNumber(value)) continue + const seriesValue = series[index] + input.push({ + index, + position, + value, + series: isChartKey(seriesValue) + ? seriesValue + : fallbackSeries === 'index' + ? index + : 'value', + }) } - return [...input].sort((left, right) => { - const difference = - (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) - return order === 'ascending' ? difference : -difference - }) + return input } diff --git a/packages/charts-core/src/stack-order-internal.ts b/packages/charts-core/src/stack-order-internal.ts new file mode 100644 index 00000000..79396c2d --- /dev/null +++ b/packages/charts-core/src/stack-order-internal.ts @@ -0,0 +1,30 @@ +import { valueKey } from './scales' +import type { StackInput } from './stack-internal' +import type { StackOrder } from './stack' +import type { ChartKey } from './types' + +export function orderedSeries( + rows: readonly StackInput[], + input: readonly ChartKey[], + order: StackOrder | undefined, +): ChartKey[] { + if (Array.isArray(order)) { + const explicit = [...order] + const explicitKeys = new Set(explicit.map(valueKey)) + return [ + ...explicit, + ...input.filter((value) => !explicitKeys.has(valueKey(value))), + ] + } + if (order !== 'ascending' && order !== 'descending') return [...input] + const totals = new Map(input.map((value) => [valueKey(value), 0])) + for (const row of rows) { + const key = valueKey(row.series) + totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) + } + return [...input].sort((left, right) => { + const difference = + (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) + return order === 'ascending' ? difference : -difference + }) +} From 50e0fffa964153c28c4f8615ff9f1496eb0f86a6 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 18:06:39 +0200 Subject: [PATCH 06/10] Share motion contexts and resolve ownership without sorting Centralize motion callback defaults and semantic class precedence. Find the longest matching owner in one pass instead of allocating and sorting candidate arrays for marks, focus guides, scale lookup, and transition overrides. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 178,524 -> 177,432 B; saves 1,092 B (0.61%). gzip: 59,341 -> 59,238 B; saves 103 B (0.17%). Brotli: 51,279 -> 51,236 B; saves 43 B (0.08%). Total savings from baseline: 7,987 B (4.31%) minified, 1,630 B (2.68%) gzip, and 849 B (1.63%) Brotli. Validation: TypeScript and motion tests pass; production Edge SVG snapshots match the baseline, including updates, focus, resize, and reduced motion. --- packages/charts-core/src/motion.ts | 369 +++++++++++++++-------------- 1 file changed, 186 insertions(+), 183 deletions(-) diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 3e0ebaa7..6fbe2586 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1249,18 +1249,19 @@ function createBarTracks( horizontal, horizontal ? targetX : targetY + targetHeight, ) - const timing = timingFor({ - phase: 'enter', - role: 'bar', - key, - markId: point?.markId ?? motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex, - datumCount: shapes.length, - datum: point?.datum, - point, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role: 'bar', + key, + markId: point?.markId ?? motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + datumIndex, + datumCount: shapes.length, + point, + }), + ) tracks.push( createBarEntranceTrack( @@ -1502,18 +1503,16 @@ function createCartesianPathTracks( ? 'area' : 'line' const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` - const timing = timingFor({ - phase: 'enter', - role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) const horizontal = scenePathAffinity(scene, seriesKey) === 'y' const baseline = resolvePathBaseline(scene, horizontal) return createTransformEntranceTrack(group, role, timing, (progress) => @@ -1543,18 +1542,16 @@ function createRadialPathTracks( ? 'dot' : 'line' const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` - const timing = timingFor({ - phase: 'enter', - role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) return createTransformEntranceTrack( group, role, @@ -1607,18 +1604,16 @@ function createArcTracks( const role: ChartMotionRole = group.classList.contains('ts-chart__bar') ? 'bar' : 'arc' - const timing = timingFor({ - phase: 'enter', - role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) const document = root.ownerDocument let definitions = root.querySelector('defs') if (!definitions) { @@ -1955,13 +1950,11 @@ function motionPointScaleId( ): string | undefined { if (!point) return undefined const initialized = motionSceneSource(scene)?.[1] - const mark = initialized - ?.filter( - (candidate) => - point.markId === candidate.id || - point.markId.startsWith(`${candidate.id}:`), - ) - .sort((left, right) => right.id.length - left.id.length)[0] + const mark = findLongestKeyPrefix( + initialized ?? [], + point.markId, + (mark) => mark.id, + ) return mark?.channels[channel]?.scale } @@ -2812,7 +2805,7 @@ function elementTimingContext( const shapes = barGroup ? barShapeElements(barGroup) : [] const datumIndex = point?.datumIndex ?? Math.max(0, shapes.indexOf(element as BarShapeElement)) - return { + return createMotionContext({ phase, role, key, @@ -2821,9 +2814,8 @@ function elementTimingContext( seriesIndex, datumIndex, datumCount: barGroup ? Math.max(1, shapes.length) : 1, - datum: point?.datum, point, - } + }) } function guideOrMarkTimingContext( @@ -2838,18 +2830,13 @@ function guideOrMarkTimingContext( if (focusGuide) { const ownerKey = elementKey(focusGuide) ?? key const markId = motionMarkId(scene, ownerKey) - return { + return createMotionContext({ phase, role: markMotionRole(focusGuide, element), key, markId, seriesKey: ownerKey, - seriesIndex: 0, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - } + }) } const presentationFocusLayer = element.closest( @@ -2869,18 +2856,16 @@ function guideOrMarkTimingContext( const markPoints = focusPoints.filter( (candidate) => candidate.markId === point.markId, ) - return { + return createMotionContext({ phase, role: markMotionRole(presentationFocusLayer, element), key, markId: point.markId, seriesKey: `${point.markId}:${motionGroupIdentity(point)}`, - seriesIndex: 0, datumIndex: point.datumIndex, datumCount: Math.max(1, markPoints.length), - datum: point.datum, point, - } + }) } const axes = element.closest('g.ts-chart__axes') @@ -2913,7 +2898,7 @@ function guideOrMarkTimingContext( (candidate) => elementKey(candidate)?.startsWith(prefix) ?? false, ) : [element] - return { + return createMotionContext({ phase, role, key, @@ -2923,9 +2908,7 @@ function guideOrMarkTimingContext( seriesIndex: Math.max(0, Object.keys(scene.scales).indexOf(scaleId)), datumIndex: Math.max(0, peers.indexOf(element)), datumCount: Math.max(1, peers.length), - datum: undefined, - point: undefined, - } + }) } const marks = element.closest('g.ts-chart__marks') @@ -2957,18 +2940,16 @@ function guideOrMarkTimingContext( const seriesKey = point ? `${point.markId}:${String(point.group ?? '')}` : ownerKey - return { + return createMotionContext({ phase, role, key, markId, seriesKey, - seriesIndex: 0, datumIndex: point?.datumIndex ?? 0, datumCount: Math.max(1, markPoints.length), - datum: point?.datum, point, - } + }) } function guideScaleId(scene: ChartScene, key: string): string | undefined { @@ -3080,19 +3061,30 @@ function motionPointForKey( points: readonly ChartPoint[], key: string, ): ChartPoint | undefined { - let match: ChartPoint | undefined - for (const point of points) { - if ( - key !== point.key && - key !== `${point.key}:dot` && - !key.startsWith(`${point.key}:`) - ) { - continue - } - if (!match || point.key.length > match.key.length) match = point - } - return match -} + return findLongestKeyPrefix(points, key, (point) => point.key) +} + +const motionClassRoles = [ + 'area', + 'radial-area', + 'bar', + 'arc', + 'arrow', + 'band', + 'dot', + 'facet', + 'frame', + 'geo', + 'hexagon', + 'line', + 'link', + 'text', + 'rect', + 'waffle', + 'rule', + 'tick', + 'vector', +] as const function markMotionRole(owner: Element, element: Element): ChartMotionRole { let className = '' @@ -3102,30 +3094,13 @@ function markMotionRole(owner: Element, element: Element): ChartMotionRole { if (current === owner) break current = current.parentElement } - if ( - className.includes('ts-chart__area') || - className.includes('ts-chart__radial-area') + // Preserve semantic precedence when a mark carries several geometry classes. + const role = motionClassRoles.find((role) => + className.includes(`ts-chart__${role}`), ) - return 'area' - // Radial bars also carry the arc geometry role for inspection. Keep the - // authored mark role semantic when both classes are present. - if (className.includes('ts-chart__bar')) return 'bar' - if (className.includes('ts-chart__arc')) return 'arc' - if (className.includes('ts-chart__arrow')) return 'arrow' - if (className.includes('ts-chart__band')) return 'band' - if (className.includes('ts-chart__dot')) return 'dot' - if (className.includes('ts-chart__facet')) return 'facet' - if (className.includes('ts-chart__frame')) return 'frame' - if (className.includes('ts-chart__geo')) return 'geo' - if (className.includes('ts-chart__hexagon')) return 'hexagon' - if (className.includes('ts-chart__line')) return 'line' - if (className.includes('ts-chart__link')) return 'link' - if (className.includes('ts-chart__text')) return 'text' - if (className.includes('ts-chart__rect')) return 'rect' - if (className.includes('ts-chart__waffle')) return 'rect' - if (className.includes('ts-chart__rule')) return 'rule' - if (className.includes('ts-chart__tick')) return 'tick' - if (className.includes('ts-chart__vector')) return 'vector' + if (role === 'radial-area') return 'area' + if (role === 'waffle') return 'rect' + if (role) return role if (element.localName === 'circle') return 'dot' if (element.localName === 'text') return 'text' if (element.localName === 'rect') return 'rect' @@ -3135,19 +3110,20 @@ function markMotionRole(owner: Element, element: Element): ChartMotionRole { } function motionMarkId(scene: ChartScene, key: string): string | undefined { - const focusGuide = scene.focusGuides - ?.slice() - .sort((left, right) => right.key.length - left.key.length) - .find((guide) => key === guide.key || key.startsWith(`${guide.key}:`)) + const focusGuide = findLongestKeyPrefix( + scene.focusGuides ?? [], + key, + (guide) => guide.key, + ) if (focusGuide) return focusGuide.markId + const source = motionSceneSource(scene) - const candidates = new Set([ + const candidates = [ ...scene.points.map((point) => point.markId), ...(source?.[1].map((mark) => mark.id) ?? []), - ]) - return [...candidates] - .sort((left, right) => right.length - left.length) - .find((candidate) => key === candidate || key.startsWith(`${candidate}:`)) + ] + + return findLongestKeyPrefix(candidates, key, (candidate) => candidate) } function createPresentationTracks( @@ -3269,18 +3245,19 @@ function createPresentationTracks( previous ?? (horizontal ? { ...point, x: baseline } : { ...point, y: baseline }) presented.set(identity, { ...point, x: start.x, y: start.y }) - const timing = timingFor({ - phase: defaultPhase === 'enter' ? 'enter' : phase, - role: 'bar', - key: point.key, - markId: point.markId, - seriesKey: point.markId, - seriesIndex: Math.max(0, series.indexOf(point.markId)), - datumIndex: point.datumIndex, - datumCount: counts.get(point.markId) ?? 1, - datum: point.datum, - point, - }) + const timing = timingFor( + createMotionContext({ + phase: defaultPhase === 'enter' ? 'enter' : phase, + role: 'bar', + key: point.key, + markId: point.markId, + seriesKey: point.markId, + seriesIndex: Math.max(0, series.indexOf(point.markId)), + datumIndex: point.datumIndex, + datumCount: counts.get(point.markId) ?? 1, + point, + }), + ) const states = pointValueStates(runtime, identity, [start.x, start.y]) tracks.push({ ...timing, @@ -3379,23 +3356,22 @@ function createPresentationTracks( ) const group = pathGroups.get(seriesKey) const role = group ? markMotionRole(group, group) : 'line' - const timing = timingFor({ - phase: - defaultPhase === 'enter' - ? 'enter' - : previous.some(Boolean) - ? 'update' - : 'enter', - role, - key: seriesKey, - markId: points[0]?.markId ?? motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex: Math.max(0, series.indexOf(seriesKey)), - datumIndex: 0, - datumCount: points.length, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: + defaultPhase === 'enter' + ? 'enter' + : previous.some(Boolean) + ? 'update' + : 'enter', + role, + key: seriesKey, + markId: points[0]?.markId ?? motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex: Math.max(0, series.indexOf(seriesKey)), + datumCount: points.length, + }), + ) const from: number[] = [] const to: number[] = [] const states: MotionValueState[] = [] @@ -3456,18 +3432,18 @@ function createPresentationTracks( } } tracks.push({ - ...timingFor({ - phase: 'exit', - role, - key: point.key, - markId: point.markId, - seriesKey: point.markId, - seriesIndex: Math.max(0, series.indexOf(point.markId)), - datumIndex: point.datumIndex, - datumCount: 1, - datum: point.datum, - point, - }), + ...timingFor( + createMotionContext({ + phase: 'exit', + role, + key: point.key, + markId: point.markId, + seriesKey: point.markId, + seriesIndex: Math.max(0, series.indexOf(point.markId)), + datumIndex: point.datumIndex, + point, + }), + ), values: bindMotionValues(undefined, [0], [1]), apply() {}, finish: cleanup, @@ -3660,17 +3636,18 @@ function resolveTiming( ) } - apply(definitions?.default) - if (context.markId && definitions?.marks) { - const markId = Object.keys(definitions.marks) - .filter( - (candidate) => - context.markId === candidate || - context.markId?.startsWith(`${candidate}:`), - ) - .sort((left, right) => right.length - left.length)[0] - if (markId) apply(definitions.marks[markId]) + const applyMark = (marks: SceneMotionDefinitions['marks']) => { + if (!context.markId || !marks) return + const markId = findLongestKeyPrefix( + Object.keys(marks), + context.markId, + (key) => key, + ) + if (markId) apply(marks[markId]) } + + apply(definitions?.default) + applyMark(definitions?.marks) const guideId = context.scaleId ?? context.axis if (guideId) { apply(definitions?.guides?.[`axis:${guideId}`]) @@ -3679,16 +3656,7 @@ function resolveTiming( } } apply(overrides?.default) - if (context.markId && overrides?.marks) { - const markId = Object.keys(overrides.marks) - .filter( - (candidate) => - context.markId === candidate || - context.markId?.startsWith(`${candidate}:`), - ) - .sort((left, right) => right.length - left.length)[0] - if (markId) apply(overrides.marks[markId]) - } + applyMark(overrides?.marks) // A delayed physical retarget would freeze the sampled velocity. Spring // updates therefore begin immediately; use delay for enter/exit choreography. @@ -4056,3 +4024,38 @@ function restoreAttribute( if (value === null) element.removeAttribute(name) else element.setAttribute(name, value) } + +function createMotionContext( + context: Pick & + Partial, +): ChartMotionContext { + return { + seriesIndex: 0, + datumIndex: 0, + datumCount: 1, + datum: context.point?.datum, + point: undefined, + ...context, + } +} + +function findLongestKeyPrefix( + values: Iterable, + key: string, + getKey: (value: T) => string, +): T | undefined { + let result: T | undefined + let length = -1 + for (const value of values) { + const candidate = getKey(value) + if ( + candidate.length > length && + (key === candidate || key.startsWith(`${candidate}:`)) + ) { + result = value + length = candidate.length + } + } + + return result +} From 6ba50cdedc8a4ba4acf5c09ff9adf78bccbafe16 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 18:09:52 +0200 Subject: [PATCH 07/10] Reuse mark initialization and numeric value policies Share the concrete initialization code behind both mark factories, the Date/Object.is comparison used by scales and interactions, and numeric option policies. Keep rejecting negative values distinct from clamping them to zero, and retain the public mark factory signatures. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 177,432 -> 176,471 B; saves 961 B (0.54%). gzip: 59,238 -> 59,063 B; saves 175 B (0.30%). Brotli: 51,236 -> 51,143 B; saves 93 B (0.18%). Total savings from baseline: 8,948 B (4.83%) minified, 1,805 B (2.97%) gzip, and 942 B (1.81%) Brotli. Validation: TypeScript and all core tests pass, including focused coverage for fallback versus clamping, non-finite inputs, dates, and signed zero. Production Edge SVG snapshots match every baseline scenario. --- packages/charts-core/src/configured-scale.ts | 7 +--- packages/charts-core/src/crosshair.ts | 31 +++++--------- packages/charts-core/src/dom-text.ts | 11 ++--- packages/charts-core/src/guide-layout.ts | 24 +++-------- packages/charts-core/src/interaction.ts | 7 +--- packages/charts-core/src/legend-static.ts | 19 ++++----- .../charts-core/src/mark-with-scale-values.ts | 18 +------- packages/charts-core/src/mark.ts | 21 +++++++--- packages/charts-core/src/motion.ts | 9 ++-- .../charts-core/src/number-internal.test.ts | 42 +++++++++++++++++++ packages/charts-core/src/number-internal.ts | 31 ++++++++++++++ packages/charts-core/src/polar-pie.ts | 5 +-- packages/charts-core/src/scene.ts | 34 +++++---------- packages/charts-core/src/tooltip-model.ts | 21 +++------- .../src/value-equality-internal.test.ts | 25 +++++++++++ .../src/value-equality-internal.ts | 10 +++++ 16 files changed, 175 insertions(+), 140 deletions(-) create mode 100644 packages/charts-core/src/number-internal.test.ts create mode 100644 packages/charts-core/src/number-internal.ts create mode 100644 packages/charts-core/src/value-equality-internal.test.ts create mode 100644 packages/charts-core/src/value-equality-internal.ts diff --git a/packages/charts-core/src/configured-scale.ts b/packages/charts-core/src/configured-scale.ts index 8d4b99bb..3f4e7936 100644 --- a/packages/charts-core/src/configured-scale.ts +++ b/packages/charts-core/src/configured-scale.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import type { ChartContinuousValue, ChartContinuousDomain, @@ -253,12 +254,6 @@ function continuousNumber(value: ChartContinuousValue): number { return value instanceof Date ? value.getTime() : value } -function sameChartValue(left: ChartValue | undefined, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} - function invalidViewportDomain(id: string): never { throw new TypeError( `Chart viewport "${id}" domain must contain two distinct finite numbers or Dates`, diff --git a/packages/charts-core/src/crosshair.ts b/packages/charts-core/src/crosshair.ts index 1dfe9b02..fa5424d7 100644 --- a/packages/charts-core/src/crosshair.ts +++ b/packages/charts-core/src/crosshair.ts @@ -1,3 +1,4 @@ +import { finiteNumber, finiteNonNegative } from './number-internal' import { resolveCrosshairGuide } from './crosshair-resolver' import { createMark } from './mark' import { valueKey } from './scales' @@ -170,12 +171,12 @@ function resolveBand( if (!input) return undefined const options = typeof input === 'object' ? input : undefined return { - bandwidth: finiteNonnegative(scale?.bandwidth, 0), - inset: finite(options?.inset, 0), + bandwidth: finiteNonNegative(scale?.bandwidth, 0), + inset: finiteNumber(options?.inset, 0), radius: options?.radius === undefined ? undefined - : finiteNonnegative(options.radius, 0), + : finiteNonNegative(options.radius, 0), style: { fill: options?.fill ?? fallbackFill, fillOpacity: options?.fillOpacity ?? 0.12, @@ -184,7 +185,7 @@ function resolveBand( strokeWidth: options?.strokeWidth === undefined ? undefined - : finiteNonnegative(options.strokeWidth, 0), + : finiteNonNegative(options.strokeWidth, 0), opacity: options?.opacity, }, } @@ -198,7 +199,7 @@ function resolveRuleStyle( return { stroke: axis?.stroke ?? shared.stroke ?? fallbackStroke, strokeOpacity: axis?.strokeOpacity ?? shared.strokeOpacity ?? 0.35, - strokeWidth: finiteNonnegative(axis?.strokeWidth ?? shared.strokeWidth, 1), + strokeWidth: finiteNonNegative(axis?.strokeWidth ?? shared.strokeWidth, 1), strokeDasharray: axis?.strokeDasharray ?? shared.strokeDasharray, } } @@ -216,15 +217,15 @@ function resolveLabel( : scale ? (value) => formatScaleValue(scale, value) : undefined, - offset: finiteNonnegative(options?.offset, 8), - fontSize: finiteNonnegative(options?.fontSize, 11), + offset: finiteNonNegative(options?.offset, 8), + fontSize: finiteNonNegative(options?.fontSize, 11), fontWeight: options?.fontWeight, style: { fill: options?.fill ?? fallbackFill, fillOpacity: options?.fillOpacity, stroke: options?.stroke ?? 'var(--ts-chart-crosshair-label-halo, Canvas)', strokeOpacity: options?.strokeOpacity, - strokeWidth: finiteNonnegative(options?.strokeWidth, 3), + strokeWidth: finiteNonNegative(options?.strokeWidth, 3), opacity: options?.opacity, }, } @@ -254,24 +255,14 @@ function resolveMarker( if (!input) return undefined const options = typeof input === 'object' ? input : undefined return { - radius: finiteNonnegative(options?.radius, 4), + radius: finiteNonNegative(options?.radius, 4), style: { fill: options?.fill ?? 'var(--ts-chart-crosshair-marker-fill, Canvas)', fillOpacity: options?.fillOpacity, stroke: options?.stroke, strokeOpacity: options?.strokeOpacity, - strokeWidth: finiteNonnegative(options?.strokeWidth, 2), + strokeWidth: finiteNonNegative(options?.strokeWidth, 2), opacity: options?.opacity, }, } } - -function finiteNonnegative(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - -function finite(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback -} diff --git a/packages/charts-core/src/dom-text.ts b/packages/charts-core/src/dom-text.ts index ccfb6660..f9cd2c64 100644 --- a/packages/charts-core/src/dom-text.ts +++ b/packages/charts-core/src/dom-text.ts @@ -1,3 +1,4 @@ +import { finitePositive } from './number-internal' import { estimateSceneText, logicalTextAnchorOffset } from './guide-layout' import type { ChartTextMeasurer, @@ -91,7 +92,7 @@ function configureContext( defaultWeight: string, options: ChartTextMeasureOptions, ): void { - const fontScale = positiveFinite(options.fontScale, 1) + const fontScale = finitePositive(options.fontScale, 1) const fontSize = options.fontSize * fontScale const weight = options.fontWeight ?? defaultWeight context.font = [ @@ -116,7 +117,7 @@ function paintedBounds( measured: TextMetrics, options: ChartTextMeasureOptions, ): ChartTextMetrics { - const fontSize = options.fontSize * positiveFinite(options.fontScale, 1) + const fontSize = options.fontSize * finitePositive(options.fontScale, 1) const left = measured.actualBoundingBoxLeft const right = measured.actualBoundingBoxRight const ascent = measured.actualBoundingBoxAscent @@ -189,9 +190,3 @@ function finiteCssPixels(value: string | undefined): number { const parsed = Number.parseFloat(value ?? '') return Number.isFinite(parsed) ? parsed : 0 } - -function positiveFinite(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) && value > 0 - ? value - : fallback -} diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 205f8994..23898be3 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -1,3 +1,8 @@ +import { + finiteNumber, + finitePositive, + finiteNonNegative, +} from './number-internal' import type { ChartBounds, ChartMargin, @@ -293,22 +298,3 @@ function estimateCharacterWidth(character: string): number { if (character.codePointAt(0)! > 0x7f) return 1 return 0.54 } - -function finiteNonNegative( - value: number | undefined, - fallback: number, -): number { - return value !== undefined && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - -function finiteNumber(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) ? value : fallback -} - -function finitePositive(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) && value > 0 - ? value - : fallback -} diff --git a/packages/charts-core/src/interaction.ts b/packages/charts-core/src/interaction.ts index a350d0c4..a08ac20c 100644 --- a/packages/charts-core/src/interaction.ts +++ b/packages/charts-core/src/interaction.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import { focusNearestX, focusNearestY, focusGroupX, focusGroupY } from './focus' import { findContainingScenePoint } from './nearest' import type { @@ -250,9 +251,3 @@ function compareNavigationPoints< ) { return left.x - right.x || left.y - right.y || leftIndex - rightIndex } - -function sameChartValue(left: ChartValue, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} diff --git a/packages/charts-core/src/legend-static.ts b/packages/charts-core/src/legend-static.ts index 2ade3fe1..563aa659 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -1,3 +1,4 @@ +import { clampNonnegativeNumber } from './number-internal' import { estimateSceneText, physicalTextAnchor, @@ -382,14 +383,14 @@ function resolveCategoricalLegendPresentation( ): CategoricalLegendPresentation { const labelOptions = options.label const indicatorOptions = options.indicator - const fontSize = finiteNonnegative(labelOptions?.fontSize, 11) + const fontSize = clampNonnegativeNumber(labelOptions?.fontSize, 11) const fontWeight = Number.isFinite(labelOptions?.fontWeight) ? labelOptions?.fontWeight : undefined - const indicatorWidth = finiteNonnegative(indicatorOptions?.width, 8) - const indicatorHeight = finiteNonnegative(indicatorOptions?.height, 8) - const indicatorGap = finiteNonnegative(indicatorOptions?.gap, 5) - const rowGap = finiteNonnegative(options.rowGap, 8) + const indicatorWidth = clampNonnegativeNumber(indicatorOptions?.width, 8) + const indicatorHeight = clampNonnegativeNumber(indicatorOptions?.height, 8) + const indicatorGap = clampNonnegativeNumber(indicatorOptions?.gap, 5) + const rowGap = clampNonnegativeNumber(options.rowGap, 8) const resolvedItems = resolveCategoricalLegendItems( context.colors, labelOptions?.format, @@ -457,7 +458,7 @@ function resolveCategoricalLegendPresentation( const layout = layoutCategoricalLegendFlow( items.map((item) => item.width), context.bounds.width, - finiteNonnegative(options.gap, 16), + clampNonnegativeNumber(options.gap, 16), justify, ) return { @@ -559,12 +560,6 @@ function resolveItemValue( : (input ?? fallback) } -function finiteNonnegative(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) - ? Math.max(0, value) - : fallback -} - function validTextMetrics(metrics: ChartTextMetrics) { return ( Number.isFinite(metrics.x) && diff --git a/packages/charts-core/src/mark-with-scale-values.ts b/packages/charts-core/src/mark-with-scale-values.ts index 17a7a488..75f5924a 100644 --- a/packages/charts-core/src/mark-with-scale-values.ts +++ b/packages/charts-core/src/mark-with-scale-values.ts @@ -6,7 +6,7 @@ import type { MarkInitialization, MarkInitializeContext, } from './types' -import { applyMarkRenderer, normalizeMarkInitialization } from './mark' +import { createMarkDefinition } from './mark' export type { ChartMarkPointX, @@ -42,19 +42,5 @@ export function createMarkWithScaleValues< TXScaleId, TYScaleId > { - const normalizedInitialize = (context: MarkInitializeContext) => { - const initialized = normalizeMarkInitialization(initialize(context)) - const withMotion = - motion === undefined || initialized.motion !== undefined - ? initialized - : { ...initialized, motion } - return renderer === undefined - ? withMotion - : applyMarkRenderer(withMotion, renderer) - } - return { - initialize: normalizedInitialize, - ...(motion === undefined ? {} : { motion }), - ...(renderer === undefined ? {} : { renderer }), - } + return createMarkDefinition(initialize, motion, renderer) } diff --git a/packages/charts-core/src/mark.ts b/packages/charts-core/src/mark.ts index d7b0e220..7a2da983 100644 --- a/packages/charts-core/src/mark.ts +++ b/packages/charts-core/src/mark.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from './number-internal' import { isChartKey, valueKey } from './scales' import type { Channel, @@ -19,7 +20,7 @@ declare const process: { env: { NODE_ENV?: string } } | undefined const warnedKeyFallbacks = new WeakSet() -export { isChartKey } +export { isChartKey, isFiniteNumber } export function isChartValue(value: unknown): value is ChartValue { return ( @@ -29,10 +30,6 @@ export function isChartValue(value: unknown): value is ChartValue { ) } -export function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - export function isNonnegativeFiniteNumber(value: unknown): value is number { return isFiniteNumber(value) && value >= 0 } @@ -50,6 +47,20 @@ export function createMark< motion?: ChartMotionDefinition, renderer?: ChartMarkRenderer, ): ChartMark { + return createMarkDefinition(initialize, motion, renderer) +} + +export function createMarkDefinition< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + initialize: ( + context: MarkInitializeContext, + ) => MarkInitialization, + motion?: ChartMotionDefinition, + renderer?: ChartMarkRenderer, +) { const normalizedInitialize = (context: MarkInitializeContext) => { const initialized = normalizeMarkInitialization(initialize(context)) const withMotion = diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 6fbe2586..0d677aed 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1,3 +1,4 @@ +import { clampNonnegativeNumber } from './number-internal' import { focusedNodeKeys, resolveFocusScene } from './focus-layer' import { resolveFocusGuides } from './focus-presentation' import { resolveMarkStateScene } from './mark-state' @@ -3625,7 +3626,7 @@ function resolveTiming( ? authored.delay(context) : authored.delay if (authoredDelay !== undefined) { - delay = nonNegative(authoredDelay, delay) + delay = clampNonnegativeNumber(authoredDelay, delay) } if (authored.path !== undefined) path = authored.path transition = resolveTransition( @@ -3935,7 +3936,7 @@ function resolveTransition( } return { type: 'tween', - duration: nonNegative( + duration: clampNonnegativeNumber( transition?.duration, fallback?.type === 'tween' ? fallback.duration : fallbackDuration, ), @@ -3996,10 +3997,6 @@ function numberAttribute(element: Element, name: string) { return Number.isFinite(value) ? value : 0 } -function nonNegative(value: number | undefined, fallback: number) { - return Number.isFinite(value) ? Math.max(0, value!) : fallback -} - function formatNumber(value: number) { return String(Math.round(value * 1_000) / 1_000) } diff --git a/packages/charts-core/src/number-internal.test.ts b/packages/charts-core/src/number-internal.test.ts new file mode 100644 index 00000000..ca1360eb --- /dev/null +++ b/packages/charts-core/src/number-internal.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + clampNonnegativeNumber, + isFiniteNumber, + finiteNumber, + finiteNonNegative, + finitePositive, +} from './number-internal' + +describe('numeric option policies', () => { + it('retains valid authored values', () => { + expect(isFiniteNumber(12)).toBe(true) + expect(finiteNumber(-12, 4)).toBe(-12) + expect(finitePositive(12, 4)).toBe(12) + expect(finiteNonNegative(0, 4)).toBe(0) + expect(clampNonnegativeNumber(12, 4)).toBe(12) + }) + + it('keeps rejection and clamping of negative values distinct', () => { + expect(finiteNonNegative(-12, 4)).toBe(4) + expect(clampNonnegativeNumber(-12, 4)).toBe(0) + expect(finitePositive(0, 4)).toBe(4) + expect(finiteNonNegative(-0, 4)).toBe(-0) + expect(clampNonnegativeNumber(-0, 4)).toBe(0) + }) + + it.each([undefined, Number.NaN, Infinity, -Infinity])( + 'uses the fallback for %s', + (value) => { + expect(isFiniteNumber(value)).toBe(false) + expect(finiteNumber(value, 4)).toBe(4) + expect(finitePositive(value, 4)).toBe(4) + expect(finiteNonNegative(value, 4)).toBe(4) + expect(clampNonnegativeNumber(value, 4)).toBe(4) + }, + ) + + it('does not coerce strings or boxed numbers', () => { + expect(isFiniteNumber('12')).toBe(false) + expect(isFiniteNumber(new Number(12))).toBe(false) + }) +}) diff --git a/packages/charts-core/src/number-internal.ts b/packages/charts-core/src/number-internal.ts new file mode 100644 index 00000000..7bcd5530 --- /dev/null +++ b/packages/charts-core/src/number-internal.ts @@ -0,0 +1,31 @@ +export function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function finiteNumber( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) ? value : fallback +} + +export function finitePositive( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) && value > 0 ? value : fallback +} + +export function finiteNonNegative( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) && value >= 0 ? value : fallback +} + +export function clampNonnegativeNumber( + value: number | undefined, + fallback = 0, +): number { + return isFiniteNumber(value) ? Math.max(0, value) : fallback +} diff --git a/packages/charts-core/src/polar-pie.ts b/packages/charts-core/src/polar-pie.ts index 2f4911cd..2c2b09c2 100644 --- a/packages/charts-core/src/polar-pie.ts +++ b/packages/charts-core/src/polar-pie.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from './number-internal' import type { TransformLineage, TransformOrderOptions, @@ -160,10 +161,6 @@ function assertPieGapCapacity( } } -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - function assertFinite(value: number, name: string): void { if (!Number.isFinite(value)) { throw new TypeError(`pie: ${name} must be finite`) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 41324c26..ac2ac508 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1,3 +1,4 @@ +import { finiteNonNegative, clampNonnegativeNumber } from './number-internal' import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { @@ -1269,12 +1270,13 @@ function resolveMarginLocks( margin: StaticChartDefinition['margin'], ): Partial { if (typeof margin === 'number') { - return uniformMargin(finiteMargin(margin)) + return uniformMargin(clampNonnegativeNumber(margin)) } if (!margin) return {} const locks: Partial = {} for (const side of marginSides) { - if (margin[side] !== undefined) locks[side] = finiteMargin(margin[side]) + if (margin[side] !== undefined) + locks[side] = clampNonnegativeNumber(margin[side]) } return locks } @@ -1301,19 +1303,6 @@ function marginsEqual(left: ChartMargin, right: ChartMargin): boolean { ) } -function finiteMargin(value: number | undefined): number { - return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0 -} - -function finiteNonNegative( - value: number | undefined, - fallback: number, -): number { - return value !== undefined && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - function uniformMargin(value: number): ChartMargin { return { top: value, right: value, bottom: value, left: value } } @@ -1512,10 +1501,10 @@ function createAxes( } const ticks = presentation?.ticks === false ? [] : guide.scale.ticks - const tickSize = finiteMargin( + const tickSize = clampNonnegativeNumber( presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), ) - const tickPadding = finiteMargin( + const tickPadding = clampNonnegativeNumber( presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), ) const tickLabels = tickLabelPresentation(presentation) @@ -1580,7 +1569,7 @@ function createAxes( x: horizontal ? chart.x + chart.width / 2 : axisPosition, y: horizontal ? explicitOffset - ? axisPosition + direction * Math.max(0, finiteMargin(labelOffset)) + ? axisPosition + direction * clampNonnegativeNumber(labelOffset) : tickOuter + direction * 8 : chart.y + chart.height / 2, text: labelText, @@ -1603,8 +1592,7 @@ function createAxes( if (!horizontal) { label.rotate = positive ? 90 : -90 if (explicitOffset) { - label.x = - axisPosition + direction * Math.max(0, finiteMargin(labelOffset)) + label.x = axisPosition + direction * clampNonnegativeNumber(labelOffset) } else { const localBounds = measureSceneLabelBounds( { ...label, x: 0, y: 0 }, @@ -1681,10 +1669,10 @@ function resolveTickCount( } if (configured.values) return Math.max(1, configured.values.length) if (configured.count !== undefined) { - return Math.max(1, Math.floor(finiteMargin(configured.count))) + return Math.max(1, Math.floor(clampNonnegativeNumber(configured.count))) } if (configured.spacing !== undefined) { - const spacing = Math.max(1, finiteMargin(configured.spacing)) + const spacing = Math.max(1, clampNonnegativeNumber(configured.spacing)) return Math.max(1, Math.floor(length / spacing)) } return Math.max(2, Math.min(maximum, Math.floor(length / defaultSpacing))) @@ -1847,7 +1835,7 @@ function thinTickLabels( ): TickLabelCandidate[] { if (options.thin === false || candidates.length < 2) return [...candidates] const thin = typeof options.thin === 'object' ? options.thin : {} - const minGap = Math.max(0, finiteMargin(thin.minGap ?? 4)) + const minGap = clampNonnegativeNumber(thin.minGap ?? 4) const selected: TickLabelCandidate[] = candidates.filter( (candidate) => candidate.hard, ) diff --git a/packages/charts-core/src/tooltip-model.ts b/packages/charts-core/src/tooltip-model.ts index 802c5e74..4950f918 100644 --- a/packages/charts-core/src/tooltip-model.ts +++ b/packages/charts-core/src/tooltip-model.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import type { ChartFocusState, ChartPoint, @@ -30,10 +31,10 @@ export function orderChartTooltipPoints< const first = points[0] const sharedX = first !== undefined && - points.every((point) => sameChartTooltipValue(point.xValue, first.xValue)) + points.every((point) => sameChartValue(point.xValue, first.xValue)) const sharedY = first !== undefined && - points.every((point) => sameChartTooltipValue(point.yValue, first.yValue)) + points.every((point) => sameChartValue(point.yValue, first.yValue)) return [...points].sort((left, right) => sharedY && !sharedX ? left.x - right.x || left.y - right.y @@ -181,14 +182,10 @@ function defaultTooltipContent( const group = findTooltipChannelItem(options?.items, 'group') const sharedX = points.length > 1 && - points.every((candidate) => - sameChartTooltipValue(candidate.xValue, point.xValue), - ) + points.every((candidate) => sameChartValue(candidate.xValue, point.xValue)) const sharedY = points.length > 1 && - points.every((candidate) => - sameChartTooltipValue(candidate.yValue, point.yValue), - ) + points.every((candidate) => sameChartValue(candidate.yValue, point.yValue)) if (sharedX || sharedY) { const axis = sharedX ? 'x' : 'y' @@ -359,7 +356,7 @@ function formatPointAxis( interval === 'range' && start !== undefined && end !== undefined && - !sameChartTooltipValue(start, end) + !sameChartValue(start, end) ) { return `${formatChartTooltipValue(start)}–${formatChartTooltipValue(end)}` } @@ -421,9 +418,3 @@ function colorOrder(scene: ChartScene, group: ChartPoint['group']) { const index = group == null ? -1 : scene.colors.domain.indexOf(group) return index < 0 ? Number.MAX_SAFE_INTEGER : index } - -function sameChartTooltipValue(left: ChartValue, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} diff --git a/packages/charts-core/src/value-equality-internal.test.ts b/packages/charts-core/src/value-equality-internal.test.ts new file mode 100644 index 00000000..da8d171b --- /dev/null +++ b/packages/charts-core/src/value-equality-internal.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { sameChartValue } from './value-equality-internal' + +describe('sameChartValue', () => { + it('compares primitives and dates by value', () => { + expect(sameChartValue(12, 12)).toBe(true) + expect(sameChartValue('September', 'September')).toBe(true) + expect(sameChartValue(new Date('2026-09-01'), new Date('2026-09-01'))).toBe( + true, + ) + expect(sameChartValue(new Date('2026-09-01'), new Date('2026-09-02'))).toBe( + false, + ) + }) + + it('preserves Object.is semantics and distinguishes dates from timestamps', () => { + expect(sameChartValue(Number.NaN, Number.NaN)).toBe(true) + expect(sameChartValue(-0, 0)).toBe(false) + expect(sameChartValue(new Date(0), 0)).toBe(false) + expect(sameChartValue(undefined, 0)).toBe(false) + expect(sameChartValue(new Date(Number.NaN), new Date(Number.NaN))).toBe( + false, + ) + }) +}) diff --git a/packages/charts-core/src/value-equality-internal.ts b/packages/charts-core/src/value-equality-internal.ts new file mode 100644 index 00000000..06db1d74 --- /dev/null +++ b/packages/charts-core/src/value-equality-internal.ts @@ -0,0 +1,10 @@ +import type { ChartValue } from './types' + +export function sameChartValue( + left: ChartValue | undefined, + right: ChartValue, +): boolean { + return left instanceof Date && right instanceof Date + ? left.getTime() === right.getTime() + : Object.is(left, right) +} From e380af83726f30f07e79f31537d7ac240a1913a1 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 18:16:13 +0200 Subject: [PATCH 08/10] Share guide node construction and traversal Use one orientation-aware tick-label and grid node shape, share physical anchor resolution, and walk guide labels and rules with one translation-aware traversal. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 176,471 -> 176,268 B; saves 203 B (0.12%). gzip: 59,063 -> 59,054 B; saves 9 B (0.02%). Brotli: 51,143 -> 51,128 B; saves 15 B (0.03%). Total savings from baseline: 9,151 B (4.94%) minified, 1,814 B (2.98%) gzip, and 957 B (1.84%) Brotli. Validation: scene and guide-layout tests, TypeScript, and identical normalized SVGs in the browser scenarios. --- packages/charts-core/src/guide-layout.ts | 36 ++------ packages/charts-core/src/scene.ts | 102 ++++++++++------------- 2 files changed, 51 insertions(+), 87 deletions(-) diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 23898be3..43d71a10 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -158,8 +158,8 @@ export function resolveGuideMargins( let bottom = inset let left = inset - visitLabels(axes, 0, 0, (label, translateX, translateY) => { - if (!label.text) return + visitGuideNodes(axes, 0, 0, (label, translateX, translateY) => { + if (label.kind !== 'label' || !label.text) return const bounds = measureSceneLabelBounds(label, measureText) const boundsLeft = bounds.x + translateX @@ -186,7 +186,9 @@ export function includeGuideStrokeMargins( guides: SceneGroup, plot: ChartBounds, ): void { - visitRules(guides, 0, 0, (rule, translateX, translateY) => { + visitGuideNodes(guides, 0, 0, (rule, translateX, translateY) => { + if (rule.kind !== 'rule') return + const style = rule.style const extendsGeometry = style?.strokeWidth !== undefined || @@ -219,37 +221,17 @@ export function includeGuideStrokeMargins( }) } -function visitLabels( - node: SceneNode, - translateX: number, - translateY: number, - visit: (label: SceneLabel, translateX: number, translateY: number) => void, -): void { - if (node.kind === 'label') { - visit(node, translateX, translateY) - return - } - - if (node.kind !== 'group') return - - const childTranslateX = translateX + (node.translateX ?? 0) - const childTranslateY = translateY + (node.translateY ?? 0) - for (const child of node.children) { - visitLabels(child, childTranslateX, childTranslateY, visit) - } -} - -function visitRules( +function visitGuideNodes( node: SceneNode, translateX: number, translateY: number, visit: ( - rule: Extract, + node: Extract, translateX: number, translateY: number, ) => void, ): void { - if (node.kind === 'rule') { + if (node.kind === 'label' || node.kind === 'rule') { visit(node, translateX, translateY) return } @@ -259,7 +241,7 @@ function visitRules( const childTranslateX = translateX + (node.translateX ?? 0) const childTranslateY = translateY + (node.translateY ?? 0) for (const child of node.children) { - visitRules(child, childTranslateX, childTranslateY, visit) + visitGuideNodes(child, childTranslateX, childTranslateY, visit) } } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index ac2ac508..b8ab3213 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1343,27 +1343,16 @@ function createGrid( const style = guideLineStyle(guide.options.grid) for (const tick of guide.scale.ticks) { const key = `${guide.id}-grid:${valueKey(tick.value)}` - children.push( - guide.channel === 'x' - ? { - kind: 'rule', - key, - x1: tick.position, - x2: tick.position, - y1: chart.y, - y2: chart.y + chart.height, - ...(style ? { style } : {}), - } - : { - kind: 'rule', - key, - x1: chart.x, - x2: chart.x + chart.width, - y1: tick.position, - y2: tick.position, - ...(style ? { style } : {}), - }, - ) + const horizontal = guide.channel === 'x' + children.push({ + kind: 'rule', + key, + x1: horizontal ? tick.position : chart.x, + x2: horizontal ? tick.position : chart.x + chart.width, + y1: horizontal ? chart.y : tick.position, + y2: horizontal ? chart.y + chart.height : tick.position, + style, + }) } } @@ -1760,52 +1749,45 @@ function createTickLabelCandidates( const dy = resolveTickLabelValue(options.dy, context) ?? 0 // Automatic anchors preserve a physical placement outside the plot. // Authored anchors remain logical SVG start/end values. - const automaticAnchor: NonNullable = + const anchorSide = guide.channel === 'y' ? positiveSide - ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') - : physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') + ? 'left' + : 'right' : (rotate ?? 0) < 0 - ? physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') + ? 'right' : (rotate ?? 0) > 0 - ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') + ? 'left' : 'middle' + const automaticAnchor = physicalTextAnchor( + anchorSide, + rightToLeft ? 'rtl' : 'ltr', + ) const anchor = resolveTickLabelValue(options.anchor, context) ?? automaticAnchor - const label: SceneLabel = - guide.channel === 'x' - ? { - kind: 'label', - key: `${guide.id}-tick-label:${valueKey(tick.value)}`, - x: tick.position + dx, - y: - axisPosition + direction * (size + padding + fontSize * 0.8) + dy, - text: tick.label, - anchor, - rotate, - fontSize, - fontWeight, - style: { - fill: theme.muted, - ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), - }, - } - : { - kind: 'label', - key: `${guide.id}-tick-label:${valueKey(tick.value)}`, - x: axisPosition + direction * (size + padding) + dx, - y: tick.position + dy, - text: tick.label, - anchor, - baseline: 'middle', - rotate, - fontSize, - fontWeight, - style: { - fill: theme.muted, - ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), - }, - } + const horizontal = guide.channel === 'x' + const label: SceneLabel = { + kind: 'label', + key: `${guide.id}-tick-label:${valueKey(tick.value)}`, + x: + (horizontal + ? tick.position + : axisPosition + direction * (size + padding)) + dx, + y: horizontal + ? axisPosition + direction * (size + padding + fontSize * 0.8) + dy + : tick.position + dy, + text: tick.label, + anchor, + baseline: horizontal ? undefined : 'middle', + rotate, + fontSize, + fontWeight, + style: { + fill: theme.muted, + ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), + }, + } + return { value: tick.value, label, From 92c6da359ae47a7d8d37652e365b3f4565cfa8bf Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 12 Sep 2026 18:18:56 +0200 Subject: [PATCH 09/10] Share hit-testing distance calculations Reuse squared axis distances for rectangular bounds and one radius-distance calculation for dots, strokes, and rounded rectangles. Remove a redundant segment-count clamp after empty and single-point paths have already returned. Generic Vite application, TanStack only, compared with the preceding commit: Minified: 176,268 -> 176,112 B; saves 156 B (0.09%). gzip: 59,054 -> 59,015 B; saves 39 B (0.07%). Brotli: 51,128 -> 51,044 B; saves 84 B (0.16%). Total savings from baseline: 9,307 B (5.02%) minified, 1,853 B (3.04%) gzip, and 1,041 B (2.00%) Brotli. Validation: nearest-point tests and unchanged browser SVG snapshots across updates, resizing, empty data, and reduced motion; keyboard tooltips pass. --- packages/charts-core/src/nearest.ts | 48 +++++++++++++---------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/charts-core/src/nearest.ts b/packages/charts-core/src/nearest.ts index f266cbe8..d86e9da7 100644 --- a/packages/charts-core/src/nearest.ts +++ b/packages/charts-core/src/nearest.ts @@ -352,11 +352,10 @@ function distanceToTarget( case 'dot': { const dx = localX - node.x const dy = localY - node.y - const amount = Math.max( - 0, - Math.sqrt(dx * dx + dy * dy) - Math.max(0, node.radius), + distance = squaredDistanceOutsideRadius( + dx * dx + dy * dy, + Math.max(0, node.radius), ) - distance = amount * amount break } case 'area': @@ -367,8 +366,7 @@ function distanceToTarget( break case 'polyline': { const raw = squaredDistanceToPolyline(node.points, localX, localY, false) - const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node)) - distance = amount * amount + distance = squaredDistanceOutsideRadius(raw, strokeRadius(node)) break } case 'rule': { @@ -380,8 +378,7 @@ function distanceToTarget( localX, localY, ) - const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node)) - distance = amount * amount + distance = squaredDistanceOutsideRadius(raw, strokeRadius(node)) break } } @@ -464,9 +461,11 @@ function squaredDistanceToRoundedRect( const radius = Math.max(0, Math.min(node.radius ?? 0, halfWidth, halfHeight)) const offsetX = Math.abs(x - (bounds.x + halfWidth)) - (halfWidth - radius) const offsetY = Math.abs(y - (bounds.y + halfHeight)) - (halfHeight - radius) - const outside = - Math.sqrt(Math.max(0, offsetX) ** 2 + Math.max(0, offsetY) ** 2) - radius - return Math.max(0, outside) ** 2 + + return squaredDistanceOutsideRadius( + Math.max(0, offsetX) ** 2 + Math.max(0, offsetY) ** 2, + radius, + ) } function containsPolygon( @@ -531,7 +530,7 @@ function squaredDistanceToPolyline( return (point[0] - x) ** 2 + (point[1] - y) ** 2 } let distance = Infinity - const segmentCount = closed ? points.length : Math.max(0, points.length - 1) + const segmentCount = closed ? points.length : points.length - 1 for (let index = 0; index < segmentCount; index += 1) { const start = points[index]! const end = points[(index + 1) % points.length]! @@ -639,7 +638,7 @@ function squaredAxisDistance( value: number, axis: 'x' | 'y', ) { - const start = axis === 'x' ? bounds.x : bounds.y + const start = bounds[axis] const size = axis === 'x' ? bounds.width : bounds.height const distance = value < start @@ -652,19 +651,16 @@ function squaredAxisDistance( function squaredDistanceToBounds(bounds: ChartBounds, x: number, y: number) { const normalized = normalizeRect(bounds) - const dx = - x < normalized.x - ? normalized.x - x - : x > normalized.x + normalized.width - ? x - normalized.x - normalized.width - : 0 - const dy = - y < normalized.y - ? normalized.y - y - : y > normalized.y + normalized.height - ? y - normalized.y - normalized.height - : 0 - return dx * dx + dy * dy + return ( + squaredAxisDistance(normalized, x, 'x') + + squaredAxisDistance(normalized, y, 'y') + ) +} + +function squaredDistanceOutsideRadius(distance: number, radius: number) { + const amount = Math.max(0, Math.sqrt(distance) - radius) + + return amount * amount } function strokeRadius(node: GeometricSceneNode) { From aa7a5e1af2beb182123800e04234933bc5e4a91f Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sun, 13 Sep 2026 15:08:24 +0200 Subject: [PATCH 10/10] Record bundle savings and refresh validation baselines Refresh universal and comparison measurements, source provenance, generated documentation and catalog previews, and add a patch changeset. Classify the extracted numeric, stack-order, and reconciliation helpers in the existing dependency-boundary checks. Calibrate isolated gzip ceilings for measured helper-sharing overhead: stack transforms +10 B, treemap +30 B, and the scale-value mark factory +8 B against main. Every locked universal entry is smaller in both minified and gzip bytes. Regenerated tooltip preview differences are weekday labels from the existing local-time formatter; geometry is unchanged. No additional TanStack runtime changes in this commit. Total savings from baseline: 9,307 B (5.02%) minified, 1,853 B (3.04%) gzip, and 1,041 B (2.00%) Brotli. Validation: tests, types, packed consumers, documentation, catalog, formatting, and bundle checks pass. Browser scenarios retain identical normalized SVGs and visually matching screenshots. The generic Vite TanStack chunk is byte-identical after restoring the original helper names, and all regenerated catalog SVGs match the previous output. --- .changeset/shared-chart-runtime.md | 7 + .../bundle-size/universal-baseline.json | 40 +++--- benchmarks/comparison/bundle-baseline.json | 128 +++++++++--------- .../previews/132-shadcn-tooltip-advanced.svg | 2 +- .../previews/190-shadcn-tooltip-default.svg | 2 +- .../previews/191-shadcn-tooltip-formatter.svg | 2 +- .../previews/192-shadcn-tooltip-icons.svg | 2 +- .../193-shadcn-tooltip-indicator-line.svg | 2 +- .../194-shadcn-tooltip-indicator-none.svg | 2 +- .../195-shadcn-tooltip-label-custom.svg | 2 +- .../196-shadcn-tooltip-label-formatter.svg | 2 +- .../197-shadcn-tooltip-label-none.svg | 2 +- benchmarks/conformance/previews/manifest.json | 20 +-- docs/comparison.md | 8 +- packages/charts-core/docs/comparison.md | 8 +- scripts/measure-bundles.mjs | 19 ++- 16 files changed, 130 insertions(+), 118 deletions(-) create mode 100644 .changeset/shared-chart-runtime.md diff --git a/.changeset/shared-chart-runtime.md b/.changeset/shared-chart-runtime.md new file mode 100644 index 00000000..76844a74 --- /dev/null +++ b/.changeset/shared-chart-runtime.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': patch +--- + +Reduce chart bundles by sharing Cartesian guide rendering, mark initialization, +stack ordering, motion tracks, SVG reconciliation, and hit-testing calculations. +Keep the motion renderer independent of the unused standalone tween engine. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index f0ebd0b4..0ceca201 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 53412, - "gzip": 19894 + "bytes": 52086, + "gzip": 19825 }, "D3-scale line + static SVG": { - "bytes": 59566, - "gzip": 22210 + "bytes": 58248, + "gzip": 22132 }, "Representative marks": { - "bytes": 83306, - "gzip": 30340 + "bytes": 80993, + "gzip": 29994 }, "TanStack DOM host": { - "bytes": 81870, - "gzip": 28574 + "bytes": 80633, + "gzip": 28522 }, "React adapter": { - "bytes": 84195, - "gzip": 29518 + "bytes": 82959, + "gzip": 29492 }, "React line consumer": { - "bytes": 107854, - "gzip": 38849 + "bytes": 106656, + "gzip": 38799 }, "Compact-scale line scene": { - "bytes": 35865, - "gzip": 12732 + "bytes": 34536, + "gzip": 12669 }, "React compact-scale line consumer": { - "bytes": 90354, - "gzip": 31746 + "bytes": 89156, + "gzip": 31707 }, "Custom-scale line scene": { - "bytes": 34047, - "gzip": 11995 + "bytes": 32718, + "gzip": 11947 }, "D3 linear-scale line scene": { - "bytes": 53344, - "gzip": 19856 + "bytes": 52018, + "gzip": 19787 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index e85dd9fd..7c2c2a2b 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-10T04:50:31.051Z", + "generatedAt": "2026-09-13T19:48:59.290Z", "packageVersions": { - "tanstack": "0.17.0", + "tanstack": "0.18.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "8bab934c07762e335108b12687e3587cddc1288e", - "inputDigest": "sha256:73e433a9402a39e35a475fac52af5655016beae0c80c3a9950e47425ef4cd965" + "revision": "92c6da359ae47a7d8d37652e365b3f4565cfa8bf", + "inputDigest": "sha256:ae0e348223fa04b2381b59ec6e8a22f8401b740e71246accd7d2cb0273644f87" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117941, - "gzipBytes": 42555, - "brotliBytes": 37623, - "incrementalGzipBytes": 42555, - "incrementalBrotliBytes": 37623 + "minifiedBytes": 116641, + "gzipBytes": 42471, + "brotliBytes": 37657, + "incrementalGzipBytes": 42471, + "incrementalBrotliBytes": 37657 }, "tanstack-line-interactive": { - "minifiedBytes": 123686, - "gzipBytes": 44460, - "brotliBytes": 39218, - "incrementalGzipBytes": 44460, - "incrementalBrotliBytes": 39218 + "minifiedBytes": 122388, + "gzipBytes": 44372, + "brotliBytes": 39156, + "incrementalGzipBytes": 44372, + "incrementalBrotliBytes": 39156 }, "tanstack-line-advanced": { - "minifiedBytes": 130878, - "gzipBytes": 46798, - "brotliBytes": 41196, - "incrementalGzipBytes": 46798, - "incrementalBrotliBytes": 41196 + "minifiedBytes": 129583, + "gzipBytes": 46712, + "brotliBytes": 41172, + "incrementalGzipBytes": 46712, + "incrementalBrotliBytes": 41172 }, "tanstack-bar-basic": { - "minifiedBytes": 130379, - "gzipBytes": 47284, - "brotliBytes": 41635, - "incrementalGzipBytes": 47284, - "incrementalBrotliBytes": 41635 + "minifiedBytes": 128254, + "gzipBytes": 46980, + "brotliBytes": 41354, + "incrementalGzipBytes": 46980, + "incrementalBrotliBytes": 41354 }, "tanstack-bar-interactive": { - "minifiedBytes": 134983, - "gzipBytes": 48679, - "brotliBytes": 42750, - "incrementalGzipBytes": 48679, - "incrementalBrotliBytes": 42750 + "minifiedBytes": 132860, + "gzipBytes": 48380, + "brotliBytes": 42606, + "incrementalGzipBytes": 48380, + "incrementalBrotliBytes": 42606 }, "tanstack-bar-advanced": { - "minifiedBytes": 135322, - "gzipBytes": 48828, - "brotliBytes": 42851, - "incrementalGzipBytes": 48828, - "incrementalBrotliBytes": 42851 + "minifiedBytes": 133199, + "gzipBytes": 48530, + "brotliBytes": 42692, + "incrementalGzipBytes": 48530, + "incrementalBrotliBytes": 42692 }, "tanstack-area-basic": { - "minifiedBytes": 123121, - "gzipBytes": 44596, - "brotliBytes": 39388, - "incrementalGzipBytes": 44596, - "incrementalBrotliBytes": 39388 + "minifiedBytes": 121643, + "gzipBytes": 44498, + "brotliBytes": 39323, + "incrementalGzipBytes": 44498, + "incrementalBrotliBytes": 39323 }, "tanstack-area-interactive": { - "minifiedBytes": 128876, - "gzipBytes": 46452, - "brotliBytes": 40950, - "incrementalGzipBytes": 46452, - "incrementalBrotliBytes": 40950 + "minifiedBytes": 127398, + "gzipBytes": 46335, + "brotliBytes": 40885, + "incrementalGzipBytes": 46335, + "incrementalBrotliBytes": 40885 }, "tanstack-area-advanced": { - "minifiedBytes": 136250, - "gzipBytes": 48807, - "brotliBytes": 43047, - "incrementalGzipBytes": 48807, - "incrementalBrotliBytes": 43047 + "minifiedBytes": 134774, + "gzipBytes": 48745, + "brotliBytes": 43041, + "incrementalGzipBytes": 48745, + "incrementalBrotliBytes": 43041 }, "tanstack-scatter-basic": { - "minifiedBytes": 118947, - "gzipBytes": 42908, - "brotliBytes": 37974, - "incrementalGzipBytes": 42908, - "incrementalBrotliBytes": 37974 + "minifiedBytes": 117647, + "gzipBytes": 42827, + "brotliBytes": 37990, + "incrementalGzipBytes": 42827, + "incrementalBrotliBytes": 37990 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124696, - "gzipBytes": 44826, - "brotliBytes": 39490, - "incrementalGzipBytes": 44826, - "incrementalBrotliBytes": 39490 + "minifiedBytes": 123398, + "gzipBytes": 44743, + "brotliBytes": 39476, + "incrementalGzipBytes": 44743, + "incrementalBrotliBytes": 39476 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124712, - "gzipBytes": 44832, - "brotliBytes": 39527, - "incrementalGzipBytes": 44832, - "incrementalBrotliBytes": 39527 + "minifiedBytes": 123414, + "gzipBytes": 44750, + "brotliBytes": 39470, + "incrementalGzipBytes": 44750, + "incrementalBrotliBytes": 39470 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg b/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg index e45a795f..a74d4320 100644 --- a/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg +++ b/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg b/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg index a536d1d6..f3565020 100644 --- a/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg +++ b/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg b/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg index 3f91ac67..231b2330 100644 --- a/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg +++ b/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg b/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg index 92998466..a3f74064 100644 --- a/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg +++ b/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg b/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg index f3fa5dcf..ea3ce186 100644 --- a/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg +++ b/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg b/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg index 24bc2585..4f2e897e 100644 --- a/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg +++ b/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg b/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg index 7d9f4e08..ab1a5767 100644 --- a/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg +++ b/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg b/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg index 003322dd..64f12719 100644 --- a/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg +++ b/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg b/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg index 941b2616..b415f939 100644 --- a/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg +++ b/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index f18c50d3..42c69dea 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "c5e393081d8428d13d2efdeca32ad1e7fc56062b610b5c014d29c28af02bbde9", + "sourceHash": "effdd579df63e4be803a324a318135326e4dccea63385502de6ec945eef94a60", "assets": [ { "id": "01-line-gaps", @@ -616,7 +616,7 @@ }, { "id": "132-shadcn-tooltip-advanced", - "sha256": "97738ec4dd4be6c3e40c7b3bae1fa519e809c5f71f451384d2be9b84ef4be79a", + "sha256": "8762da7358bcfa728e206a9c906cf596f298f483d39f8e01eeb086c1ffc991e4", "bytes": 5019 }, { @@ -906,42 +906,42 @@ }, { "id": "190-shadcn-tooltip-default", - "sha256": "fe48eaec0c292c1338d653953946483982ef1b23ec356478b24bbf11730fd3f9", + "sha256": "51bd9df24b4f9e1b9e1978c870d901dd7c7e861aba5530e786bddfba070247fc", "bytes": 5018 }, { "id": "191-shadcn-tooltip-formatter", - "sha256": "43eb6270aa388f3a3004cec62e53f0fbdb73ff5ddc5c4190649ff64d61730240", + "sha256": "b7e374ccd584b066a7501419d7f021a6397911f90dfc498ac2dc5f79e8b94beb", "bytes": 5020 }, { "id": "192-shadcn-tooltip-icons", - "sha256": "bda712a532d9455b3409e7dd4280e1e9262a1bc805fe6b7ffcc237de9693e39e", + "sha256": "2f61168ff6f8a22b04fdea877adfe9c4e2dfbd2c16fc4df734a0d508fac9b959", "bytes": 5016 }, { "id": "193-shadcn-tooltip-indicator-line", - "sha256": "a46a207d4db94529ad698128a0f348300212593c91517fbeb05a4a6e9de07888", + "sha256": "4be629683b7b8688e318e27e769e5da19732f1a7b58cbdfdab9a33b63f31fb3e", "bytes": 5025 }, { "id": "194-shadcn-tooltip-indicator-none", - "sha256": "ac6fe965bea2a6588cc0597a595fa8624c7f6cac3b38a20201ae8807f0ba4514", + "sha256": "b5606048801c5fc115081a953430c46228ebb6f43dfe79a18891d2fe3f8283fd", "bytes": 5023 }, { "id": "195-shadcn-tooltip-label-custom", - "sha256": "b09982f65a0481e1c2efdee73d3132058594acdc9786d8c1f9ad877816752536", + "sha256": "55ab8ea80d10636234d51d03172a5b32fff6541a51b02ff800f939b346131e6f", "bytes": 5023 }, { "id": "196-shadcn-tooltip-label-formatter", - "sha256": "b18e7ed66963b22aa33bc543ab08d119be452cf7e6f77d485f8756694dd16f2f", + "sha256": "26bcd0b7899a2545c8a3e0bc46f027836d3ef59a6f99e7fe4d36635897c9f951", "bytes": 5026 }, { "id": "197-shadcn-tooltip-label-none", - "sha256": "af6631dd0b4e88a936d9cff1ae28a90b549ef6c2d72513b90e9dadb80d4f437a", + "sha256": "4968486648c607560e8b47274b3fcd628134c2f9453a0d09d9362861e0d011bf", "bytes": 5019 } ] diff --git a/docs/comparison.md b/docs/comparison.md index ea1cb923..015b3f67 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 `8bab934` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92c6da3` | | [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 `8bab934`. +at page render time. The measured TanStack workspace revision is `92c6da3`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-10`. +Baseline date: `2026-09-13`. 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.48–47.60 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 ea1cb923..015b3f67 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 `8bab934` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92c6da3` | | [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 `8bab934`. +at page render time. The measured TanStack workspace revision is `92c6da3`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-10`. +Baseline date: `2026-09-13`. 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.48–47.60 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/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..48b194c8 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -23,6 +23,7 @@ const rendererBoundaryModules = { ], svg: [ 'packages/charts-core/src/reconcile.ts', + 'packages/charts-core/src/reconcile-internal.ts', 'packages/charts-core/src/svg-focus-guide-layer.ts', 'packages/charts-core/src/svg-focus-guide-serializer.ts', 'packages/charts-core/src/svg-renderer.ts', @@ -45,6 +46,7 @@ const rendererBoundaryModules = { 'packages/charts-core/src/dom-text.ts', 'packages/charts-core/src/export.ts', 'packages/charts-core/src/reconcile.ts', + 'packages/charts-core/src/reconcile-internal.ts', 'packages/charts-core/src/renderer.ts', 'packages/charts-core/src/svg-focus-guide-layer.ts', 'packages/charts-core/src/svg-focus-guide-serializer.ts', @@ -56,6 +58,7 @@ const rendererBoundaryModules = { ], } const retainedInputGroups = { + numericValues: [/(?:^|\/)packages\/charts-core\/src\/number-internal\.ts$/u], crosshairRuntime: [ /(?:^|\/)packages\/charts-core\/src\/crosshair(?:-resolver)?\.ts$/u, ], @@ -64,7 +67,7 @@ const retainedInputGroups = { /(?:^|\/)packages\/charts-core\/src\/focus-layer\.ts$/u, ], platformRendererRuntime: [ - /(?:^|\/)packages\/charts-core\/src\/(?:adapter(?:-renderer)?|canvas|dom(?:-text)?|export|reconcile|renderer|svg(?:-focus-guide-(?:layer|serializer)|-renderer|-resources|-surface)?)\.ts$/u, + /(?:^|\/)packages\/charts-core\/src\/(?:adapter(?:-renderer)?|canvas|dom(?:-text)?|export|reconcile(?:-internal)?|renderer|svg(?:-focus-guide-(?:layer|serializer)|-renderer|-resources|-surface)?)\.ts$/u, /(?:^|\/)packages\/(?:react-charts|react-native-charts)\/src\//u, ], compactLinear: [/(?:^|\/)packages\/charts-scales\/src\/linear\.ts$/u], @@ -220,7 +223,9 @@ const retainedInputGroups = { facetMark: [/(?:^|\/)packages\/charts-core\/src\/facet\.ts$/u], areaYMark: [/(?:^|\/)packages\/charts-core\/src\/area\.ts$/u], areaXMark: [/(?:^|\/)packages\/charts-core\/src\/area-x\.ts$/u], - stackInternal: [/(?:^|\/)packages\/charts-core\/src\/stack-internal\.ts$/u], + stackInternal: [ + /(?:^|\/)packages\/charts-core\/src\/stack(?:-order)?-internal\.ts$/u, + ], transformStatistics: [ /(?:^|\/)packages\/charts-core\/src\/transform-statistics-internal\.ts$/u, ], @@ -265,7 +270,7 @@ const retainedInputGroups = { ], polarPie: [/(?:^|\/)packages\/charts-core\/src\/polar-pie\.ts$/u], markInfrastructure: [ - /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|materialized-channel-internal|scales)\.ts$/u, + /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|materialized-channel-internal|number-internal|scales)\.ts$/u, ], rectMark: [/(?:^|\/)packages\/charts-core\/src\/rect\.ts$/u], rectRadiusState: [ @@ -459,7 +464,7 @@ const entries = [ budgeted( 'Transform: stack', 'benchmarks/entries/charts-transform-stack.ts', - 2.68, + 2.69, { inputBoundary: granularTransformBoundary('transformStack', { allowD3Shape: true, @@ -557,7 +562,7 @@ const entries = [ 'Hierarchy treemap mark', 'benchmarks/entries/charts-hierarchy-treemap.ts', 'D3 hierarchy treemap kernel', - 3.86, + 3.89, { inputBoundary: { require: ['hierarchyFlat', 'hierarchyTreemap', 'd3Hierarchy'], @@ -1076,7 +1081,7 @@ const entries = [ budgeted( 'Custom mark scale-value factory', 'benchmarks/entries/charts-mark-scale-values.ts', - 0.39, + 0.4, ), measured( 'Crosshair mark extension', @@ -1431,7 +1436,7 @@ const entries = [ 'd3GeometryRuntime', ], addedFrom: 'Categorical legend', - allowAdded: [], + allowAdded: ['numericValues'], }, }, ),