From b06b0f117763f706978a9b69a01ccb4b32a2fcc2 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:25:50 -1000 Subject: [PATCH 01/13] feat: add renderer-neutral pattern resources Declare repeating tiles in a chart's patterns list and reference them from fill or stroke as url(#id). SVG emits scoped userSpaceOnUse patterns, Canvas rasterizes the tile at device resolution and repeats it with the same rotation, and React Native emits react-native-svg Pattern. linePattern() and dotPattern() at @tanstack/charts/pattern build hatch and dot tiles with CSS-variable paint. --- .changeset/add-pattern-resources.md | 10 ++ packages/charts-core/package.json | 5 + packages/charts-core/src/canvas.test.ts | 124 ++++++++++++++++ packages/charts-core/src/canvas.ts | 114 +++++++++++++-- packages/charts-core/src/export.test.ts | 52 +++++++ packages/charts-core/src/exports.test.ts | 11 ++ packages/charts-core/src/index.ts | 7 + packages/charts-core/src/pattern.ts | 100 +++++++++++++ packages/charts-core/src/scene.ts | 1 + packages/charts-core/src/svg-renderer.test.ts | 55 +++++++ packages/charts-core/src/svg.ts | 24 +++- packages/charts-core/src/types.ts | 19 +++ packages/charts-core/src/universal-types.ts | 6 + packages/charts-core/src/universal.ts | 1 + packages/charts-core/src/view.ts | 7 +- .../react-native-charts/src/SvgScene.test.tsx | 46 ++++++ packages/react-native-charts/src/SvgScene.tsx | 59 ++++++-- scripts/check-canvas-renderer.mjs | 135 +++++++++++++++++- 18 files changed, 743 insertions(+), 33 deletions(-) create mode 100644 .changeset/add-pattern-resources.md create mode 100644 packages/charts-core/src/pattern.ts diff --git a/.changeset/add-pattern-resources.md b/.changeset/add-pattern-resources.md new file mode 100644 index 00000000..6d719ccc --- /dev/null +++ b/.changeset/add-pattern-resources.md @@ -0,0 +1,10 @@ +--- +'@tanstack/charts': minor +'@tanstack/react-native-charts': minor +--- + +Add renderer-neutral pattern resources. Declare repeating tiles in a chart's +`patterns` list and reference them from `fill` or `stroke` as `url(#id)`. +`linePattern()` and `dotPattern()` build hatch and dot tiles whose paint +accepts CSS variables. SVG, Canvas, standalone export, and React Native draw +the same tile, with resource IDs scoped by `idPrefix`. diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index f93100f9..10f18b15 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -163,6 +163,7 @@ "./mark/composite": "./src/mark-composite.ts", "./mark/decorative": "./src/mark-decorative.ts", "./mark/scale-values": "./src/mark-with-scale-values.ts", + "./pattern": "./src/pattern.ts", "./polar": "./src/polar.ts", "./preact": "./src/preact.ts", "./react": "./src/react.ts", @@ -478,6 +479,10 @@ "types": "./dist/mark-with-scale-values.d.ts", "import": "./dist/mark-with-scale-values.js" }, + "./pattern": { + "types": "./dist/pattern.d.ts", + "import": "./dist/pattern.js" + }, "./polar": { "types": "./dist/polar.d.ts", "import": "./dist/polar.js" diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index e2591934..2d805fe7 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -20,6 +20,7 @@ import { handleX, type HandleXChange } from './interaction-handle' import { zoomX, type ZoomXChange, type ZoomXWindow } from './interaction-zoom' import { interactiveColorLegend } from './interactive-legend' import { dot } from './dot' +import { linePattern } from './pattern' import { facet } from './facet' import { lineX, lineY } from './line' import { mountChart } from './dom' @@ -49,6 +50,7 @@ interface FakeCanvasContext { fillStyles: Array globalAlphas: number[] arcTos: Array<[number, number, number, number, number]> + patterns: Array<{ source: CanvasImageSource; transforms: DOMMatrix2DInit[] }> context: CanvasRenderingContext2D } @@ -1039,6 +1041,111 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints pattern tiles at device resolution with the SVG rotation', () => { + const readStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => + element instanceof HTMLSpanElement && + element.style.color === 'var(--hatch)' + ? ({ color: 'rgb(1, 2, 3)' } as CSSStyleDeclaration) + : readStyle(element), + ) + const container = document.createElement('div') + const surface = createCanvasChartRenderer({ pixelRatio: 2 }).mount( + container, + () => {}, + ) + const patternScene: ChartScene = { + ...scene([ + { + kind: 'rect', + key: 'hatched', + x: 10, + y: 20, + width: 40, + height: 20, + style: { fill: 'url(#hatch)', stroke: 'url(#hatch)' }, + }, + { + kind: 'label', + key: 'hatched-label', + x: 30, + y: 40, + text: 'Hatched', + style: { fill: 'url(#hatch)' }, + }, + { + kind: 'rect', + key: 'circular', + x: 60, + y: 0, + width: 10, + height: 10, + style: { fill: 'url(#loop)' }, + }, + ]), + patterns: [ + linePattern({ + id: 'hatch', + color: 'var(--hatch)', + background: 'rgb(255, 255, 255)', + spacing: 5, + strokeWidth: 2, + angle: 90, + }), + { + id: 'loop', + width: 4, + height: 4, + nodes: [ + { + kind: 'rect', + key: 'loop-cell', + x: 0, + y: 0, + width: 4, + height: 4, + style: { fill: 'url(#loop)' }, + }, + ], + }, + ], + } + surface.render(patternScene, renderOptions()) + + const painted = contexts.get(surface.sceneCanvas) + if (!painted) throw new Error('Expected a painted scene canvas') + const [record, circular] = painted.patterns + if (!record) throw new Error('Expected one cached pattern tile') + expect(painted.patterns).toHaveLength(2) + expect(circular?.transforms).toHaveLength(1) + const tile = record.source as HTMLCanvasElement + expect([tile.width, tile.height]).toEqual([10, 10]) + const tileOperations = contexts.get(tile)?.operations + expect(tileOperations?.[0]).toBe('scale:2,2') + expect(tileOperations).toEqual( + expect.arrayContaining([ + 'rect:0,0,5,5', + 'fill:rgb(255, 255, 255):1', + 'rect:0,1.5,5,2', + 'fill:rgb(1, 2, 3):1', + ]), + ) + expect(tileOperations?.indexOf('fill:rgb(255, 255, 255):1')).toBeLessThan( + tileOperations?.indexOf('fill:rgb(1, 2, 3):1') ?? -1, + ) + expect(painted.operations).toContain('fill:pattern:0:1') + expect(painted.operations).toContain('stroke:pattern:0:1:1') + expect(painted.operations).toContain('fillText:Hatched,0,0') + const [rectFill, , labelFill] = record.transforms + expect(rectFill?.a).toBeCloseTo(0) + expect(rectFill?.b).toBeCloseTo(-0.5) + expect(rectFill?.c).toBeCloseTo(0.5) + expect(rectFill?.d).toBeCloseTo(0) + expect(Math.abs(rectFill?.e ?? 1) + Math.abs(rectFill?.f ?? 1)).toBe(0) + expect([labelFill?.e, labelFill?.f]).toEqual([-30, -40]) + surface.destroy() + }) + it('normalizes legacy linear coordinates and decreasing stops', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -3208,6 +3315,7 @@ function fakeContext(): FakeCanvasContext { const fillStyles: Array = [] const globalAlphas: number[] = [] const arcTos: Array<[number, number, number, number, number]> = [] + const patterns: FakeCanvasContext['patterns'] = [] let fillStyle: string | CanvasGradient = '#000000' let strokeStyle: string | CanvasGradient = '#000000' let lineWidth = 1 @@ -3246,6 +3354,21 @@ function fakeContext(): FakeCanvasContext { transform: (...values: number[]) => operations.push(`transform:${values.join(',')}`), rotate: (value: number) => operations.push(`rotate:${value}`), + scale: (...values: number[]) => + operations.push(`scale:${values.join(',')}`), + getTransform: () => ({ a: 2, b: 0, c: 0, d: 2, e: 0, f: 0 }), + createPattern: (source: CanvasImageSource) => { + const record: FakeCanvasContext['patterns'][number] = { + source, + transforms: [], + } + patterns.push(record) + return { + setTransform: (transform: DOMMatrix2DInit) => + record.transforms.push(transform), + toString: () => `pattern:${patterns.indexOf(record)}`, + } as unknown as CanvasPattern + }, clip: (...values: unknown[]) => { operations.push('clip') const [pathOrRule, fillRule] = values @@ -3369,6 +3492,7 @@ function fakeContext(): FakeCanvasContext { fillStyles, globalAlphas, arcTos, + patterns, context, } } diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts index a6a36678..c29200c7 100644 --- a/packages/charts-core/src/canvas.ts +++ b/packages/charts-core/src/canvas.ts @@ -19,6 +19,7 @@ import type { ChartAnimationOptions, ChartBounds, ChartMarkStateTransition, + ChartPattern, ChartPoint, ChartRadialGradient, ChartRuntime, @@ -135,8 +136,12 @@ interface ScenePainter { scene: ChartScene Path: typeof Path2D | undefined font: FontState + document: Document + patterns: Map } +type CanvasPaint = string | CanvasGradient | CanvasPattern + interface RadialFillPaint { gradient: ChartRadialGradient bounds: ChartBounds @@ -1174,10 +1179,28 @@ function paintScene( resolver: CanvasPaintResolver, root: HTMLDivElement, ): void { - const Path = root.ownerDocument.defaultView?.Path2D - const font = readFont(root) - const painter: ScenePainter = { context, resolver, scene, Path, font } - paintNodes(painter, scene.nodes, defaultPaint) + paintNodes( + createPainter(context, resolver, scene, root), + scene.nodes, + defaultPaint, + ) +} + +function createPainter( + context: CanvasRenderingContext2D, + resolver: CanvasPaintResolver, + scene: ChartScene, + root: HTMLDivElement, +): ScenePainter { + return { + context, + resolver, + scene, + Path: root.ownerDocument.defaultView?.Path2D, + font: readFont(root), + document: root.ownerDocument, + patterns: new Map(), + } } function paintFocusCanvas( @@ -1192,9 +1215,7 @@ function paintFocusCanvas( resetContext(context, pixelRatio) context.clearRect(0, 0, scene.width, scene.height) if (!nodes.length) return - const Path = root.ownerDocument.defaultView?.Path2D - const font = readFont(root) - paintNodes({ context, resolver, scene, Path, font }, nodes, defaultPaint) + paintNodes(createPainter(context, resolver, scene, root), nodes, defaultPaint) } function paintNodes( @@ -1551,7 +1572,7 @@ function strokeCurrentPath( function configureStroke( context: CanvasRenderingContext2D, state: PaintState, - stroke: string | CanvasGradient, + stroke: CanvasPaint, ): void { context.globalAlpha = state.opacity * state.strokeOpacity context.strokeStyle = stroke @@ -1565,10 +1586,11 @@ function resolveStrokePaint( painter: ScenePainter, state: PaintState, bounds: ChartBounds | null, + origin?: readonly [number, number], ) { if (!Number.isFinite(state.strokeWidth) || state.strokeWidth <= 0) return null rejectRadialStroke(painter, state.stroke) - return resolvePaint(painter, state.stroke, bounds) + return resolvePaint(painter, state.stroke, bounds, origin) } function paintLabel( @@ -1602,13 +1624,15 @@ function paintLabel( : node.baseline === 'hanging' ? 'hanging' : 'alphabetic' - const fill = resolvePaint(painter, state.fill, null) + // SVG text keeps its user-space origin at the scene origin. + const origin = [node.x, node.y] as const + const fill = resolvePaint(painter, state.fill, null, origin) if (fill) { context.globalAlpha = state.opacity * state.fillOpacity context.fillStyle = fill context.fillText(node.text, 0, 0) } - const stroke = resolveStrokePaint(painter, state, null) + const stroke = resolveStrokePaint(painter, state, null, origin) if (stroke) { configureStroke(context, state, stroke) context.strokeText(node.text, 0, 0) @@ -1647,10 +1671,15 @@ function resolvePaint( painter: ScenePainter, value: string | null, bounds: ChartBounds | null, -): string | CanvasGradient | null { + origin?: readonly [number, number], +): CanvasPaint | null { if (!value) return null const match = /^url\(#([^)]+)\)$/.exec(value) if (!match) return painter.resolver.resolve(value) + const pattern = painter.scene.patterns?.find( + (candidate) => candidate.id === match[1], + ) + if (pattern) return resolvePattern(painter, pattern, origin) const gradient = painter.scene.gradients.find( (candidate) => candidate.id === match[1], ) @@ -1682,6 +1711,67 @@ function resolvePaint( return canvasGradient } +/** + * Rasterize one unrotated tile at device resolution, then map it back into + * user space with the same rotation SVG applies through `patternTransform`. + */ +function resolvePattern( + painter: ScenePainter, + pattern: ChartPattern, + origin: readonly [number, number] = [0, 0], +): CanvasPattern | null { + // SVG serializes tile geometry in hundredths; match it. + const width = hundredths(pattern.width) + const height = hundredths(pattern.height) + if (!(width > 0 && height > 0 && Number.isFinite(width * height))) { + return null + } + const { context } = painter + const { a, b } = context.getTransform() + const scale = Math.hypot(a, b) || 1 + const pixelWidth = Math.ceil(width * scale) + const pixelHeight = Math.ceil(height * scale) + const key = `${pattern.id}:${pixelWidth}:${pixelHeight}` + let canvasPattern = painter.patterns.get(key) + if (canvasPattern === undefined) { + const canvas = painter.document.createElement('canvas') + canvas.width = pixelWidth + canvas.height = pixelHeight + const tileContext = requiredContext(canvas) + tileContext.scale(pixelWidth / width, pixelHeight / height) + // A tile that references itself paints nothing, as a circular SVG + // pattern reference does. + painter.patterns.set(key, null) + paintNodes( + { ...painter, context: tileContext }, + pattern.nodes, + defaultPaint, + ) + canvasPattern = context.createPattern(canvas, 'repeat') + painter.patterns.set(key, canvasPattern) + } + if (!canvasPattern) return null + const angle = hundredths(pattern.angle ?? 0) + const radians = Number.isFinite(angle) ? (-angle * Math.PI) / 180 : 0 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + const scaleX = width / pixelWidth + const scaleY = height / pixelHeight + canvasPattern.setTransform({ + a: cos * scaleX, + b: sin * scaleX, + c: -sin * scaleY, + d: cos * scaleY, + e: -origin[0], + f: -origin[1], + }) + return canvasPattern +} + +function hundredths(value: number) { + return Math.round(value * 100) / 100 +} + function resolveRadialFill( painter: ScenePainter, value: string | null, diff --git a/packages/charts-core/src/export.test.ts b/packages/charts-core/src/export.test.ts index 04d88322..04318645 100644 --- a/packages/charts-core/src/export.test.ts +++ b/packages/charts-core/src/export.test.ts @@ -4,6 +4,7 @@ import { crosshair } from './crosshair' import { dot } from './dot' import { renderChartImage, serializeChartSvg } from './export' import { lineY } from './line' +import { linePattern } from './pattern' import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' import { renderChartSvgWithResources } from './svg-resources' @@ -156,6 +157,57 @@ describe('optional export', () => { readStyle.mockRestore() }) + it('inlines computed pattern tile paint', () => { + const container = document.createElement('div') + container.innerHTML = renderChartSvgWithResources( + createChartScene( + defineChart({ + marks: [lineY([1, 3, 2], { stroke: 'url(#hatch)' })], + ...linearAxes([0, 2], [0, 3]), + patterns: [ + linePattern({ + id: 'hatch', + color: 'var(--hatch)', + background: 'var(--hatch-ground)', + }), + ], + }), + { width: 480, height: 260 }, + ), + { ariaLabel: 'Pattern export', idPrefix: 'export' }, + ) + const readStyle = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element) => { + const key = element.getAttribute('data-ts-key') + const fill = + key === 'pattern:hatch:line' + ? 'rgb(37, 99, 235)' + : key === 'pattern:hatch:background' + ? 'rgb(248, 250, 252)' + : '' + return { + getPropertyValue: (property: string) => + property === 'fill' ? fill : '', + } as CSSStyleDeclaration + }) + + const result = serializeChartSvg(container) + + expect(result).toContain( + '', + ) + expect(result).toContain( + ' { const container = document.createElement('div') container.innerHTML = renderChartSvg( diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 978653ba..0f691a6e 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -5,6 +5,8 @@ import type { ChartGradientBase as RootChartGradientBase, ChartLinearGradient as RootChartLinearGradient, ChartRadialGradient as RootChartRadialGradient, + ChartPattern as RootChartPattern, + LinePatternOptions as RootLinePatternOptions, ChartGuideLineStyle as RootChartGuideLineStyle, CreateDotLayoutOptions as RootCreateDotLayoutOptions, DotLayout as RootDotLayout, @@ -15,6 +17,8 @@ import type { ChartGradientBase as UniversalChartGradientBase, ChartLinearGradient as UniversalChartLinearGradient, ChartRadialGradient as UniversalChartRadialGradient, + ChartPattern as UniversalChartPattern, + LinePatternOptions as UniversalLinePatternOptions, ChartGuideLineStyle as UniversalChartGuideLineStyle, CreateDotLayoutOptions as UniversalCreateDotLayoutOptions, DotLayout as UniversalDotLayout, @@ -25,6 +29,7 @@ import type { ChartGradientBase as TypesChartGradientBase, ChartLinearGradient as TypesChartLinearGradient, ChartRadialGradient as TypesChartRadialGradient, + ChartPattern as TypesChartPattern, } from '@tanstack/charts/types' import type { ChartGuideLineStyle as TypesChartGuideLineStyle } from '@tanstack/charts/types' @@ -122,6 +127,12 @@ describe('public package exports', () => { } }) + it('exports pattern resource types from every authoring barrel', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + it('keeps guide line styles aligned across public type barrels', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index b2e3ea5c..950e5c2f 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -117,6 +117,12 @@ export type { export { createMark } from './mark' export { compositeMark } from './mark-composite' export type { CompositeMarkOptions } from './mark-composite' +export { dotPattern, linePattern } from './pattern' +export type { + ChartPatternOptions, + DotPatternOptions, + LinePatternOptions, +} from './pattern' export { cell, rect } from './rect' export type { CellOptions, RectOptions } from './rect' export { createChartRuntime, isResponsiveChartDefinition } from './runtime' @@ -371,6 +377,7 @@ export type { ChartNumericScaleOptions, ChartLinearGradient, ChartRadialGradient, + ChartPattern, ChartMark, ChartMarkOptions, ChartMarkRenderer, diff --git a/packages/charts-core/src/pattern.ts b/packages/charts-core/src/pattern.ts new file mode 100644 index 00000000..587c7448 --- /dev/null +++ b/packages/charts-core/src/pattern.ts @@ -0,0 +1,100 @@ +import type { ChartPattern, SceneNode } from './types' + +export interface ChartPatternOptions { + id: string + /** Foreground paint. Accepts any CSS color, including `var()`. */ + color: string + /** Paint behind the foreground. Omit for a transparent background. */ + background?: string + /** Distance between repeats in pixels, at least `1`. Defaults to `6`. */ + spacing?: number + /** Tile rotation in degrees, counterclockwise. */ + angle?: number +} + +export interface LinePatternOptions extends ChartPatternOptions { + /** Line thickness in pixels, clamped to `spacing`. Defaults to `1`. */ + strokeWidth?: number +} + +export interface DotPatternOptions extends ChartPatternOptions { + /** Dot radius in pixels, clamped to half of `spacing`. Defaults to `1.5`. */ + radius?: number +} + +/** Parallel lines. `angle` defaults to `45`, which draws `/` hatching. */ +export function linePattern(options: LinePatternOptions): ChartPattern { + const spacing = patternSpacing(options) + const width = Math.min(spacing, size(options.strokeWidth, 1)) + return patternTile(options, spacing, 45, { + kind: 'rect', + key: `pattern:${options.id}:line`, + x: 0, + y: round((spacing - width) / 2), + width: spacing, + height: width, + style: { fill: options.color }, + }) +} + +/** A square grid of dots. `angle` defaults to `0`. */ +export function dotPattern(options: DotPatternOptions): ChartPattern { + const spacing = patternSpacing(options) + return patternTile(options, spacing, 0, { + kind: 'dot', + key: `pattern:${options.id}:dot`, + x: spacing / 2, + y: spacing / 2, + radius: Math.min(spacing / 2, size(options.radius, 1.5)), + style: { fill: options.color }, + }) +} + +function patternTile( + options: ChartPatternOptions, + spacing: number, + defaultAngle: number, + mark: SceneNode, +): ChartPattern { + const nodes: SceneNode[] = + options.background === undefined + ? [] + : [ + { + kind: 'rect', + key: `pattern:${options.id}:background`, + x: 0, + y: 0, + width: spacing, + height: spacing, + style: { fill: options.background }, + }, + ] + nodes.push(mark) + return { + id: options.id, + width: spacing, + height: spacing, + angle: round( + Number.isFinite(options.angle) ? options.angle! : defaultAngle, + ), + nodes, + } +} + +function patternSpacing(options: ChartPatternOptions) { + return Math.max(1, size(options.spacing, 6)) +} + +// Hundredths match SVG serialization, so SVG and raster tiles agree. +function size(value: number | undefined, fallback: number) { + return round( + value !== undefined && Number.isFinite(value) && value >= 0 + ? value + : fallback, + ) +} + +function round(value: number) { + return Math.round(value * 100) / 100 +} diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index e1165e13..48d94381 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -584,6 +584,7 @@ function createChartSceneWithScaleResolver< scales, colors, gradients: definition.gradients ?? [], + patterns: definition.patterns, theme, ...(layoutOptions.typography?.direction === undefined ? {} diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index 31401b9a..ae430087 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -1,6 +1,7 @@ import { scaleLinear } from 'd3-scale' import { describe, expect, it } from 'vitest' import { lineY } from './line' +import { dotPattern, linePattern } from './pattern' import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' import { withSvgRenderChildren } from './svg-render-context-internal' @@ -278,6 +279,60 @@ describe('SVG scene renderer', () => { '', ) }) + + it('scopes pattern tiles and rewrites fill and stroke references', () => { + const svg = renderChartSvg( + { + ...testScene(), + nodes: [ + { + kind: 'rect', + key: 'hatched', + x: 0, + y: 0, + width: 40, + height: 20, + style: { fill: 'url(#hatch)', stroke: 'url(#grain)' }, + }, + { + kind: 'rect', + key: 'foreign', + x: 50, + y: 0, + width: 40, + height: 20, + style: { fill: 'url(#app-owned)' }, + }, + ], + patterns: [ + linePattern({ + id: 'hatch', + color: 'var(--hatch)', + background: '#ffffff', + spacing: 8, + strokeWidth: 20, + }), + dotPattern({ + id: 'grain', + color: '#123456', + spacing: Number.NaN, + radius: 9, + angle: 30, + }), + { id: 'flat', width: 4, height: 4, nodes: [] }, + ], + }, + { ariaLabel: 'Patterns', idPrefix: 'chart.one' }, + ) + + expect(svg).toContain( + '', + ) + expect(svg).toContain( + 'fill="url(#chartone-hatch)" stroke="url(#chartone-grain)"', + ) + expect(svg).toContain('fill="url(#app-owned)"') + }) }) function testScene(): ChartScene { diff --git a/packages/charts-core/src/svg.ts b/packages/charts-core/src/svg.ts index 3169546e..d0f0fcf4 100644 --- a/packages/charts-core/src/svg.ts +++ b/packages/charts-core/src/svg.ts @@ -1,6 +1,7 @@ import { number, escapeAttribute } from './markup-internal' import { renderChartSvgWithHooks, + renderSceneNodes, renderSvgClip, type ChartSvgRenderHooks, } from './svg-renderer' @@ -10,16 +11,21 @@ export function renderChartSvg( scene: ChartScene, options: RenderChartSvgOptions, ): string { - const gradientIds = new Set(scene.gradients.map((gradient) => gradient.id)) + const resourceIds = new Set( + [...scene.gradients, ...(scene.patterns ?? [])].map( + (resource) => resource.id, + ), + ) return renderChartSvgWithHooks(scene, options, { renderDefinitions: (currentScene, idPrefix) => - renderGradients(currentScene, sanitizeId(idPrefix)), + renderGradients(currentScene, sanitizeId(idPrefix)) + + renderPatterns(currentScene, sanitizeId(idPrefix)), renderGroup: renderSvgClip, - resolvePaint: gradientIds.size + resolvePaint: resourceIds.size ? (value, idPrefix) => { const match = /^url\(#([^)]+)\)$/.exec(value) const id = match?.[1] - return id && gradientIds.has(id) + return id && resourceIds.has(id) ? `url(#${scopedId(sanitizeId(idPrefix), id)})` : value } @@ -49,6 +55,16 @@ function renderGradients(scene: ChartScene, idPrefix: string) { .join('')}` } +function renderPatterns(scene: ChartScene, idPrefix: string) { + if (!scene.patterns?.length) return '' + return `${scene.patterns + .map( + (pattern) => + `${renderSceneNodes(pattern.nodes, idPrefix)}`, + ) + .join('')}` +} + function scopedId(prefix: string, id: string) { return prefix ? `${prefix}-${id}` : id } diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 2596b110..6ed34a5b 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -657,6 +657,22 @@ export interface ChartRadialGradient extends ChartGradientBase { export type ChartGradient = ChartLinearGradient | ChartRadialGradient +/** + * A repeating tile referenced from paint as `url(#id)`. Sizes are user-space + * pixels. Tiles repeat from the user-space origin the node is drawn in. + */ +export interface ChartPattern { + id: string + /** Tile width in pixels. A non-positive width paints nothing. */ + width: number + /** Tile height in pixels. A non-positive height paints nothing. */ + height: number + /** Tile rotation in degrees, counterclockwise. Defaults to `0`. */ + angle?: number + /** Tile content in tile coordinates, painted in order. */ + nodes: readonly SceneNode[] +} + /** A scale-contributing mark that does not own interactive chart points. */ export type DecorativeChartMark< TMark extends ChartMark, @@ -778,6 +794,7 @@ export interface ChartSpecBase { guides?: boolean color?: ChartColorOptions gradients?: readonly ChartGradient[] + patterns?: readonly ChartPattern[] clip?: boolean margin?: number | Partial theme?: Partial @@ -1591,6 +1608,8 @@ export interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] + /** Declared pattern resources. Hand-built scenes may omit this field. */ + patterns?: readonly ChartPattern[] theme: ChartTheme direction?: ChartTextTypography['direction'] controls?: readonly ChartHostControl[] diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 13c4746a..3853b58b 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -1,6 +1,11 @@ export type { AreaYOptions } from './area' export type { AreaXCurve, AreaXOptions } from './area-x' export type { ArrowOptions } from './arrow' +export type { + ChartPatternOptions, + DotPatternOptions, + LinePatternOptions, +} from './pattern' export type { BarEndRadius, BarRadius, BarXOptions, BarYOptions } from './bar' export type { BandXOptions, BandYOptions } from './band' export type { @@ -257,6 +262,7 @@ export type { ChartNumericScaleOptions, ChartLinearGradient, ChartRadialGradient, + ChartPattern, ChartSelectionController, ChartSelectionSource, ChartMark, diff --git a/packages/charts-core/src/universal.ts b/packages/charts-core/src/universal.ts index 8c7f5242..c3f47870 100644 --- a/packages/charts-core/src/universal.ts +++ b/packages/charts-core/src/universal.ts @@ -42,6 +42,7 @@ export { } from './legend-static' export { createMark } from './mark' export { compositeMark } from './mark-composite' +export { dotPattern, linePattern } from './pattern' export { cell, rect } from './rect' export { createChartRuntime, isResponsiveChartDefinition } from './runtime' export { diff --git a/packages/charts-core/src/view.ts b/packages/charts-core/src/view.ts index 81c40ab2..4c5bc80e 100644 --- a/packages/charts-core/src/view.ts +++ b/packages/charts-core/src/view.ts @@ -89,9 +89,10 @@ export type ComposableStaticChartDefinition< TYValue extends ChartValue = ChartValue, > = Omit< WithoutEmbeddedHostOptions>, - 'gradients' | 'theme' + 'gradients' | 'patterns' | 'theme' > & { gradients?: readonly [] + patterns?: readonly [] theme?: ComposableChartTheme } @@ -783,9 +784,9 @@ function assertChildDefinition( ) } if ('chart' in definition) return - if (definition.gradients?.length) { + if (definition.gradients?.length || definition.patterns?.length) { throw new TypeError( - `View "${id}" cannot embed gradients until child scene resources can be adopted by the outer scene`, + `View "${id}" cannot embed gradients or patterns until child scene resources can be adopted by the outer scene`, ) } if (definition.theme?.background !== undefined) { diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 5755e158..9946f437 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { createChartScene, defineChart, lineY } from '@tanstack/charts' +import { dotPattern, linePattern } from '@tanstack/charts/pattern' import { scaleLinear } from '@tanstack/charts/scales/linear' import type { ChartScene, SceneNode } from '@tanstack/charts/types' import { @@ -28,6 +29,7 @@ vi.mock('react-native-svg', () => ({ Line: 'line', LinearGradient: 'linearGradient', Path: 'path', + Pattern: 'pattern', RadialGradient: 'radialGradient', Rect: 'rect', Stop: 'stop', @@ -462,6 +464,50 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain('url(#native-one-a_x29_b)') }) + it('renders scoped pattern tiles with resolved native paint', () => { + const patternScene = scene() + patternScene.patterns = [ + linePattern({ + id: 'hatch.a', + color: 'var(--hatch, #123456)', + background: '#ffffff', + spacing: 8, + strokeWidth: 2, + }), + dotPattern({ id: 'grain', color: '#abcdef', radius: 9 }), + ] + patternScene.nodes = [ + { + kind: 'rect', + key: 'hatched', + x: 0, + y: 0, + width: 20, + height: 10, + style: { fill: 'url(#hatch.a)', stroke: 'url(#grain)' }, + }, + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain( + '', + ) + expect(markup).toContain(']*fill="#123456"[^>]*y="3"[^>]*height="2"/) + expect(markup).toMatch(/]*fill="#abcdef"[^>]*r="3"/) + expect(markup).toContain('fill="url(#native-one-hatch_x2e_a)"') + expect(markup).toContain('stroke="url(#native-one-grain)"') + }) + it('renders only the resolved native retarget candidate', () => { const focusScene = scene() const first = { diff --git a/packages/react-native-charts/src/SvgScene.tsx b/packages/react-native-charts/src/SvgScene.tsx index 8e4acc0c..74d75b0d 100644 --- a/packages/react-native-charts/src/SvgScene.tsx +++ b/packages/react-native-charts/src/SvgScene.tsx @@ -8,6 +8,7 @@ import { Line, LinearGradient, Path, + Pattern, RadialGradient, Rect, Stop, @@ -55,21 +56,21 @@ export function NativeChartSceneNodes({ idPrefix, resolvePaint, }: NativeChartSceneNodesProps) { - const gradientIds = React.useMemo( - () => new Set(scene.gradients.map((gradient) => gradient.id)), - [scene.gradients], + const resourceIds = React.useMemo( + () => sceneResourceIds(scene), + [scene.gradients, scene.patterns], ) const paint = React.useCallback( (value: string) => resolveScenePaint( value, - gradientIds, + resourceIds, idPrefix, resolvePaint, color, focusFill, ), - [color, focusFill, gradientIds, idPrefix, resolvePaint], + [color, focusFill, resourceIds, idPrefix, resolvePaint], ) return ( <> @@ -100,21 +101,21 @@ export const NativeChartScene = React.memo(function NativeChartScene({ focusFill, focusPresentation, }: NativeChartSceneProps) { - const gradientIds = React.useMemo( - () => new Set(scene.gradients.map((gradient) => gradient.id)), - [scene.gradients], + const resourceIds = React.useMemo( + () => sceneResourceIds(scene), + [scene.gradients, scene.patterns], ) const paint = React.useCallback( (value: string) => resolveScenePaint( value, - gradientIds, + resourceIds, idPrefix, resolvePaint, color, focusFill, ), - [color, focusFill, gradientIds, idPrefix, resolvePaint], + [color, focusFill, resourceIds, idPrefix, resolvePaint], ) return ( @@ -182,6 +183,32 @@ export const NativeChartScene = React.memo(function NativeChartScene({ })} ) : null} + {scene.patterns?.length ? ( + + {scene.patterns.map((pattern) => ( + + {pattern.nodes.map((node) => + renderSceneNode( + node, + idPrefix, + paint, + positiveFinite(fontScale, 1), + direction, + ), + )} + + ))} + + ) : null} {scene.theme.background === 'transparent' ? null : ( , + resourceIds: ReadonlySet, idPrefix: string, resolvePaint: NativePaintResolver, color: ColorValue, @@ -420,12 +447,20 @@ function resolveScenePaint( ) { const match = /^url\(#([\s\S]*)\)$/.exec(value) const id = match?.[1] - if (id !== undefined && gradientIds.has(id)) { + if (id !== undefined && resourceIds.has(id)) { return `url(#${scopedId(idPrefix, id)})` } return resolvePaint(value, { color, canvas }) } +function sceneResourceIds(scene: ChartScene) { + return new Set( + [...scene.gradients, ...(scene.patterns ?? [])].map( + (resource) => resource.id, + ), + ) +} + function pointsPath( points: readonly (readonly [number, number])[], close: boolean, diff --git a/scripts/check-canvas-renderer.mjs b/scripts/check-canvas-renderer.mjs index 41199b9f..c869e6f9 100644 --- a/scripts/check-canvas-renderer.mjs +++ b/scripts/check-canvas-renderer.mjs @@ -22,11 +22,15 @@ const bundle = await build({ import { arrow } from '@tanstack/charts/arrow' import { barY } from '@tanstack/charts/bar' import { dot } from '@tanstack/charts/dot' - import { renderChartImage } from '@tanstack/charts/export' + import { + renderChartImage, + serializeChartSvg, + } from '@tanstack/charts/export' import { facet } from '@tanstack/charts/facet' import { hexagon } from '@tanstack/charts/hexagon' import { lineY } from '@tanstack/charts/line' import { link } from '@tanstack/charts/link' + import { dotPattern, linePattern } from '@tanstack/charts/pattern' import { mountChart } from '@tanstack/charts/dom' import { polar, radialDot, radialLine } from '@tanstack/charts/polar' import { rect } from '@tanstack/charts/rect' @@ -367,6 +371,120 @@ const bundle = await build({ } radialSurface.destroy() + const patternContainer = document.querySelector('#pattern') + patternContainer.style.setProperty('--pattern-ink', '#1d4ed8') + const patternScene = { + ...makeScene(200, 104), + nodes: [ + { + kind: 'rect', + key: 'hatch', + x: 12, + y: 12, + width: 84, + height: 80, + style: { fill: 'url(#hatch)' }, + }, + { + kind: 'rect', + key: 'grain', + x: 104, + y: 12, + width: 84, + height: 80, + style: { fill: 'url(#grain)' }, + }, + ], + points: [], + patterns: [ + linePattern({ + id: 'hatch', + color: 'var(--pattern-ink)', + background: '#ffffff', + spacing: 7, + strokeWidth: 2.5, + angle: 30, + }), + dotPattern({ + id: 'grain', + color: 'var(--pattern-ink)', + background: '#ffffff', + spacing: 8, + radius: 2.5, + angle: 45, + }), + ], + } + const patternSurface = createCanvasChartRenderer().mount( + patternContainer, + () => {}, + ) + patternSurface.render(patternScene, { + ariaLabel: 'Canvas pattern parity check', + tabIndex: -1, + }) + const patternCanvasContext = patternSurface.canvas.getContext('2d') + // Resolve CSS variables through the standalone export path. + const patternSvgHost = document.createElement('div') + patternContainer.append(patternSvgHost) + patternSvgHost.innerHTML = renderChartSvg(patternScene, { + ariaLabel: 'SVG pattern parity check', + tabIndex: -1, + idPrefix: 'pattern-parity-check', + }) + const patternMarkup = serializeChartSvg(patternSvgHost) + patternSvgHost.remove() + const patternImage = new Image() + patternImage.src = + 'data:image/svg+xml;charset=utf-8,' + + encodeURIComponent(patternMarkup) + await patternImage.decode() + const patternSvgCanvas = document.createElement('canvas') + patternSvgCanvas.width = patternScene.width * ratio + patternSvgCanvas.height = patternScene.height * ratio + const patternSvgContext = patternSvgCanvas.getContext('2d') + patternSvgContext.scale(ratio, ratio) + patternSvgContext.drawImage( + patternImage, + 0, + 0, + patternScene.width, + patternScene.height, + ) + const comparePatternRegion = (x, y, width, height) => { + const read = (context) => + context.getImageData(x * ratio, y * ratio, width * ratio, height * ratio) + .data + const canvasPixels = read(patternCanvasContext) + const svgPixels = read(patternSvgContext) + let totalDelta = 0 + let divergent = 0 + let inked = 0 + const pixels = canvasPixels.length / 4 + for (let index = 0; index < canvasPixels.length; index += 4) { + let pixelDelta = 0 + for (let channel = 0; channel < 4; channel += 1) { + pixelDelta = Math.max( + pixelDelta, + Math.abs(canvasPixels[index + channel] - svgPixels[index + channel]), + ) + } + totalDelta += pixelDelta + if (pixelDelta > 96) divergent += 1 + if (canvasPixels[index] < 128) inked += 1 + } + return { + meanDelta: totalDelta / pixels, + divergentShare: divergent / pixels, + inkShare: inked / pixels, + } + } + const patternResult = { + lines: comparePatternRegion(16, 16, 76, 72), + dots: comparePatternRegion(108, 16, 76, 72), + } + patternSurface.destroy() + const data = [ { id: 'a', x: 0, y: 1 }, { id: 'b', x: 1, y: 2 }, @@ -858,6 +976,7 @@ const bundle = await build({ return { surface: surfaceResult, radial: radialResult, + pattern: patternResult, export: { type: exported.type, size: exported.size }, interaction: { focused, @@ -886,7 +1005,7 @@ try { }) const page = await context.newPage() await page.setContent( - '
', + '
', ) await page.addScriptTag({ content: bundle.outputFiles[0].text }) const result = await page.evaluate(() => window.runCanvasRendererCheck()) @@ -929,6 +1048,18 @@ try { JSON.stringify(result.radial), ) assert.ok(result.radial.decreasingStops.canvas[0] >= 240) + for (const [name, region] of Object.entries(result.pattern)) { + assert.ok( + region.inkShare > 0.1 && region.inkShare < 0.9, + `Canvas ${name} pattern did not paint a two-tone tile: ` + + JSON.stringify(result.pattern), + ) + assert.ok( + region.meanDelta <= 12 && region.divergentShare <= 0.03, + `Canvas ${name} pattern diverged from rasterized SVG: ` + + JSON.stringify(result.pattern), + ) + } assert.ok(result.radial.decreasingStops.canvas[2] <= 15) assert.equal(result.radial.decreasingStops.canvas[3], 255) assert.equal(result.export.type, 'image/png') From 69764db21e688d359279fba5dc8f5c39481ded57 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:25:50 -1000 Subject: [PATCH 02/13] docs: document pattern resources and resolve F-259 --- API-FRICTION.md | 52 +++++++++++--- docs/examples/themes-and-motion.md | 4 +- docs/guides/custom-marks-and-renderers.md | 3 +- docs/guides/themes-and-styling.md | 70 +++++++++++++++++++ docs/reference/chart-spec.md | 26 ++++++- docs/reference/index.md | 1 + docs/reference/rendering-and-export.md | 17 +++-- docs/reference/runtime-and-scene.md | 2 + docs/reference/scales-guides-and-color.md | 60 +++++++++++++++- docs/reference/types.md | 4 ++ llms.txt | 4 +- .../docs/examples/themes-and-motion.md | 4 +- .../docs/guides/custom-marks-and-renderers.md | 3 +- .../docs/guides/themes-and-styling.md | 70 +++++++++++++++++++ .../charts-core/docs/reference/chart-spec.md | 26 ++++++- packages/charts-core/docs/reference/index.md | 1 + .../docs/reference/rendering-and-export.md | 17 +++-- .../docs/reference/runtime-and-scene.md | 2 + .../docs/reference/scales-guides-and-color.md | 60 +++++++++++++++- packages/charts-core/docs/reference/types.md | 4 ++ packages/charts-core/llms.txt | 4 +- 21 files changed, 401 insertions(+), 33 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 1c43acbb..bdbac43f 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -295,7 +295,7 @@ Each entry records: | F-256 | Shared host policy retained browser-only modules | API/Tooling | resolved | | F-257 | The release package graph leaked into application setup | API/Docs/Tooling | resolved | | F-258 | Tooltip chrome required specificity overrides | API/Documentation | resolved | -| F-259 | Chart resources cannot declare patterns | API | open | +| F-259 | Chart resources cannot declare patterns | API | resolved | | F-260 | Static guides cannot express stroke treatment | API | resolved | | F-261 | Cartesian bars cannot round only exposed corners | API | resolved | | F-262 | Mark inference accepted an unsupported style option | API | resolved | @@ -7943,19 +7943,53 @@ Each entry records: ### F-259 — Chart resources cannot declare patterns -- Status: open +- Status: resolved - Severity: medium - Owner: API - Observed in: the theme-treatment audit for catalog cases 120–124 and the - Bklit-derived card references + Bklit-derived card references; restated by GitHub issue #128 for funnel, + Sankey, candlestick, gauge, and heatmap hatching - Friction: `ChartSpec.gradients` keeps CSS-variable linear and radial gradients inside the renderer-neutral definition. The same resource model - cannot declare a hatch, dot, or line pattern. An author must omit that - treatment or leave the native definition and renderer resource boundary. -- Current decision: case 124 uses the same declared linear gradient across its - palette treatments. Do not inject an application-owned SVG pattern as a - catalog workaround. Keep a native pattern resource open until one contract - covers SVG, Canvas, export, CSS-variable paint, and `idPrefix` scoping. + could not declare a hatch, dot, or line pattern. An author had to omit that + treatment or render an application-owned `` beside the chart and + reference it by ID, which broke `serializeChartSvg`, `renderChartImage`, + Canvas, React Native, and `idPrefix` scoping. +- Decision: add a sibling `patterns` spec and scene key that mirrors + `gradients` and is referenced from `fill` or `stroke` as `url(#id)`. A + `ChartPattern` is plain data: an ID, a tile `width` and `height` in pixels, + an optional counterclockwise `angle`, and ordinary `SceneNode` tile content. + Renderers reuse their existing node serializers and painters for the tile, + which keeps the shared path small. The small vocabulary lives in + tree-shakeable `linePattern()` and `dotPattern()` factories at + `@tanstack/charts/pattern`; they own defaults, clamping, and hundredth + rounding, and their `color` and `background` accept CSS variables. SVG emits + a scoped `userSpaceOnUse` `` and rewrites matching paints. Canvas + rasterizes the tile at device resolution and maps it back through + `CanvasPattern.setTransform` with the same rotation. Text labels get an + origin correction because SVG text keeps the scene origin while Canvas + translates to the label. React Native emits `react-native-svg` `Pattern`, + which version 15 supports. Embedded views reject child patterns, as they do + child gradients. Animated patterns stay out of scope; a pattern is static + until the definition changes. +- Follow-up: gradients and patterns are now two sibling resource lists with + one paint reference syntax. A later unified paint-resource model could merge + them, but this entry keeps the existing gradient contract unchanged. +- Verification: SVG unit coverage checks scoped IDs, rotation, factory + clamping, empty tiles, fill and stroke rewriting, and untouched foreign + references. Canvas unit coverage checks the device-resolution tile, CSS + variable resolution through the paint resolver, background-then-foreground + order, cached reuse across fill, stroke, and label paint, the rotation + matrix, and the label origin. Export coverage proves computed tile paint + replaces `var()` in standalone SVG. React Native coverage checks scoped + `Pattern` resources and resolved native paint. The browser Canvas gate + rasterizes exported SVG and compares rotated hatch and dot fills with + Canvas output: the mean channel delta is about 1 of 255 with no divergent + pixels, and reversing the Canvas rotation fails the gate. Declaring no + patterns costs 8 gzip bytes in scene-only consumers and 114 to 125 bytes in + static SVG and DOM consumers, comparable to radial gradients. The Canvas + renderer entry grows by 322 gzip bytes for tile rasterization. The + factories add about 0.27 KiB under an isolated 0.3 KiB budget. ### F-260 — Static guides cannot express stroke treatment diff --git a/docs/examples/themes-and-motion.md b/docs/examples/themes-and-motion.md index 59cb6df4..4c437ce9 100644 --- a/docs/examples/themes-and-motion.md +++ b/docs/examples/themes-and-motion.md @@ -60,8 +60,8 @@ gradient, Canvas, and tooltip behavior. ## Current limits -- Chart specs declare linear and radial gradients, but they do not declare - pattern resources. +- Chart specs declare linear and radial gradients plus repeating patterns. + Pattern tiles are static and cannot animate. - `grid` and the static axis line are visibility controls. They do not accept stroke width, dash, or opacity. - Use `theme.grid` for shared guide paint. Use rule marks for styled static diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index 93816a8e..5c0e4154 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -412,7 +412,8 @@ adapter. Preserve: - scoped IDs through `idPrefix`; - deterministic server output. -The default `renderChartSvg` already emits declared gradients and group clips. +The default `renderChartSvg` already emits declared gradients, patterns, and +group clips. The compatible `renderChartSvgWithResources` export remains available when an explicit resource serializer name is useful. diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index f78ab2b0..80c05f8a 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -212,6 +212,76 @@ A Canvas gradient needs measurable node bounds; path-only geometry with no point bounds should use an explicit paint instead. Canvas rejects radial strokes because the required nonuniform transform would distort stroke width. +## Patterns + +Patterns are opt-in resources for hatch and dot fills. They help when a series +must stay distinct in print, in grayscale, or for readers who cannot rely on +hue alone. Declare them next to gradients and reference them the same way: + +```ts group=pattern-bars env=charts file=/src/chart.ts entry +import { barY, defineChart } from '@tanstack/charts' +import { linePattern } from '@tanstack/charts/pattern' +import { scaleBand } from '@tanstack/charts/scales/band' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { actual, projected } from './data' + +export default defineChart({ + marks: [ + barY(actual, { + x: 'quarter', + y: 'revenue', + fill: 'var(--chart-actual, #2563eb)', + }), + barY(projected, { + x: 'quarter', + y: 'revenue', + fill: 'url(#projected)', + stroke: 'var(--chart-actual, #2563eb)', + }), + ], + scales: { + x: { scale: () => scaleBand().padding(0.2) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, + }, + patterns: [ + linePattern({ + id: 'projected', + color: 'var(--chart-actual, #2563eb)', + background: 'var(--chart-surface, #ffffff)', + spacing: 6, + strokeWidth: 2, + }), + ], +}) +``` + +```ts group=pattern-bars file=/src/data.ts collapsed +export const actual = [ + { quarter: 'Q1', revenue: 42_000 }, + { quarter: 'Q2', revenue: 51_000 }, +] + +export const projected = [ + { quarter: 'Q3', revenue: 58_000 }, + { quarter: 'Q4', revenue: 64_000 }, +] +``` + +`linePattern()` draws parallel lines. Its `angle` is measured counterclockwise +from horizontal and defaults to `45`, which draws `/` hatching. `dotPattern()` +draws a square grid of dots and accepts the same `angle`, which defaults to +`0`. Sizes are pixels. Tiles repeat from the origin of the coordinate space the +shape is drawn in, not from each shape's corner, so neighboring shapes that +share a pattern line up. + +`color` and `background` accept any CSS color, including `var()` with a +fallback. SVG, Canvas, standalone export, and React Native draw the same tile. +Patterns do not animate; declare a new definition to change one. + ## HTML tooltip styling The built-in DOM tooltip is an HTML element inside the chart container by default. diff --git a/docs/reference/chart-spec.md b/docs/reference/chart-spec.md index cf6b13c3..71f000c6 100644 --- a/docs/reference/chart-spec.md +++ b/docs/reference/chart-spec.md @@ -1,6 +1,6 @@ --- title: Chart Spec -description: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec. +description: Reference for marks, axes, color, gradients, patterns, clipping, margins, guides, and themes in a TanStack Charts spec. --- Every static definition and responsive chart builder resolves to a `ChartSpec`. @@ -14,6 +14,7 @@ type ChartSpec = { guides?: boolean color?: ChartColorOptions gradients?: readonly ChartGradient[] + patterns?: readonly ChartPattern[] clip?: boolean margin?: number | Partial theme?: Partial @@ -36,6 +37,7 @@ type ChartScales = Readonly< | `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | | `color` | No | Shared categorical or quantitative color scale and optional legend. | | `gradients` | No | Linear and radial gradient resources consumed by SVG, Canvas, and React Native renderers. | +| `patterns` | No | Repeating tile resources consumed by SVG, Canvas, and React Native renderers. | | `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | | `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | | `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | @@ -183,6 +185,28 @@ document or native SVG tree. Stops keep their authored order. A stop before the previous offset is clamped forward to that offset. See [Rendering and export](./rendering-and-export.md). +## Pattern resources + +`patterns` declares repeating tiles that marks reference the same way: + +```ts +import { dotPattern, linePattern } from '@tanstack/charts/pattern' + +const definition = defineChart({ + marks, + scales: { x, y }, + patterns: [ + linePattern({ id: 'forecast', color: '#2563eb', strokeWidth: 2 }), + dotPattern({ id: 'sampled', color: 'var(--chart-muted)', radius: 1 }), + ], +}) +``` + +Use `url(#forecast)` as a `fill` or `stroke`. Pattern sizes are pixels rather +than normalized bounds, so a hatch keeps its density on every shape. The +[pattern reference](./scales-guides-and-color.md#patterns) lists the resource +shape and every factory option. + ## Theme The complete default theme is exported as `defaultChartTheme`: diff --git a/docs/reference/index.md b/docs/reference/index.md index aa294b54..2d5ef778 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -165,6 +165,7 @@ aggregate `/scales` export. | `@tanstack/charts/motion/definition` | Isolated `stagger` and `ChartMotionStaggerOptions` without the renderer or spring solver | | `@tanstack/charts/network/force` | `forceLayout`, built-in descriptors, named D3-compatible force factories, private working-clone context, settled node/link result, and lineage types | | `@tanstack/charts/network/sankey` | `sankeyDiagram`, shorthand and callable alignment types, responsive layout options, immutable node/link values, comparator contexts, and lineage types | +| `@tanstack/charts/pattern` | `linePattern`, `dotPattern`, and their option types for hatch and dot pattern resources | | `@tanstack/charts/polar` | `pie`, `polar`, `focusGroupAngle`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | diff --git a/docs/reference/rendering-and-export.md b/docs/reference/rendering-and-export.md index 9e44887c..4d299773 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -258,6 +258,8 @@ Renderer-specific tradeoffs: - Gradients require geometry with measurable bounds. - Radial gradients support Canvas fills only. A radial stroke throws rather than changing stroke width under a nonuniform bounds transform. +- Patterns rasterize one tile at the device pixel ratio and repeat it under + the same rotation as SVG. Fills, strokes, and labels are supported. Linear gradients map their normalized endpoints directly into each node's bounds. Radial fills are clipped to the node, then painted in normalized unit @@ -275,9 +277,9 @@ import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' `renderChartSvg` and the compatible explicit `renderChartSvgWithResources(scene, options)` entry both: -- emit declared linear and radial gradients in `` -- scope gradient IDs with sanitized `idPrefix` -- rewrite matching `url(#gradient-id)` paints +- emit declared linear and radial gradients and patterns in `` +- scope gradient and pattern IDs with sanitized `idPrefix` +- rewrite matching `url(#resource-id)` paints - emit clip paths for scene groups with `clip` bounds Default SVG hosts and framework adapters use this behavior without a custom @@ -286,8 +288,13 @@ stop offsets are clamped to `0..1` and emitted as percentages. A radial gradient defaults to center `(0.5, 0.5)` and radius `0.5`; each omitted focal coordinate inherits the matching center coordinate. -The React Native adapter emits matching `LinearGradient` and `RadialGradient` -resources through `react-native-svg`. Its `Chart` generates an `idPrefix` with +A pattern becomes a `userSpaceOnUse` `` with the tile's `width` and +`height`. A nonzero `angle` adds `patternTransform="rotate(-angle)"`. The tile +nodes serialize like any other scene nodes. Tile paint keeps authored `var()` +values, and standalone export inlines their computed values. + +The React Native adapter emits matching `LinearGradient`, `RadialGradient`, +and `Pattern` resources through `react-native-svg`. Its `Chart` generates an `idPrefix` with `useId()` unless the application supplies one, then scopes resource IDs and rewrites matching paints in the same way as the web adapters. diff --git a/docs/reference/runtime-and-scene.md b/docs/reference/runtime-and-scene.md index 6aed13c2..28957db1 100644 --- a/docs/reference/runtime-and-scene.md +++ b/docs/reference/runtime-and-scene.md @@ -197,6 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] + patterns?: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -212,6 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | +| `patterns` | Declared pattern resources, when the definition has any | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index 3a7686f2..f4321a66 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -1,6 +1,6 @@ --- title: Scales, Guides, and Color -description: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, and gradients. +description: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, gradients, and patterns. --- Pass a compatible scale factory when its domain should come from mark channels. @@ -933,3 +933,61 @@ support Canvas fills and strokes. `@tanstack/charts`, `@tanstack/charts/universal`, and `@tanstack/charts/types`. Use an `idPrefix` when charts share a document; see [Rendering and export](./rendering-and-export.md#svg-resources). + +## Patterns + +A chart pattern is a renderer-neutral repeating tile: + +```ts +interface ChartPattern { + id: string + width: number + height: number + angle?: number + nodes: readonly SceneNode[] +} +``` + +`width` and `height` are the tile size in pixels; a non-positive or +non-finite size paints nothing. `angle` rotates the tile counterclockwise in +degrees. `nodes` are ordinary scene nodes in tile coordinates, so a tile can +hold rectangles, dots, paths, or any other primitive the renderers already +draw. Tiles repeat from the origin of the coordinate space a node is drawn in, +matching SVG `patternUnits="userSpaceOnUse"`, so adjacent shapes that share a +pattern stay aligned. + +Most charts use the two factories from `@tanstack/charts/pattern`, which are +also exported from `@tanstack/charts` and `@tanstack/charts/universal`: + +```ts +import { dotPattern, linePattern } from '@tanstack/charts/pattern' + +linePattern({ id: 'hatch', color: 'currentColor', strokeWidth: 2 }) +dotPattern({ id: 'grain', color: 'var(--chart-muted)', radius: 1 }) +``` + +| Option | Factory | Default | Meaning | +| ------------- | --------------- | ---------------------------- | ------------------------------------------------------------- | +| `id` | Both | Required | Resource ID referenced as `url(#id)`. | +| `color` | Both | Required | Foreground paint. Any CSS color, including `var()`. | +| `background` | Both | Transparent | Paint behind the foreground. | +| `spacing` | Both | `6` | Tile width and height in pixels. Values below `1` become `1`. | +| `angle` | Both | `45` for lines, `0` for dots | Tile rotation in degrees, counterclockwise. | +| `strokeWidth` | `linePattern()` | `1` | Line thickness in pixels, clamped to `spacing`. | +| `radius` | `dotPattern()` | `1.5` | Dot radius in pixels, clamped to half of `spacing`. | + +A missing, negative, or non-finite factory size uses its default. Factories +round sizes and angles to hundredths, the precision SVG serialization uses, so +SVG output and Canvas tiles agree. + +Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. +SVG and React Native emit a native ``. Canvas rasterizes one tile at +the device pixel ratio and repeats it under the same rotation, including for +text labels. Standalone SVG export resolves `var()` paint inside the tile like +any other chart paint. Pattern tiles do not animate. + +`ChartPattern` is a type export from `@tanstack/charts`, +`@tanstack/charts/universal`, and `@tanstack/charts/types`. +`ChartPatternOptions`, `LinePatternOptions`, and `DotPatternOptions` are type +exports from `@tanstack/charts`, `@tanstack/charts/universal`, and +`@tanstack/charts/pattern`. diff --git a/docs/reference/types.md b/docs/reference/types.md index fb81f80e..f1f4ff0a 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -277,6 +277,10 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartLinearGradient` | Named linear-gradient resource | | `ChartRadialGradient` | Named radial-gradient resource | | `ChartGradientStop` | Gradient offset, color, and optional opacity | +| `ChartPattern` | Named repeating tile of scene nodes | +| `ChartPatternOptions` | Shared `linePattern()` and `dotPattern()` options | +| `LinePatternOptions` | `linePattern()` options with line thickness | +| `DotPatternOptions` | `dotPattern()` options with dot radius | | `ChartCurve` | Line and y-area path generation | See [Scales, guides, and color](./scales-guides-and-color.md). diff --git a/llms.txt b/llms.txt index 23bead6c..cf9134dd 100644 --- a/llms.txt +++ b/llms.txt @@ -62,10 +62,10 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/examples/themes-and-motion.md: Themes and Motion: Apply inherited palettes and optional motion while keeping chart definitions separate from card layout and controls. - docs/reference/index.md: API Reference: Reference for TanStack Charts definitions, marks, scales, runtime, rendering, interaction, and framework adapters. - docs/reference/chart-definitions.md: Chart Definition API: Reference static and responsive chart definitions, build context, and identity-based updates. -- docs/reference/chart-spec.md: Chart Spec: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec. +- docs/reference/chart-spec.md: Chart Spec: Reference for marks, axes, color, gradients, patterns, clipping, margins, guides, and themes in a TanStack Charts spec. - docs/reference/view-composition.md: View Composition: Compose complete chart definitions with deterministic grid, layer, fill, and inset layouts. - docs/reference/transforms.md: Data Transforms: Typed, eager transforms for reusable application data. -- docs/reference/scales-guides-and-color.md: Scales, Guides, and Color: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, and gradients. +- docs/reference/scales-guides-and-color.md: Scales, Guides, and Color: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, gradients, and patterns. - docs/reference/dom-host.md: DOM Host: Mount, update, size, interact with, and destroy a TanStack Chart in a browser DOM container. - docs/reference/adapter-controller.md: Adapter Controller: Build framework adapters over shared prerender, mount, update, layout, scene access, and cleanup behavior. - docs/reference/runtime-and-scene.md: Runtime and Scene: Compile object and responsive chart definitions into renderer-neutral scenes. diff --git a/packages/charts-core/docs/examples/themes-and-motion.md b/packages/charts-core/docs/examples/themes-and-motion.md index 59cb6df4..4c437ce9 100644 --- a/packages/charts-core/docs/examples/themes-and-motion.md +++ b/packages/charts-core/docs/examples/themes-and-motion.md @@ -60,8 +60,8 @@ gradient, Canvas, and tooltip behavior. ## Current limits -- Chart specs declare linear and radial gradients, but they do not declare - pattern resources. +- Chart specs declare linear and radial gradients plus repeating patterns. + Pattern tiles are static and cannot animate. - `grid` and the static axis line are visibility controls. They do not accept stroke width, dash, or opacity. - Use `theme.grid` for shared guide paint. Use rule marks for styled static diff --git a/packages/charts-core/docs/guides/custom-marks-and-renderers.md b/packages/charts-core/docs/guides/custom-marks-and-renderers.md index 93816a8e..5c0e4154 100644 --- a/packages/charts-core/docs/guides/custom-marks-and-renderers.md +++ b/packages/charts-core/docs/guides/custom-marks-and-renderers.md @@ -412,7 +412,8 @@ adapter. Preserve: - scoped IDs through `idPrefix`; - deterministic server output. -The default `renderChartSvg` already emits declared gradients and group clips. +The default `renderChartSvg` already emits declared gradients, patterns, and +group clips. The compatible `renderChartSvgWithResources` export remains available when an explicit resource serializer name is useful. diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index f78ab2b0..80c05f8a 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -212,6 +212,76 @@ A Canvas gradient needs measurable node bounds; path-only geometry with no point bounds should use an explicit paint instead. Canvas rejects radial strokes because the required nonuniform transform would distort stroke width. +## Patterns + +Patterns are opt-in resources for hatch and dot fills. They help when a series +must stay distinct in print, in grayscale, or for readers who cannot rely on +hue alone. Declare them next to gradients and reference them the same way: + +```ts group=pattern-bars env=charts file=/src/chart.ts entry +import { barY, defineChart } from '@tanstack/charts' +import { linePattern } from '@tanstack/charts/pattern' +import { scaleBand } from '@tanstack/charts/scales/band' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { actual, projected } from './data' + +export default defineChart({ + marks: [ + barY(actual, { + x: 'quarter', + y: 'revenue', + fill: 'var(--chart-actual, #2563eb)', + }), + barY(projected, { + x: 'quarter', + y: 'revenue', + fill: 'url(#projected)', + stroke: 'var(--chart-actual, #2563eb)', + }), + ], + scales: { + x: { scale: () => scaleBand().padding(0.2) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, + }, + patterns: [ + linePattern({ + id: 'projected', + color: 'var(--chart-actual, #2563eb)', + background: 'var(--chart-surface, #ffffff)', + spacing: 6, + strokeWidth: 2, + }), + ], +}) +``` + +```ts group=pattern-bars file=/src/data.ts collapsed +export const actual = [ + { quarter: 'Q1', revenue: 42_000 }, + { quarter: 'Q2', revenue: 51_000 }, +] + +export const projected = [ + { quarter: 'Q3', revenue: 58_000 }, + { quarter: 'Q4', revenue: 64_000 }, +] +``` + +`linePattern()` draws parallel lines. Its `angle` is measured counterclockwise +from horizontal and defaults to `45`, which draws `/` hatching. `dotPattern()` +draws a square grid of dots and accepts the same `angle`, which defaults to +`0`. Sizes are pixels. Tiles repeat from the origin of the coordinate space the +shape is drawn in, not from each shape's corner, so neighboring shapes that +share a pattern line up. + +`color` and `background` accept any CSS color, including `var()` with a +fallback. SVG, Canvas, standalone export, and React Native draw the same tile. +Patterns do not animate; declare a new definition to change one. + ## HTML tooltip styling The built-in DOM tooltip is an HTML element inside the chart container by default. diff --git a/packages/charts-core/docs/reference/chart-spec.md b/packages/charts-core/docs/reference/chart-spec.md index cf6b13c3..71f000c6 100644 --- a/packages/charts-core/docs/reference/chart-spec.md +++ b/packages/charts-core/docs/reference/chart-spec.md @@ -1,6 +1,6 @@ --- title: Chart Spec -description: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec. +description: Reference for marks, axes, color, gradients, patterns, clipping, margins, guides, and themes in a TanStack Charts spec. --- Every static definition and responsive chart builder resolves to a `ChartSpec`. @@ -14,6 +14,7 @@ type ChartSpec = { guides?: boolean color?: ChartColorOptions gradients?: readonly ChartGradient[] + patterns?: readonly ChartPattern[] clip?: boolean margin?: number | Partial theme?: Partial @@ -36,6 +37,7 @@ type ChartScales = Readonly< | `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | | `color` | No | Shared categorical or quantitative color scale and optional legend. | | `gradients` | No | Linear and radial gradient resources consumed by SVG, Canvas, and React Native renderers. | +| `patterns` | No | Repeating tile resources consumed by SVG, Canvas, and React Native renderers. | | `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | | `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | | `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | @@ -183,6 +185,28 @@ document or native SVG tree. Stops keep their authored order. A stop before the previous offset is clamped forward to that offset. See [Rendering and export](./rendering-and-export.md). +## Pattern resources + +`patterns` declares repeating tiles that marks reference the same way: + +```ts +import { dotPattern, linePattern } from '@tanstack/charts/pattern' + +const definition = defineChart({ + marks, + scales: { x, y }, + patterns: [ + linePattern({ id: 'forecast', color: '#2563eb', strokeWidth: 2 }), + dotPattern({ id: 'sampled', color: 'var(--chart-muted)', radius: 1 }), + ], +}) +``` + +Use `url(#forecast)` as a `fill` or `stroke`. Pattern sizes are pixels rather +than normalized bounds, so a hatch keeps its density on every shape. The +[pattern reference](./scales-guides-and-color.md#patterns) lists the resource +shape and every factory option. + ## Theme The complete default theme is exported as `defaultChartTheme`: diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index aa294b54..2d5ef778 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -165,6 +165,7 @@ aggregate `/scales` export. | `@tanstack/charts/motion/definition` | Isolated `stagger` and `ChartMotionStaggerOptions` without the renderer or spring solver | | `@tanstack/charts/network/force` | `forceLayout`, built-in descriptors, named D3-compatible force factories, private working-clone context, settled node/link result, and lineage types | | `@tanstack/charts/network/sankey` | `sankeyDiagram`, shorthand and callable alignment types, responsive layout options, immutable node/link values, comparator contexts, and lineage types | +| `@tanstack/charts/pattern` | `linePattern`, `dotPattern`, and their option types for hatch and dot pattern resources | | `@tanstack/charts/polar` | `pie`, `polar`, `focusGroupAngle`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | diff --git a/packages/charts-core/docs/reference/rendering-and-export.md b/packages/charts-core/docs/reference/rendering-and-export.md index 9e44887c..4d299773 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -258,6 +258,8 @@ Renderer-specific tradeoffs: - Gradients require geometry with measurable bounds. - Radial gradients support Canvas fills only. A radial stroke throws rather than changing stroke width under a nonuniform bounds transform. +- Patterns rasterize one tile at the device pixel ratio and repeat it under + the same rotation as SVG. Fills, strokes, and labels are supported. Linear gradients map their normalized endpoints directly into each node's bounds. Radial fills are clipped to the node, then painted in normalized unit @@ -275,9 +277,9 @@ import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' `renderChartSvg` and the compatible explicit `renderChartSvgWithResources(scene, options)` entry both: -- emit declared linear and radial gradients in `` -- scope gradient IDs with sanitized `idPrefix` -- rewrite matching `url(#gradient-id)` paints +- emit declared linear and radial gradients and patterns in `` +- scope gradient and pattern IDs with sanitized `idPrefix` +- rewrite matching `url(#resource-id)` paints - emit clip paths for scene groups with `clip` bounds Default SVG hosts and framework adapters use this behavior without a custom @@ -286,8 +288,13 @@ stop offsets are clamped to `0..1` and emitted as percentages. A radial gradient defaults to center `(0.5, 0.5)` and radius `0.5`; each omitted focal coordinate inherits the matching center coordinate. -The React Native adapter emits matching `LinearGradient` and `RadialGradient` -resources through `react-native-svg`. Its `Chart` generates an `idPrefix` with +A pattern becomes a `userSpaceOnUse` `` with the tile's `width` and +`height`. A nonzero `angle` adds `patternTransform="rotate(-angle)"`. The tile +nodes serialize like any other scene nodes. Tile paint keeps authored `var()` +values, and standalone export inlines their computed values. + +The React Native adapter emits matching `LinearGradient`, `RadialGradient`, +and `Pattern` resources through `react-native-svg`. Its `Chart` generates an `idPrefix` with `useId()` unless the application supplies one, then scopes resource IDs and rewrites matching paints in the same way as the web adapters. diff --git a/packages/charts-core/docs/reference/runtime-and-scene.md b/packages/charts-core/docs/reference/runtime-and-scene.md index 6aed13c2..28957db1 100644 --- a/packages/charts-core/docs/reference/runtime-and-scene.md +++ b/packages/charts-core/docs/reference/runtime-and-scene.md @@ -197,6 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] + patterns?: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -212,6 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | +| `patterns` | Declared pattern resources, when the definition has any | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index 3a7686f2..f4321a66 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -1,6 +1,6 @@ --- title: Scales, Guides, and Color -description: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, and gradients. +description: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, gradients, and patterns. --- Pass a compatible scale factory when its domain should come from mark channels. @@ -933,3 +933,61 @@ support Canvas fills and strokes. `@tanstack/charts`, `@tanstack/charts/universal`, and `@tanstack/charts/types`. Use an `idPrefix` when charts share a document; see [Rendering and export](./rendering-and-export.md#svg-resources). + +## Patterns + +A chart pattern is a renderer-neutral repeating tile: + +```ts +interface ChartPattern { + id: string + width: number + height: number + angle?: number + nodes: readonly SceneNode[] +} +``` + +`width` and `height` are the tile size in pixels; a non-positive or +non-finite size paints nothing. `angle` rotates the tile counterclockwise in +degrees. `nodes` are ordinary scene nodes in tile coordinates, so a tile can +hold rectangles, dots, paths, or any other primitive the renderers already +draw. Tiles repeat from the origin of the coordinate space a node is drawn in, +matching SVG `patternUnits="userSpaceOnUse"`, so adjacent shapes that share a +pattern stay aligned. + +Most charts use the two factories from `@tanstack/charts/pattern`, which are +also exported from `@tanstack/charts` and `@tanstack/charts/universal`: + +```ts +import { dotPattern, linePattern } from '@tanstack/charts/pattern' + +linePattern({ id: 'hatch', color: 'currentColor', strokeWidth: 2 }) +dotPattern({ id: 'grain', color: 'var(--chart-muted)', radius: 1 }) +``` + +| Option | Factory | Default | Meaning | +| ------------- | --------------- | ---------------------------- | ------------------------------------------------------------- | +| `id` | Both | Required | Resource ID referenced as `url(#id)`. | +| `color` | Both | Required | Foreground paint. Any CSS color, including `var()`. | +| `background` | Both | Transparent | Paint behind the foreground. | +| `spacing` | Both | `6` | Tile width and height in pixels. Values below `1` become `1`. | +| `angle` | Both | `45` for lines, `0` for dots | Tile rotation in degrees, counterclockwise. | +| `strokeWidth` | `linePattern()` | `1` | Line thickness in pixels, clamped to `spacing`. | +| `radius` | `dotPattern()` | `1.5` | Dot radius in pixels, clamped to half of `spacing`. | + +A missing, negative, or non-finite factory size uses its default. Factories +round sizes and angles to hundredths, the precision SVG serialization uses, so +SVG output and Canvas tiles agree. + +Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. +SVG and React Native emit a native ``. Canvas rasterizes one tile at +the device pixel ratio and repeats it under the same rotation, including for +text labels. Standalone SVG export resolves `var()` paint inside the tile like +any other chart paint. Pattern tiles do not animate. + +`ChartPattern` is a type export from `@tanstack/charts`, +`@tanstack/charts/universal`, and `@tanstack/charts/types`. +`ChartPatternOptions`, `LinePatternOptions`, and `DotPatternOptions` are type +exports from `@tanstack/charts`, `@tanstack/charts/universal`, and +`@tanstack/charts/pattern`. diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index fb81f80e..f1f4ff0a 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -277,6 +277,10 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartLinearGradient` | Named linear-gradient resource | | `ChartRadialGradient` | Named radial-gradient resource | | `ChartGradientStop` | Gradient offset, color, and optional opacity | +| `ChartPattern` | Named repeating tile of scene nodes | +| `ChartPatternOptions` | Shared `linePattern()` and `dotPattern()` options | +| `LinePatternOptions` | `linePattern()` options with line thickness | +| `DotPatternOptions` | `dotPattern()` options with dot radius | | `ChartCurve` | Line and y-area path generation | See [Scales, guides, and color](./scales-guides-and-color.md). diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index 23bead6c..cf9134dd 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -62,10 +62,10 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/examples/themes-and-motion.md: Themes and Motion: Apply inherited palettes and optional motion while keeping chart definitions separate from card layout and controls. - docs/reference/index.md: API Reference: Reference for TanStack Charts definitions, marks, scales, runtime, rendering, interaction, and framework adapters. - docs/reference/chart-definitions.md: Chart Definition API: Reference static and responsive chart definitions, build context, and identity-based updates. -- docs/reference/chart-spec.md: Chart Spec: Reference for marks, axes, color, gradients, clipping, margins, guides, and themes in a TanStack Charts spec. +- docs/reference/chart-spec.md: Chart Spec: Reference for marks, axes, color, gradients, patterns, clipping, margins, guides, and themes in a TanStack Charts spec. - docs/reference/view-composition.md: View Composition: Compose complete chart definitions with deterministic grid, layer, fill, and inset layouts. - docs/reference/transforms.md: Data Transforms: Typed, eager transforms for reusable application data. -- docs/reference/scales-guides-and-color.md: Scales, Guides, and Color: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, and gradients. +- docs/reference/scales-guides-and-color.md: Scales, Guides, and Color: Reference for injected positional scales, automatic axes and margins, color scales, legends, themes, gradients, and patterns. - docs/reference/dom-host.md: DOM Host: Mount, update, size, interact with, and destroy a TanStack Chart in a browser DOM container. - docs/reference/adapter-controller.md: Adapter Controller: Build framework adapters over shared prerender, mount, update, layout, scene access, and cleanup behavior. - docs/reference/runtime-and-scene.md: Runtime and Scene: Compile object and responsive chart definitions into renderer-neutral scenes. From 39023032011b3f92e0ed91a2fc3748caba376fe8 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:25:50 -1000 Subject: [PATCH 03/13] chore: record pattern resource bundle measurements --- benchmarks/bundle-size/README.md | 10 ++- .../bundle-size/universal-baseline.json | 40 ++++----- benchmarks/conformance/previews/manifest.json | 2 +- benchmarks/entries/charts-pattern-svg.ts | 29 +++++++ scripts/measure-bundles.mjs | 85 ++++++++++++------- 5 files changed, 114 insertions(+), 52 deletions(-) create mode 100644 benchmarks/entries/charts-pattern-svg.ts diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 0d3ee2bd..0975d31f 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -26,7 +26,7 @@ compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The scene has a 12.46 KiB gzip ceiling. The React compact-scale line consumer has a -31.03 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +31.14 KiB ceiling with React and React DOM external. `d3-array` tick helpers are allowed only in the compact linear path; categorical compact-scale kernels reject every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, and `internmap`. @@ -60,6 +60,14 @@ Radial resources add 112 gzip bytes to the static SVG consumer and 124 bytes to the DOM host. Scene-only consumers remain unchanged apart from one byte of compression variation. +Pattern resources are part of the default scene and SVG contracts. Copying the +declared list adds 8 gzip bytes to scene-only consumers, and emitting scoped +tiles through the existing node serializer adds 114 to 125 bytes to static SVG +and DOM consumers. Canvas tile rasterization adds 322 bytes to the Canvas +renderer. The `linePattern()` and `dotPattern()` vocabulary stays in its own +subpath: its static SVG fixture adds about 0.27 KiB under a 0.3 KiB incremental +cap, and the ordinary line consumer forbids it. + Every public transform family has an isolated budget and retained-input allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain `d3-shape`. Other transform entries reject those dependencies, while every diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index f0ebd0b4..ce6ebf74 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": 53432, + "gzip": 19902 }, "D3-scale line + static SVG": { - "bytes": 59566, - "gzip": 22210 + "bytes": 59963, + "gzip": 22326 }, "Representative marks": { - "bytes": 83306, - "gzip": 30340 + "bytes": 83703, + "gzip": 30461 }, "TanStack DOM host": { - "bytes": 81870, - "gzip": 28574 + "bytes": 82267, + "gzip": 28688 }, "React adapter": { - "bytes": 84195, - "gzip": 29518 + "bytes": 84592, + "gzip": 29637 }, "React line consumer": { - "bytes": 107854, - "gzip": 38849 + "bytes": 108251, + "gzip": 38967 }, "Compact-scale line scene": { - "bytes": 35865, - "gzip": 12732 + "bytes": 35885, + "gzip": 12739 }, "React compact-scale line consumer": { - "bytes": 90354, - "gzip": 31746 + "bytes": 90751, + "gzip": 31871 }, "Custom-scale line scene": { - "bytes": 34047, - "gzip": 11995 + "bytes": 34067, + "gzip": 12003 }, "D3 linear-scale line scene": { - "bytes": 53344, - "gzip": 19856 + "bytes": 53364, + "gzip": 19864 } } } diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index bc8af86a..be6e27e2 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "4b5e1de199530168188592df376662b15aa5c570dc24bc742e761d312e5fe141", + "sourceHash": "3e6e45ec13a9f1b9c5bc4c0c70dda44c50af86f3019a9c977875ccf9805b4704", "assets": [ { "id": "01-line-gaps", diff --git a/benchmarks/entries/charts-pattern-svg.ts b/benchmarks/entries/charts-pattern-svg.ts new file mode 100644 index 00000000..fb3eb05b --- /dev/null +++ b/benchmarks/entries/charts-pattern-svg.ts @@ -0,0 +1,29 @@ +import { + createChartScene, + defineChart, + lineY, + renderChartSvg, +} from '@tanstack/charts' +import { dotPattern, linePattern } from '@tanstack/charts/pattern' +import { scaleLinear } from 'd3-scale' + +const definition = defineChart({ + marks: [ + lineY([4, 9, 7], { stroke: 'url(#hatch)', strokeWidth: 8 }), + lineY([2, 3, 5], { stroke: 'url(#grain)', strokeWidth: 8 }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + patterns: [ + linePattern({ id: 'hatch', color: 'currentColor', strokeWidth: 2 }), + dotPattern({ id: 'grain', color: 'currentColor', radius: 1 }), + ], +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Patterned area', + }) +} diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..68aa6654 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -125,6 +125,7 @@ const retainedInputGroups = { /(?:^|\/)packages\/charts-core\/src\/interactive-legend\.ts$/u, ], staticLegend: [/(?:^|\/)packages\/charts-core\/src\/legend-static\.ts$/u], + patternResources: [/(?:^|\/)packages\/charts-core\/src\/pattern\.ts$/u], keyedSelection: [/(?:^|\/)packages\/charts-core\/src\/selection\.ts$/u], decorativeMarkPublic: [ /(?:^|\/)packages\/charts-core\/src\/mark-decorative\.ts$/u, @@ -632,6 +633,7 @@ const entries = [ rendererBoundary: 'svg', inputBoundary: { forbid: [ + 'patternResources', 'd3GeometryRuntime', 'networkForce', 'd3Force', @@ -677,37 +679,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 21.64, + 21.73, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 26.24, + 26.34, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 24.25, + 24.37, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 25.46, + 25.57, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 21.6, + 21.7, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 25.64, + 25.75, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 22.0, + 22.11, { inputBoundary: { forbid: [ @@ -767,6 +769,29 @@ const entries = [ }, }, ), + incrementalBudgeted( + 'Line and dot patterns + static SVG', + 'benchmarks/entries/charts-pattern-svg.ts', + 'D3-scale line + static SVG', + 0.3, + { + rendererBoundary: 'svg', + inputBoundary: { + require: ['patternResources'], + forbid: [ + 'focusGuide', + 'focusMark', + 'motionRuntime', + 'springRuntime', + 'tooltipRuntime', + 'tooltipPortal', + 'd3GeometryRuntime', + ], + addedFrom: 'D3-scale line + static SVG', + allowAdded: ['patternResources'], + }, + }, + ), incrementalBudgeted( 'Axis title styles + static SVG', 'benchmarks/entries/charts-axis-label-styles.ts', @@ -791,7 +816,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 12.97, + 13.07, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -930,7 +955,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 30.36, + 30.47, { inputBoundary: { require: [ @@ -1099,7 +1124,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 21.51, + 21.61, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1129,7 +1154,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 21.49, + 21.59, { inputBoundary: { forbid: [ @@ -1169,22 +1194,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 22.45, + 22.55, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 21.68, + 21.77, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 18.93, + 19.05, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 17.93, + 18.04, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1203,18 +1228,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 27.03, + 27.13, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 23.54, + 23.64, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 27.03, + 27.13, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1225,7 +1250,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 28.05, + 28.14, ), locked( 'Representative marks', @@ -1252,7 +1277,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 39.17, + 39.61, { rendererBoundary: 'mixed', inputBoundary: { @@ -1630,7 +1655,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 44.98, + 45.41, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1729,7 +1754,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.03, + 31.14, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1791,7 +1816,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 22.45, + 22.56, { rendererBoundary: 'svg', inputBoundary: { @@ -1897,7 +1922,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 56.77, + 56.87, ), locked( 'Custom-scale line scene', @@ -1933,27 +1958,27 @@ const entries = [ budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 23.96, + 24.05, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 23.0, + 23.11, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 26.24, + 26.34, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.31, + 39.42, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 44.61, + 44.71, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2157,7 +2182,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 57.81, + 57.93, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From 87eaf2fc938c51d29387165379ede871d6c555e9 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:29:15 -1000 Subject: [PATCH 04/13] chore: record cumulative pattern resource comparison measurements --- benchmarks/comparison/bundle-baseline.json | 126 +++++++++--------- docs/comparison.md | 8 +- docs/guides/bundle-size-and-performance.md | 8 +- packages/charts-core/docs/comparison.md | 8 +- .../guides/bundle-size-and-performance.md | 8 +- 5 files changed, 79 insertions(+), 79 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 37d5af0a..d7081d90 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-30T14:33:25.271Z", + "generatedAt": "2026-10-01T01:26:39.771Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "4bbe1c5690789064f2c525226321baa5817af39a", - "inputDigest": "sha256:b07d6531a6ef39c0c4e9295f2b3d5335e67a38f9255517426f757a3efedecaad" + "revision": "b06b0f117763f706978a9b69a01ccb4b32a2fcc2", + "inputDigest": "sha256:6d77671b1dc31187dfea727e7392b0975b7c04f252ed1c43eec57a0e31ebbfac" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117941, - "gzipBytes": 42555, - "brotliBytes": 37623, - "incrementalGzipBytes": 42555, - "incrementalBrotliBytes": 37623 + "minifiedBytes": 118338, + "gzipBytes": 42676, + "brotliBytes": 37789, + "incrementalGzipBytes": 42676, + "incrementalBrotliBytes": 37789 }, "tanstack-line-interactive": { - "minifiedBytes": 123686, - "gzipBytes": 44460, - "brotliBytes": 39218, - "incrementalGzipBytes": 44460, - "incrementalBrotliBytes": 39218 + "minifiedBytes": 124083, + "gzipBytes": 44562, + "brotliBytes": 39308, + "incrementalGzipBytes": 44562, + "incrementalBrotliBytes": 39308 }, "tanstack-line-advanced": { - "minifiedBytes": 130878, - "gzipBytes": 46798, - "brotliBytes": 41196, - "incrementalGzipBytes": 46798, - "incrementalBrotliBytes": 41196 + "minifiedBytes": 131275, + "gzipBytes": 46908, + "brotliBytes": 41309, + "incrementalGzipBytes": 46908, + "incrementalBrotliBytes": 41309 }, "tanstack-bar-basic": { - "minifiedBytes": 130379, - "gzipBytes": 47284, - "brotliBytes": 41635, - "incrementalGzipBytes": 47284, - "incrementalBrotliBytes": 41635 + "minifiedBytes": 130779, + "gzipBytes": 47408, + "brotliBytes": 41661, + "incrementalGzipBytes": 47408, + "incrementalBrotliBytes": 41661 }, "tanstack-bar-interactive": { - "minifiedBytes": 134983, - "gzipBytes": 48679, - "brotliBytes": 42750, - "incrementalGzipBytes": 48679, - "incrementalBrotliBytes": 42750 + "minifiedBytes": 135383, + "gzipBytes": 48800, + "brotliBytes": 42828, + "incrementalGzipBytes": 48800, + "incrementalBrotliBytes": 42828 }, "tanstack-bar-advanced": { - "minifiedBytes": 135322, - "gzipBytes": 48828, - "brotliBytes": 42851, - "incrementalGzipBytes": 48828, - "incrementalBrotliBytes": 42851 + "minifiedBytes": 135722, + "gzipBytes": 48953, + "brotliBytes": 42916, + "incrementalGzipBytes": 48953, + "incrementalBrotliBytes": 42916 }, "tanstack-area-basic": { - "minifiedBytes": 123121, - "gzipBytes": 44596, - "brotliBytes": 39388, - "incrementalGzipBytes": 44596, - "incrementalBrotliBytes": 39388 + "minifiedBytes": 123520, + "gzipBytes": 44725, + "brotliBytes": 39479, + "incrementalGzipBytes": 44725, + "incrementalBrotliBytes": 39479 }, "tanstack-area-interactive": { - "minifiedBytes": 128876, - "gzipBytes": 46452, - "brotliBytes": 40950, - "incrementalGzipBytes": 46452, - "incrementalBrotliBytes": 40950 + "minifiedBytes": 129275, + "gzipBytes": 46579, + "brotliBytes": 41067, + "incrementalGzipBytes": 46579, + "incrementalBrotliBytes": 41067 }, "tanstack-area-advanced": { - "minifiedBytes": 136250, - "gzipBytes": 48807, - "brotliBytes": 43047, - "incrementalGzipBytes": 48807, - "incrementalBrotliBytes": 43047 + "minifiedBytes": 136649, + "gzipBytes": 48915, + "brotliBytes": 43111, + "incrementalGzipBytes": 48915, + "incrementalBrotliBytes": 43111 }, "tanstack-scatter-basic": { - "minifiedBytes": 118947, - "gzipBytes": 42908, - "brotliBytes": 37974, - "incrementalGzipBytes": 42908, - "incrementalBrotliBytes": 37974 + "minifiedBytes": 119344, + "gzipBytes": 43032, + "brotliBytes": 38061, + "incrementalGzipBytes": 43032, + "incrementalBrotliBytes": 38061 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124696, - "gzipBytes": 44826, - "brotliBytes": 39490, - "incrementalGzipBytes": 44826, - "incrementalBrotliBytes": 39490 + "minifiedBytes": 125093, + "gzipBytes": 44944, + "brotliBytes": 39586, + "incrementalGzipBytes": 44944, + "incrementalBrotliBytes": 39586 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124712, - "gzipBytes": 44832, - "brotliBytes": 39527, - "incrementalGzipBytes": 44832, - "incrementalBrotliBytes": 39527 + "minifiedBytes": 125109, + "gzipBytes": 44952, + "brotliBytes": 39582, + "incrementalGzipBytes": 44952, + "incrementalBrotliBytes": 39582 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 1e349fe5..33ad1b1f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `4bbe1c5` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b06b0f1` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `4bbe1c5`. +at page render time. The measured TanStack workspace revision is `b06b0f1`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-30`. +Baseline date: `2026-10-01`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.68–47.81 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/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 26324070..9dcb623f 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.03 KiB gzip. +The locked compact React line consumer must remain at or below 31.14 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,726 gzip bytes -versus 19,890 with D3 linear scales. The equivalent React consumers measure -31,742 and 38,843 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,733 gzip bytes +versus 19,898 with D3 linear scales. The equivalent React consumers measure +31,867 and 38,961 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 1e349fe5..33ad1b1f 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `4bbe1c5` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b06b0f1` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `4bbe1c5`. +at page render time. The measured TanStack workspace revision is `b06b0f1`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-30`. +Baseline date: `2026-10-01`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.68–47.81 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/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 26324070..9dcb623f 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.03 KiB gzip. +The locked compact React line consumer must remain at or below 31.14 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,726 gzip bytes -versus 19,890 with D3 linear scales. The equivalent React consumers measure -31,742 and 38,843 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,733 gzip bytes +versus 19,898 with D3 linear scales. The equivalent React consumers measure +31,867 and 38,961 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. From e63a3599e589885f2e18376a2a8c1906b38c6c6f Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:42:45 -1000 Subject: [PATCH 05/13] fix: scope tile paints and reject duplicate resource ids Pass the SVG paint hooks into pattern tiles so a tile that paints with a gradient gets the scoped id. Reject gradient and pattern ids that collide, since SVG emits both while Canvas picks one. Make ChartScene.patterns required like gradients while renderers still tolerate untyped scenes without it. --- API-FRICTION.md | 18 ++++-- benchmarks/bundle-size/README.md | 11 ++-- .../bundle-size/universal-baseline.json | 40 ++++++------ docs/guides/bundle-size-and-performance.md | 8 +-- docs/reference/runtime-and-scene.md | 4 +- docs/reference/scales-guides-and-color.md | 4 ++ .../guides/bundle-size-and-performance.md | 8 +-- .../docs/reference/runtime-and-scene.md | 4 +- .../docs/reference/scales-guides-and-color.md | 4 ++ packages/charts-core/src/canvas.test.ts | 1 + packages/charts-core/src/exports.test.ts | 3 + packages/charts-core/src/scene.test.ts | 23 +++++++ packages/charts-core/src/scene.ts | 19 ++++-- packages/charts-core/src/svg-renderer.test.ts | 23 ++++++- packages/charts-core/src/svg.ts | 15 +++-- packages/charts-core/src/types.ts | 4 +- .../react-native-charts/src/SvgScene.test.tsx | 1 + .../react-native-charts/src/Tooltip.test.ts | 1 + .../src/interaction.test.ts | 1 + scripts/measure-bundles.mjs | 64 +++++++++---------- 20 files changed, 168 insertions(+), 88 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index bdbac43f..e61d23b6 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -7970,14 +7970,18 @@ Each entry records: origin correction because SVG text keeps the scene origin while Canvas translates to the label. React Native emits `react-native-svg` `Pattern`, which version 15 supports. Embedded views reject child patterns, as they do - child gradients. Animated patterns stay out of scope; a pattern is static + child gradients. Scene compilation rejects an ID shared by two resources, + since SVG would emit both while Canvas would pick one. `ChartScene.patterns` + is required, like `gradients`. SVG rewrites resource paints inside tiles, + so a tile may reference a gradient under `idPrefix`. Animated patterns stay out of scope; a pattern is static until the definition changes. - Follow-up: gradients and patterns are now two sibling resource lists with one paint reference syntax. A later unified paint-resource model could merge them, but this entry keeps the existing gradient contract unchanged. - Verification: SVG unit coverage checks scoped IDs, rotation, factory - clamping, empty tiles, fill and stroke rewriting, and untouched foreign - references. Canvas unit coverage checks the device-resolution tile, CSS + clamping, empty tiles, fill and stroke rewriting, a tile that references a + gradient, and untouched foreign references. Scene coverage rejects duplicate + gradient IDs and a gradient and pattern that share an ID. Canvas unit coverage checks the device-resolution tile, CSS variable resolution through the paint resolver, background-then-foreground order, cached reuse across fill, stroke, and label paint, the rotation matrix, and the label origin. Export coverage proves computed tile paint @@ -7986,9 +7990,11 @@ Each entry records: rasterizes exported SVG and compares rotated hatch and dot fills with Canvas output: the mean channel delta is about 1 of 255 with no divergent pixels, and reversing the Canvas rotation fails the gate. Declaring no - patterns costs 8 gzip bytes in scene-only consumers and 114 to 125 bytes in - static SVG and DOM consumers, comparable to radial gradients. The Canvas - renderer entry grows by 322 gzip bytes for tile rasterization. The + patterns costs about 60 to 70 gzip bytes in scene-only consumers, mostly the + duplicate resource ID check, and 170 to 210 bytes in static SVG, DOM, and + React consumers. Pattern emission alone accounts for 114 to 125 bytes, + comparable to radial gradients. The Canvas renderer entry grows by about + 320 gzip bytes for tile rasterization. The factories add about 0.27 KiB under an isolated 0.3 KiB budget. ### F-260 — Static guides cannot express stroke treatment diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 0975d31f..fb0d5118 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,8 +25,8 @@ may add only its transport module over the tooltip consumer. Ordinary line, compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The -scene has a 12.46 KiB gzip ceiling. The React compact-scale line consumer has a -31.14 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +scene has a 12.52 KiB gzip ceiling. The React compact-scale line consumer has a +31.21 KiB ceiling with React and React DOM external. `d3-array` tick helpers are allowed only in the compact linear path; categorical compact-scale kernels reject every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, and `internmap`. @@ -61,9 +61,10 @@ gzip bytes to the static SVG consumer and 124 bytes to the DOM host. Scene-only consumers remain unchanged apart from one byte of compression variation. Pattern resources are part of the default scene and SVG contracts. Copying the -declared list adds 8 gzip bytes to scene-only consumers, and emitting scoped -tiles through the existing node serializer adds 114 to 125 bytes to static SVG -and DOM consumers. Canvas tile rasterization adds 322 bytes to the Canvas +declared list and rejecting duplicate gradient or pattern IDs adds about 60 to +70 gzip bytes to scene-only consumers. With scoped tile emission through the +existing node serializer, static SVG, DOM, and React consumers grow by 170 to +210 bytes. Canvas tile rasterization adds about 320 bytes to the Canvas renderer. The `linePattern()` and `dotPattern()` vocabulary stays in its own subpath: its static SVG fixture adds about 0.27 KiB under a 0.3 KiB incremental cap, and the ordinary line consumer forbids it. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index ce6ebf74..4640747c 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": 53432, - "gzip": 19902 + "bytes": 53558, + "gzip": 19961 }, "D3-scale line + static SVG": { - "bytes": 59963, - "gzip": 22326 + "bytes": 60099, + "gzip": 22379 }, "Representative marks": { - "bytes": 83703, - "gzip": 30461 + "bytes": 83842, + "gzip": 30525 }, "TanStack DOM host": { - "bytes": 82267, - "gzip": 28688 + "bytes": 82398, + "gzip": 28757 }, "React adapter": { - "bytes": 84592, - "gzip": 29637 + "bytes": 84721, + "gzip": 29702 }, "React line consumer": { - "bytes": 108251, - "gzip": 38967 + "bytes": 108381, + "gzip": 39057 }, "Compact-scale line scene": { - "bytes": 35885, - "gzip": 12739 + "bytes": 36008, + "gzip": 12802 }, "React compact-scale line consumer": { - "bytes": 90751, - "gzip": 31871 + "bytes": 90881, + "gzip": 31941 }, "Custom-scale line scene": { - "bytes": 34067, - "gzip": 12003 + "bytes": 34190, + "gzip": 12065 }, "D3 linear-scale line scene": { - "bytes": 53364, - "gzip": 19864 + "bytes": 53490, + "gzip": 19923 } } } diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 9dcb623f..78443ba5 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.14 KiB gzip. +The locked compact React line consumer must remain at or below 31.21 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,733 gzip bytes -versus 19,898 with D3 linear scales. The equivalent React consumers measure -31,867 and 38,961 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,796 gzip bytes +versus 19,957 with D3 linear scales. The equivalent React consumers measure +31,935 and 39,049 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/docs/reference/runtime-and-scene.md b/docs/reference/runtime-and-scene.md index 28957db1..97e33e94 100644 --- a/docs/reference/runtime-and-scene.md +++ b/docs/reference/runtime-and-scene.md @@ -197,7 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - patterns?: readonly ChartPattern[] + patterns: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -213,7 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | -| `patterns` | Declared pattern resources, when the definition has any | +| `patterns` | Declared pattern resources | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index f4321a66..e71d0634 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -980,6 +980,10 @@ A missing, negative, or non-finite factory size uses its default. Factories round sizes and angles to hundredths, the precision SVG serialization uses, so SVG output and Canvas tiles agree. +Gradient and pattern IDs share one namespace; scene compilation throws a +`TypeError` when two resources use the same ID. Tile nodes may paint with +`url(#gradient-id)`, and SVG scopes that reference like any other. + Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. SVG and React Native emit a native ``. Canvas rasterizes one tile at the device pixel ratio and repeats it under the same rotation, including for diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 9dcb623f..78443ba5 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.14 KiB gzip. +The locked compact React line consumer must remain at or below 31.21 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,733 gzip bytes -versus 19,898 with D3 linear scales. The equivalent React consumers measure -31,867 and 38,961 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,796 gzip bytes +versus 19,957 with D3 linear scales. The equivalent React consumers measure +31,935 and 39,049 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/packages/charts-core/docs/reference/runtime-and-scene.md b/packages/charts-core/docs/reference/runtime-and-scene.md index 28957db1..97e33e94 100644 --- a/packages/charts-core/docs/reference/runtime-and-scene.md +++ b/packages/charts-core/docs/reference/runtime-and-scene.md @@ -197,7 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - patterns?: readonly ChartPattern[] + patterns: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -213,7 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | -| `patterns` | Declared pattern resources, when the definition has any | +| `patterns` | Declared pattern resources | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index f4321a66..e71d0634 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -980,6 +980,10 @@ A missing, negative, or non-finite factory size uses its default. Factories round sizes and angles to hundredths, the precision SVG serialization uses, so SVG output and Canvas tiles agree. +Gradient and pattern IDs share one namespace; scene compilation throws a +`TypeError` when two resources use the same ID. Tile nodes may paint with +`url(#gradient-id)`, and SVG scopes that reference like any other. + Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. SVG and React Native emit a native ``. Canvas rasterizes one tile at the device pixel ratio and repeats it under the same rotation, including for diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 2d805fe7..5306ab10 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -3286,6 +3286,7 @@ function scene( range: [], map: () => '#2563eb', }, + patterns: [], gradients, theme: { foreground: '#111111', diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 0f691a6e..26cb4dfc 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -7,6 +7,7 @@ import type { ChartRadialGradient as RootChartRadialGradient, ChartPattern as RootChartPattern, LinePatternOptions as RootLinePatternOptions, + DotPatternOptions as RootDotPatternOptions, ChartGuideLineStyle as RootChartGuideLineStyle, CreateDotLayoutOptions as RootCreateDotLayoutOptions, DotLayout as RootDotLayout, @@ -19,6 +20,7 @@ import type { ChartRadialGradient as UniversalChartRadialGradient, ChartPattern as UniversalChartPattern, LinePatternOptions as UniversalLinePatternOptions, + DotPatternOptions as UniversalDotPatternOptions, ChartGuideLineStyle as UniversalChartGuideLineStyle, CreateDotLayoutOptions as UniversalCreateDotLayoutOptions, DotLayout as UniversalDotLayout, @@ -131,6 +133,7 @@ describe('public package exports', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() }) it('keeps guide line styles aligned across public type barrels', () => { diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index e4c6c19d..1513ac42 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -3,6 +3,7 @@ import { bandX, bandY } from './band' import { mountChart } from './dom' import { frame } from './frame' import { lineY } from './line' +import { linePattern } from './pattern' import { colorLegend } from './legend' import { createMark } from './mark' import { @@ -518,6 +519,28 @@ describe('native mark and channel scene', () => { ).toThrow('Duplicate chart control id "duplicate"') }) + it.each([ + ['gradients', [{ id: 'fill', stops: [] }], []], + ['a gradient and a pattern', [{ id: 'fill', stops: [] }], ['fill']], + ] as const)( + 'rejects duplicate resource ids across %s', + (_, gradients, patterns) => { + expect(() => + createChartScene( + defineChart({ + marks: [lineY([1, 3, 2])], + gradients: patterns.length + ? gradients + : [...gradients, ...gradients], + patterns: patterns.map((id) => linePattern({ id, color: 'red' })), + ...linearAxes([0, 2], [0, 3]), + }), + { width: 480, height: 260 }, + ), + ).toThrow('Duplicate chart resource id "fill"') + }, + ) + it('rejects duplicate host-control identities from behavior output', () => { const extension = { id: 'duplicate-control', create: () => ({}) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 48d94381..f7f718a9 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -492,16 +492,20 @@ function createChartSceneWithScaleResolver< if (scaleGuides.length) { nodes.push(axisNodes) } + const resourceIds = new Set() + for (const { id } of [ + ...(definition.gradients ?? []), + ...(definition.patterns ?? []), + ]) { + addUniqueId(resourceIds, id, 'resource') + } const controls: ChartHostControl[] = [] const controlIds = new Set() for (const control of definition.controls ?? []) { if (!control.id.trim()) { throw new TypeError('Chart control ids must be nonempty') } - if (controlIds.has(control.id)) { - throw new TypeError(`Duplicate chart control id "${control.id}"`) - } - controlIds.add(control.id) + addUniqueId(controlIds, control.id, 'control') const resolved = control.resolve({ chart, scales, @@ -584,7 +588,7 @@ function createChartSceneWithScaleResolver< scales, colors, gradients: definition.gradients ?? [], - patterns: definition.patterns, + patterns: definition.patterns ?? [], theme, ...(layoutOptions.typography?.direction === undefined ? {} @@ -1999,3 +2003,8 @@ function formatAxisValue(value: ChartValue): string { function finiteSize(value: number): number { return Number.isFinite(value) ? Math.max(1, value) : 1 } + +function addUniqueId(ids: Set, id: string, kind: string): void { + if (ids.has(id)) throw new TypeError(`Duplicate chart ${kind} id "${id}"`) + ids.add(id) +} diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index ae430087..8027a475 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -320,17 +320,37 @@ describe('SVG scene renderer', () => { angle: 30, }), { id: 'flat', width: 4, height: 4, nodes: [] }, + { + id: 'shaded', + width: 4, + height: 4, + nodes: [ + { + kind: 'rect', + key: 'shaded-cell', + x: 0, + y: 0, + width: 4, + height: 4, + style: { fill: 'url(#ramp)' }, + }, + ], + }, ], + gradients: [{ id: 'ramp', stops: [{ offset: 0, color: '#000' }] }], }, { ariaLabel: 'Patterns', idPrefix: 'chart.one' }, ) expect(svg).toContain( - '', + '', ) expect(svg).toContain( 'fill="url(#chartone-hatch)" stroke="url(#chartone-grain)"', ) + expect(svg).toContain( + ' '#2563eb', }, + patterns: [], gradients: [], theme: { foreground: '#111111', diff --git a/packages/charts-core/src/svg.ts b/packages/charts-core/src/svg.ts index d0f0fcf4..40e0c637 100644 --- a/packages/charts-core/src/svg.ts +++ b/packages/charts-core/src/svg.ts @@ -16,10 +16,10 @@ export function renderChartSvg( (resource) => resource.id, ), ) - return renderChartSvgWithHooks(scene, options, { + const hooks: ChartSvgRenderHooks = { renderDefinitions: (currentScene, idPrefix) => renderGradients(currentScene, sanitizeId(idPrefix)) + - renderPatterns(currentScene, sanitizeId(idPrefix)), + renderPatterns(currentScene, sanitizeId(idPrefix), hooks), renderGroup: renderSvgClip, resolvePaint: resourceIds.size ? (value, idPrefix) => { @@ -30,7 +30,8 @@ export function renderChartSvg( : value } : undefined, - } satisfies ChartSvgRenderHooks) + } + return renderChartSvgWithHooks(scene, options, hooks) } function renderGradients(scene: ChartScene, idPrefix: string) { @@ -55,12 +56,16 @@ function renderGradients(scene: ChartScene, idPrefix: string) { .join('')}` } -function renderPatterns(scene: ChartScene, idPrefix: string) { +function renderPatterns( + scene: ChartScene, + idPrefix: string, + hooks: ChartSvgRenderHooks, +) { if (!scene.patterns?.length) return '' return `${scene.patterns .map( (pattern) => - `${renderSceneNodes(pattern.nodes, idPrefix)}`, + `${renderSceneNodes(pattern.nodes, idPrefix, hooks)}`, ) .join('')}` } diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 6ed34a5b..63e8ffbc 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -1608,8 +1608,8 @@ export interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - /** Declared pattern resources. Hand-built scenes may omit this field. */ - patterns?: readonly ChartPattern[] + /** Renderers treat a missing list in an untyped scene as empty. */ + patterns: readonly ChartPattern[] theme: ChartTheme direction?: ChartTextTypography['direction'] controls?: readonly ChartHostControl[] diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 9946f437..a413850d 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -820,6 +820,7 @@ function scene(): ChartScene { range: [], map: () => '#2563eb', }, + patterns: [], gradients: [ { id: 'fill', diff --git a/packages/react-native-charts/src/Tooltip.test.ts b/packages/react-native-charts/src/Tooltip.test.ts index 0b0841b8..1a78e464 100644 --- a/packages/react-native-charts/src/Tooltip.test.ts +++ b/packages/react-native-charts/src/Tooltip.test.ts @@ -371,6 +371,7 @@ function scene( range: ['#2563eb', '#f97316'], map: (value) => (value === 'beta' ? '#f97316' : '#2563eb'), }, + patterns: [], gradients: [], theme: { foreground: '#111827', diff --git a/packages/react-native-charts/src/interaction.test.ts b/packages/react-native-charts/src/interaction.test.ts index fc6f2b7e..b7172844 100644 --- a/packages/react-native-charts/src/interaction.test.ts +++ b/packages/react-native-charts/src/interaction.test.ts @@ -207,6 +207,7 @@ function chartScene( range: ['#2563eb', '#f97316'], map: (value) => (value === 'beta' ? '#f97316' : '#2563eb'), }, + patterns: [], gradients: [], theme: { foreground: '#111827', diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 68aa6654..8e32062d 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -679,37 +679,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 21.73, + 21.81, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 26.34, + 26.4, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 24.37, + 24.44, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 25.57, + 25.63, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 21.7, + 21.75, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 25.75, + 25.81, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 22.11, + 22.17, { inputBoundary: { forbid: [ @@ -816,7 +816,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 13.07, + 13.14, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -955,7 +955,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 30.47, + 30.53, { inputBoundary: { require: [ @@ -1124,7 +1124,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 21.61, + 21.68, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1154,7 +1154,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 21.59, + 21.65, { inputBoundary: { forbid: [ @@ -1194,22 +1194,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 22.55, + 22.62, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 21.77, + 21.84, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 19.05, + 19.12, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 18.04, + 18.1, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1228,18 +1228,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 27.13, + 27.19, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 23.64, + 23.7, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 27.13, + 27.19, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1250,7 +1250,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 28.14, + 28.2, ), locked( 'Representative marks', @@ -1277,7 +1277,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 39.61, + 39.67, { rendererBoundary: 'mixed', inputBoundary: { @@ -1655,7 +1655,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.41, + 45.48, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1735,7 +1735,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 12.46, + 12.52, { inputBoundary: { require: ['compactLinear'], @@ -1754,7 +1754,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.14, + 31.21, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1922,7 +1922,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 56.87, + 56.95, ), locked( 'Custom-scale line scene', @@ -1948,37 +1948,37 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 21.7, + 21.75, ), budgeted( 'D3 time-scale line scene', 'benchmarks/entries/charts-d3-time-scene.ts', - 23.99, + 24.05, ), budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 24.05, + 24.12, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 23.11, + 23.18, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 26.34, + 26.4, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.42, + 39.5, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 44.71, + 44.77, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2182,7 +2182,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 57.93, + 57.99, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From 5972f55e51d6628557cfb033ec871252be721e22 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:51:14 -1000 Subject: [PATCH 06/13] chore: refresh pattern resource comparison measurements --- benchmarks/comparison/bundle-baseline.json | 126 +++++++++--------- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 6 +- packages/charts-core/docs/comparison.md | 6 +- 4 files changed, 70 insertions(+), 70 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index d7081d90..a84abeea 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-01T01:26:39.771Z", + "generatedAt": "2026-10-01T01:47:53.284Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "b06b0f117763f706978a9b69a01ccb4b32a2fcc2", - "inputDigest": "sha256:6d77671b1dc31187dfea727e7392b0975b7c04f252ed1c43eec57a0e31ebbfac" + "revision": "e63a3599e589885f2e18376a2a8c1906b38c6c6f", + "inputDigest": "sha256:3552e1b97c82a059b0a11000b89ea6b078747033ef0414be821e97d608170825" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 118338, - "gzipBytes": 42676, - "brotliBytes": 37789, - "incrementalGzipBytes": 42676, - "incrementalBrotliBytes": 37789 + "minifiedBytes": 118469, + "gzipBytes": 42757, + "brotliBytes": 37895, + "incrementalGzipBytes": 42757, + "incrementalBrotliBytes": 37895 }, "tanstack-line-interactive": { - "minifiedBytes": 124083, - "gzipBytes": 44562, - "brotliBytes": 39308, - "incrementalGzipBytes": 44562, - "incrementalBrotliBytes": 39308 + "minifiedBytes": 124214, + "gzipBytes": 44672, + "brotliBytes": 39374, + "incrementalGzipBytes": 44672, + "incrementalBrotliBytes": 39374 }, "tanstack-line-advanced": { - "minifiedBytes": 131275, - "gzipBytes": 46908, - "brotliBytes": 41309, - "incrementalGzipBytes": 46908, - "incrementalBrotliBytes": 41309 + "minifiedBytes": 131405, + "gzipBytes": 46978, + "brotliBytes": 41350, + "incrementalGzipBytes": 46978, + "incrementalBrotliBytes": 41350 }, "tanstack-bar-basic": { - "minifiedBytes": 130779, - "gzipBytes": 47408, - "brotliBytes": 41661, - "incrementalGzipBytes": 47408, - "incrementalBrotliBytes": 41661 + "minifiedBytes": 130909, + "gzipBytes": 47492, + "brotliBytes": 41709, + "incrementalGzipBytes": 47492, + "incrementalBrotliBytes": 41709 }, "tanstack-bar-interactive": { - "minifiedBytes": 135383, - "gzipBytes": 48800, - "brotliBytes": 42828, - "incrementalGzipBytes": 48800, - "incrementalBrotliBytes": 42828 + "minifiedBytes": 135513, + "gzipBytes": 48870, + "brotliBytes": 42903, + "incrementalGzipBytes": 48870, + "incrementalBrotliBytes": 42903 }, "tanstack-bar-advanced": { - "minifiedBytes": 135722, - "gzipBytes": 48953, - "brotliBytes": 42916, - "incrementalGzipBytes": 48953, - "incrementalBrotliBytes": 42916 + "minifiedBytes": 135852, + "gzipBytes": 49015, + "brotliBytes": 43090, + "incrementalGzipBytes": 49015, + "incrementalBrotliBytes": 43090 }, "tanstack-area-basic": { - "minifiedBytes": 123520, - "gzipBytes": 44725, - "brotliBytes": 39479, - "incrementalGzipBytes": 44725, - "incrementalBrotliBytes": 39479 + "minifiedBytes": 123648, + "gzipBytes": 44790, + "brotliBytes": 39554, + "incrementalGzipBytes": 44790, + "incrementalBrotliBytes": 39554 }, "tanstack-area-interactive": { - "minifiedBytes": 129275, - "gzipBytes": 46579, - "brotliBytes": 41067, - "incrementalGzipBytes": 46579, - "incrementalBrotliBytes": 41067 + "minifiedBytes": 129403, + "gzipBytes": 46638, + "brotliBytes": 41174, + "incrementalGzipBytes": 46638, + "incrementalBrotliBytes": 41174 }, "tanstack-area-advanced": { - "minifiedBytes": 136649, - "gzipBytes": 48915, - "brotliBytes": 43111, - "incrementalGzipBytes": 48915, - "incrementalBrotliBytes": 43111 + "minifiedBytes": 136777, + "gzipBytes": 49011, + "brotliBytes": 43223, + "incrementalGzipBytes": 49011, + "incrementalBrotliBytes": 43223 }, "tanstack-scatter-basic": { - "minifiedBytes": 119344, - "gzipBytes": 43032, - "brotliBytes": 38061, - "incrementalGzipBytes": 43032, - "incrementalBrotliBytes": 38061 + "minifiedBytes": 119475, + "gzipBytes": 43109, + "brotliBytes": 38140, + "incrementalGzipBytes": 43109, + "incrementalBrotliBytes": 38140 }, "tanstack-scatter-interactive": { - "minifiedBytes": 125093, - "gzipBytes": 44944, - "brotliBytes": 39586, - "incrementalGzipBytes": 44944, - "incrementalBrotliBytes": 39586 + "minifiedBytes": 125224, + "gzipBytes": 45030, + "brotliBytes": 39652, + "incrementalGzipBytes": 45030, + "incrementalBrotliBytes": 39652 }, "tanstack-scatter-advanced": { - "minifiedBytes": 125109, - "gzipBytes": 44952, - "brotliBytes": 39582, - "incrementalGzipBytes": 44952, - "incrementalBrotliBytes": 39582 + "minifiedBytes": 125240, + "gzipBytes": 45036, + "brotliBytes": 39683, + "incrementalGzipBytes": 45036, + "incrementalBrotliBytes": 39683 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index be6e27e2..67d73365 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "3e6e45ec13a9f1b9c5bc4c0c70dda44c50af86f3019a9c977875ccf9805b4704", + "sourceHash": "385939bfc0530871ccd72805be4aba70ad5e757a79c5a72d9dbc3e81ea0d99e4", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 33ad1b1f..9da17bbd 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 `b06b0f1` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e63a359` | | [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 `b06b0f1`. +at page render time. The measured TanStack workspace revision is `e63a359`. ## Capability matrix @@ -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.68–47.81 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.75–47.87 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 33ad1b1f..9da17bbd 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 `b06b0f1` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e63a359` | | [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 `b06b0f1`. +at page render time. The measured TanStack workspace revision is `e63a359`. ## Capability matrix @@ -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.68–47.81 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.75–47.87 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | From df7f1038dac39c559e6177d2de1a4079e1ceba51 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 16:00:53 -1000 Subject: [PATCH 07/13] revert: drop the duplicate resource id check and required scene patterns Keep ChartScene.patterns optional and leave gradient and pattern id collisions undefined, as documented in the reference. The check cost about 60 gzip bytes in every default bundle and newly rejected duplicate gradient ids. Restore bundle budgets and baselines to measured values. --- API-FRICTION.md | 20 +++--- benchmarks/bundle-size/README.md | 11 ++-- .../bundle-size/universal-baseline.json | 40 +++++------ benchmarks/conformance/previews/manifest.json | 2 +- docs/guides/bundle-size-and-performance.md | 8 +-- docs/reference/runtime-and-scene.md | 4 +- docs/reference/scales-guides-and-color.md | 4 +- .../guides/bundle-size-and-performance.md | 8 +-- .../docs/reference/runtime-and-scene.md | 4 +- .../docs/reference/scales-guides-and-color.md | 4 +- packages/charts-core/src/canvas.test.ts | 1 - packages/charts-core/src/scene.test.ts | 23 ------- packages/charts-core/src/scene.ts | 19 ++---- packages/charts-core/src/types.ts | 4 +- .../react-native-charts/src/SvgScene.test.tsx | 1 - .../react-native-charts/src/Tooltip.test.ts | 1 - .../src/interaction.test.ts | 1 - scripts/measure-bundles.mjs | 66 +++++++++---------- 18 files changed, 92 insertions(+), 129 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index e61d23b6..f156cf01 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -7970,18 +7970,19 @@ Each entry records: origin correction because SVG text keeps the scene origin while Canvas translates to the label. React Native emits `react-native-svg` `Pattern`, which version 15 supports. Embedded views reject child patterns, as they do - child gradients. Scene compilation rejects an ID shared by two resources, - since SVG would emit both while Canvas would pick one. `ChartScene.patterns` - is required, like `gradients`. SVG rewrites resource paints inside tiles, - so a tile may reference a gradient under `idPrefix`. Animated patterns stay out of scope; a pattern is static + child gradients. SVG rewrites resource paints inside tiles, so a tile may + reference a gradient under `idPrefix`. Gradient and pattern IDs share one + namespace and must be unique; collisions are undefined. A scene-time + duplicate check measured about 60 gzip bytes on every default bundle and + would newly reject duplicate gradient IDs, so it is left for maintainers to + decide. Animated patterns stay out of scope; a pattern is static until the definition changes. - Follow-up: gradients and patterns are now two sibling resource lists with one paint reference syntax. A later unified paint-resource model could merge them, but this entry keeps the existing gradient contract unchanged. - Verification: SVG unit coverage checks scoped IDs, rotation, factory clamping, empty tiles, fill and stroke rewriting, a tile that references a - gradient, and untouched foreign references. Scene coverage rejects duplicate - gradient IDs and a gradient and pattern that share an ID. Canvas unit coverage checks the device-resolution tile, CSS + gradient, and untouched foreign references. Canvas unit coverage checks the device-resolution tile, CSS variable resolution through the paint resolver, background-then-foreground order, cached reuse across fill, stroke, and label paint, the rotation matrix, and the label origin. Export coverage proves computed tile paint @@ -7990,10 +7991,9 @@ Each entry records: rasterizes exported SVG and compares rotated hatch and dot fills with Canvas output: the mean channel delta is about 1 of 255 with no divergent pixels, and reversing the Canvas rotation fails the gate. Declaring no - patterns costs about 60 to 70 gzip bytes in scene-only consumers, mostly the - duplicate resource ID check, and 170 to 210 bytes in static SVG, DOM, and - React consumers. Pattern emission alone accounts for 114 to 125 bytes, - comparable to radial gradients. The Canvas renderer entry grows by about + patterns costs 7 to 8 gzip bytes in scene-only consumers and 123 to 139 + bytes in static SVG, DOM, and React consumers, comparable to radial + gradients. The Canvas renderer entry grows by about 320 gzip bytes for tile rasterization. The factories add about 0.27 KiB under an isolated 0.3 KiB budget. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index fb0d5118..ded8000e 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,8 +25,8 @@ may add only its transport module over the tooltip consumer. Ordinary line, compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The -scene has a 12.52 KiB gzip ceiling. The React compact-scale line consumer has a -31.21 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +scene has a 12.46 KiB gzip ceiling. The React compact-scale line consumer has a +31.16 KiB ceiling with React and React DOM external. `d3-array` tick helpers are allowed only in the compact linear path; categorical compact-scale kernels reject every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, and `internmap`. @@ -61,10 +61,9 @@ gzip bytes to the static SVG consumer and 124 bytes to the DOM host. Scene-only consumers remain unchanged apart from one byte of compression variation. Pattern resources are part of the default scene and SVG contracts. Copying the -declared list and rejecting duplicate gradient or pattern IDs adds about 60 to -70 gzip bytes to scene-only consumers. With scoped tile emission through the -existing node serializer, static SVG, DOM, and React consumers grow by 170 to -210 bytes. Canvas tile rasterization adds about 320 bytes to the Canvas +declared list adds 7 to 8 gzip bytes to scene-only consumers. Emitting scoped +tiles through the existing node serializer adds 123 to 139 bytes to static +SVG, DOM, and React consumers. Canvas tile rasterization adds about 320 bytes to the Canvas renderer. The `linePattern()` and `dotPattern()` vocabulary stays in its own subpath: its static SVG fixture adds about 0.27 KiB under a 0.3 KiB incremental cap, and the ordinary line consumer forbids it. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 4640747c..d0a49a90 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": 53558, - "gzip": 19961 + "bytes": 53432, + "gzip": 19902 }, "D3-scale line + static SVG": { - "bytes": 60099, - "gzip": 22379 + "bytes": 59973, + "gzip": 22337 }, "Representative marks": { - "bytes": 83842, - "gzip": 30525 + "bytes": 83713, + "gzip": 30471 }, "TanStack DOM host": { - "bytes": 82398, - "gzip": 28757 + "bytes": 82277, + "gzip": 28697 }, "React adapter": { - "bytes": 84721, - "gzip": 29702 + "bytes": 84602, + "gzip": 29655 }, "React line consumer": { - "bytes": 108381, - "gzip": 39057 + "bytes": 108261, + "gzip": 38981 }, "Compact-scale line scene": { - "bytes": 36008, - "gzip": 12802 + "bytes": 35885, + "gzip": 12739 }, "React compact-scale line consumer": { - "bytes": 90881, - "gzip": 31941 + "bytes": 90761, + "gzip": 31885 }, "Custom-scale line scene": { - "bytes": 34190, - "gzip": 12065 + "bytes": 34067, + "gzip": 12003 }, "D3 linear-scale line scene": { - "bytes": 53490, - "gzip": 19923 + "bytes": 53364, + "gzip": 19864 } } } diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 67d73365..1c92250f 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "385939bfc0530871ccd72805be4aba70ad5e757a79c5a72d9dbc3e81ea0d99e4", + "sourceHash": "681b9a38c4435773cadaa80b7f01af8dad6388f56a2eba0e46ad5091b4a2f9aa", "assets": [ { "id": "01-line-gaps", diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 78443ba5..d0191bc4 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.21 KiB gzip. +The locked compact React line consumer must remain at or below 31.16 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,796 gzip bytes -versus 19,957 with D3 linear scales. The equivalent React consumers measure -31,935 and 39,049 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,733 gzip bytes +versus 19,898 with D3 linear scales. The equivalent React consumers measure +31,881 and 38,975 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/docs/reference/runtime-and-scene.md b/docs/reference/runtime-and-scene.md index 97e33e94..28957db1 100644 --- a/docs/reference/runtime-and-scene.md +++ b/docs/reference/runtime-and-scene.md @@ -197,7 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - patterns: readonly ChartPattern[] + patterns?: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -213,7 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | -| `patterns` | Declared pattern resources | +| `patterns` | Declared pattern resources, when the definition has any | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index e71d0634..4569b45d 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -980,8 +980,8 @@ A missing, negative, or non-finite factory size uses its default. Factories round sizes and angles to hundredths, the precision SVG serialization uses, so SVG output and Canvas tiles agree. -Gradient and pattern IDs share one namespace; scene compilation throws a -`TypeError` when two resources use the same ID. Tile nodes may paint with +Gradient and pattern IDs share the `url(#id)` namespace and must be unique +across both lists. Behavior on a collision is undefined. Tile nodes may paint with `url(#gradient-id)`, and SVG scopes that reference like any other. Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 78443ba5..d0191bc4 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 31.21 KiB gzip. +The locked compact React line consumer must remain at or below 31.16 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,796 gzip bytes -versus 19,957 with D3 linear scales. The equivalent React consumers measure -31,935 and 39,049 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,733 gzip bytes +versus 19,898 with D3 linear scales. The equivalent React consumers measure +31,881 and 38,975 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/packages/charts-core/docs/reference/runtime-and-scene.md b/packages/charts-core/docs/reference/runtime-and-scene.md index 97e33e94..28957db1 100644 --- a/packages/charts-core/docs/reference/runtime-and-scene.md +++ b/packages/charts-core/docs/reference/runtime-and-scene.md @@ -197,7 +197,7 @@ interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - patterns: readonly ChartPattern[] + patterns?: readonly ChartPattern[] theme: ChartTheme focusGuides?: readonly SceneFocusGuide[] } @@ -213,7 +213,7 @@ interface ChartScene< | `scales` | Resolved positional scales, normally under `x` and `y` | | `colors` | Resolved chart color scale | | `gradients` | Declared linear and radial gradient resources | -| `patterns` | Declared pattern resources | +| `patterns` | Declared pattern resources, when the definition has any | | `theme` | Fully resolved theme | | `focusGuides` | Optional data-less guide descriptors resolved from current focus | diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index e71d0634..4569b45d 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -980,8 +980,8 @@ A missing, negative, or non-finite factory size uses its default. Factories round sizes and angles to hundredths, the precision SVG serialization uses, so SVG output and Canvas tiles agree. -Gradient and pattern IDs share one namespace; scene compilation throws a -`TypeError` when two resources use the same ID. Tile nodes may paint with +Gradient and pattern IDs share the `url(#id)` namespace and must be unique +across both lists. Behavior on a collision is undefined. Tile nodes may paint with `url(#gradient-id)`, and SVG scopes that reference like any other. Reference a pattern from a mark's `fill` or `stroke` as `url(#pattern-id)`. diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 5306ab10..2d805fe7 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -3286,7 +3286,6 @@ function scene( range: [], map: () => '#2563eb', }, - patterns: [], gradients, theme: { foreground: '#111111', diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 1513ac42..e4c6c19d 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -3,7 +3,6 @@ import { bandX, bandY } from './band' import { mountChart } from './dom' import { frame } from './frame' import { lineY } from './line' -import { linePattern } from './pattern' import { colorLegend } from './legend' import { createMark } from './mark' import { @@ -519,28 +518,6 @@ describe('native mark and channel scene', () => { ).toThrow('Duplicate chart control id "duplicate"') }) - it.each([ - ['gradients', [{ id: 'fill', stops: [] }], []], - ['a gradient and a pattern', [{ id: 'fill', stops: [] }], ['fill']], - ] as const)( - 'rejects duplicate resource ids across %s', - (_, gradients, patterns) => { - expect(() => - createChartScene( - defineChart({ - marks: [lineY([1, 3, 2])], - gradients: patterns.length - ? gradients - : [...gradients, ...gradients], - patterns: patterns.map((id) => linePattern({ id, color: 'red' })), - ...linearAxes([0, 2], [0, 3]), - }), - { width: 480, height: 260 }, - ), - ).toThrow('Duplicate chart resource id "fill"') - }, - ) - it('rejects duplicate host-control identities from behavior output', () => { const extension = { id: 'duplicate-control', create: () => ({}) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index f7f718a9..48d94381 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -492,20 +492,16 @@ function createChartSceneWithScaleResolver< if (scaleGuides.length) { nodes.push(axisNodes) } - const resourceIds = new Set() - for (const { id } of [ - ...(definition.gradients ?? []), - ...(definition.patterns ?? []), - ]) { - addUniqueId(resourceIds, id, 'resource') - } const controls: ChartHostControl[] = [] const controlIds = new Set() for (const control of definition.controls ?? []) { if (!control.id.trim()) { throw new TypeError('Chart control ids must be nonempty') } - addUniqueId(controlIds, control.id, 'control') + if (controlIds.has(control.id)) { + throw new TypeError(`Duplicate chart control id "${control.id}"`) + } + controlIds.add(control.id) const resolved = control.resolve({ chart, scales, @@ -588,7 +584,7 @@ function createChartSceneWithScaleResolver< scales, colors, gradients: definition.gradients ?? [], - patterns: definition.patterns ?? [], + patterns: definition.patterns, theme, ...(layoutOptions.typography?.direction === undefined ? {} @@ -2003,8 +1999,3 @@ function formatAxisValue(value: ChartValue): string { function finiteSize(value: number): number { return Number.isFinite(value) ? Math.max(1, value) : 1 } - -function addUniqueId(ids: Set, id: string, kind: string): void { - if (ids.has(id)) throw new TypeError(`Duplicate chart ${kind} id "${id}"`) - ids.add(id) -} diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 63e8ffbc..6ed34a5b 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -1608,8 +1608,8 @@ export interface ChartScene< scales: Readonly> colors: ResolvedColorScale gradients: readonly ChartGradient[] - /** Renderers treat a missing list in an untyped scene as empty. */ - patterns: readonly ChartPattern[] + /** Declared pattern resources. Hand-built scenes may omit this field. */ + patterns?: readonly ChartPattern[] theme: ChartTheme direction?: ChartTextTypography['direction'] controls?: readonly ChartHostControl[] diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index a413850d..9946f437 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -820,7 +820,6 @@ function scene(): ChartScene { range: [], map: () => '#2563eb', }, - patterns: [], gradients: [ { id: 'fill', diff --git a/packages/react-native-charts/src/Tooltip.test.ts b/packages/react-native-charts/src/Tooltip.test.ts index 1a78e464..0b0841b8 100644 --- a/packages/react-native-charts/src/Tooltip.test.ts +++ b/packages/react-native-charts/src/Tooltip.test.ts @@ -371,7 +371,6 @@ function scene( range: ['#2563eb', '#f97316'], map: (value) => (value === 'beta' ? '#f97316' : '#2563eb'), }, - patterns: [], gradients: [], theme: { foreground: '#111827', diff --git a/packages/react-native-charts/src/interaction.test.ts b/packages/react-native-charts/src/interaction.test.ts index b7172844..fc6f2b7e 100644 --- a/packages/react-native-charts/src/interaction.test.ts +++ b/packages/react-native-charts/src/interaction.test.ts @@ -207,7 +207,6 @@ function chartScene( range: ['#2563eb', '#f97316'], map: (value) => (value === 'beta' ? '#f97316' : '#2563eb'), }, - patterns: [], gradients: [], theme: { foreground: '#111827', diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 8e32062d..402ec3ab 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -679,37 +679,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 21.81, + 21.75, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 26.4, + 26.36, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 24.44, + 24.39, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 25.63, + 25.58, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 21.75, + 21.71, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 25.81, + 25.76, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 22.17, + 22.12, { inputBoundary: { forbid: [ @@ -816,7 +816,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 13.14, + 13.08, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -955,7 +955,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 30.53, + 30.49, { inputBoundary: { require: [ @@ -1124,7 +1124,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 21.68, + 21.63, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1154,7 +1154,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 21.65, + 21.6, { inputBoundary: { forbid: [ @@ -1194,22 +1194,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 22.62, + 22.56, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 21.84, + 21.79, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 19.12, + 19.06, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 18.1, + 18.05, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1228,18 +1228,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 27.19, + 27.14, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 23.7, + 23.65, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 27.19, + 27.14, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1250,7 +1250,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 28.2, + 28.15, ), locked( 'Representative marks', @@ -1277,7 +1277,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 39.67, + 39.62, { rendererBoundary: 'mixed', inputBoundary: { @@ -1655,7 +1655,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.48, + 45.43, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1735,7 +1735,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 12.52, + 12.46, { inputBoundary: { require: ['compactLinear'], @@ -1754,7 +1754,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.21, + 31.16, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1816,7 +1816,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 22.56, + 22.57, { rendererBoundary: 'svg', inputBoundary: { @@ -1922,7 +1922,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 56.95, + 56.88, ), locked( 'Custom-scale line scene', @@ -1948,37 +1948,37 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 21.75, + 21.7, ), budgeted( 'D3 time-scale line scene', 'benchmarks/entries/charts-d3-time-scene.ts', - 24.05, + 23.99, ), budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 24.12, + 24.07, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 23.18, + 23.12, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 26.4, + 26.36, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.5, + 39.43, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 44.77, + 44.71, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2182,7 +2182,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 57.99, + 57.94, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From c2c0ca18ce719ebb14ef0da9b089b633860c214a Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 16:04:01 -1000 Subject: [PATCH 08/13] chore: refresh pattern resource comparison measurements --- benchmarks/comparison/bundle-baseline.json | 126 ++++++++++----------- docs/comparison.md | 6 +- packages/charts-core/docs/comparison.md | 6 +- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a84abeea..ff302ada 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-01T01:47:53.284Z", + "generatedAt": "2026-10-01T02:01:50.992Z", "packageVersions": { "tanstack": "0.18.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "e63a3599e589885f2e18376a2a8c1906b38c6c6f", - "inputDigest": "sha256:3552e1b97c82a059b0a11000b89ea6b078747033ef0414be821e97d608170825" + "revision": "df7f1038dac39c559e6177d2de1a4079e1ceba51", + "inputDigest": "sha256:913829d54a04280d24458932b15b6c544af445d7f3b04f9be5765991e9f08546" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 118469, - "gzipBytes": 42757, - "brotliBytes": 37895, - "incrementalGzipBytes": 42757, - "incrementalBrotliBytes": 37895 + "minifiedBytes": 118348, + "gzipBytes": 42685, + "brotliBytes": 37754, + "incrementalGzipBytes": 42685, + "incrementalBrotliBytes": 37754 }, "tanstack-line-interactive": { - "minifiedBytes": 124214, - "gzipBytes": 44672, - "brotliBytes": 39374, - "incrementalGzipBytes": 44672, - "incrementalBrotliBytes": 39374 + "minifiedBytes": 124093, + "gzipBytes": 44574, + "brotliBytes": 39291, + "incrementalGzipBytes": 44574, + "incrementalBrotliBytes": 39291 }, "tanstack-line-advanced": { - "minifiedBytes": 131405, - "gzipBytes": 46978, - "brotliBytes": 41350, - "incrementalGzipBytes": 46978, - "incrementalBrotliBytes": 41350 + "minifiedBytes": 131285, + "gzipBytes": 46918, + "brotliBytes": 41364, + "incrementalGzipBytes": 46918, + "incrementalBrotliBytes": 41364 }, "tanstack-bar-basic": { - "minifiedBytes": 130909, - "gzipBytes": 47492, - "brotliBytes": 41709, - "incrementalGzipBytes": 47492, - "incrementalBrotliBytes": 41709 + "minifiedBytes": 130789, + "gzipBytes": 47417, + "brotliBytes": 41696, + "incrementalGzipBytes": 47417, + "incrementalBrotliBytes": 41696 }, "tanstack-bar-interactive": { - "minifiedBytes": 135513, - "gzipBytes": 48870, - "brotliBytes": 42903, - "incrementalGzipBytes": 48870, - "incrementalBrotliBytes": 42903 + "minifiedBytes": 135393, + "gzipBytes": 48810, + "brotliBytes": 42859, + "incrementalGzipBytes": 48810, + "incrementalBrotliBytes": 42859 }, "tanstack-bar-advanced": { - "minifiedBytes": 135852, - "gzipBytes": 49015, - "brotliBytes": 43090, - "incrementalGzipBytes": 49015, - "incrementalBrotliBytes": 43090 + "minifiedBytes": 135732, + "gzipBytes": 48961, + "brotliBytes": 42982, + "incrementalGzipBytes": 48961, + "incrementalBrotliBytes": 42982 }, "tanstack-area-basic": { - "minifiedBytes": 123648, - "gzipBytes": 44790, - "brotliBytes": 39554, - "incrementalGzipBytes": 44790, - "incrementalBrotliBytes": 39554 + "minifiedBytes": 123530, + "gzipBytes": 44735, + "brotliBytes": 39541, + "incrementalGzipBytes": 44735, + "incrementalBrotliBytes": 39541 }, "tanstack-area-interactive": { - "minifiedBytes": 129403, - "gzipBytes": 46638, - "brotliBytes": 41174, - "incrementalGzipBytes": 46638, - "incrementalBrotliBytes": 41174 + "minifiedBytes": 129285, + "gzipBytes": 46588, + "brotliBytes": 41060, + "incrementalGzipBytes": 46588, + "incrementalBrotliBytes": 41060 }, "tanstack-area-advanced": { - "minifiedBytes": 136777, - "gzipBytes": 49011, - "brotliBytes": 43223, - "incrementalGzipBytes": 49011, - "incrementalBrotliBytes": 43223 + "minifiedBytes": 136659, + "gzipBytes": 48922, + "brotliBytes": 43137, + "incrementalGzipBytes": 48922, + "incrementalBrotliBytes": 43137 }, "tanstack-scatter-basic": { - "minifiedBytes": 119475, - "gzipBytes": 43109, - "brotliBytes": 38140, - "incrementalGzipBytes": 43109, - "incrementalBrotliBytes": 38140 + "minifiedBytes": 119354, + "gzipBytes": 43042, + "brotliBytes": 38062, + "incrementalGzipBytes": 43042, + "incrementalBrotliBytes": 38062 }, "tanstack-scatter-interactive": { - "minifiedBytes": 125224, - "gzipBytes": 45030, - "brotliBytes": 39652, - "incrementalGzipBytes": 45030, - "incrementalBrotliBytes": 39652 + "minifiedBytes": 125103, + "gzipBytes": 44955, + "brotliBytes": 39635, + "incrementalGzipBytes": 44955, + "incrementalBrotliBytes": 39635 }, "tanstack-scatter-advanced": { - "minifiedBytes": 125240, - "gzipBytes": 45036, - "brotliBytes": 39683, - "incrementalGzipBytes": 45036, - "incrementalBrotliBytes": 39683 + "minifiedBytes": 125119, + "gzipBytes": 44962, + "brotliBytes": 39579, + "incrementalGzipBytes": 44962, + "incrementalBrotliBytes": 39579 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 9da17bbd..74118981 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 `e63a359` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `df7f103` | | [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 `e63a359`. +at page render time. The measured TanStack workspace revision is `df7f103`. ## Capability matrix @@ -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.75–47.87 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.68–47.81 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 9da17bbd..74118981 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 `e63a359` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `df7f103` | | [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 `e63a359`. +at page render time. The measured TanStack workspace revision is `df7f103`. ## Capability matrix @@ -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.75–47.87 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.68–47.81 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | From 4f738e4ccc25bec76b54ec077bcc37eb97b721d4 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:40:26 -1000 Subject: [PATCH 09/13] Refresh pattern comparison evidence after upstream merge --- .changeset/add-pattern-resources.md | 2 ++ benchmarks/comparison/bundle-baseline.json | 19 +++++-------------- docs/comparison.md | 4 ++-- packages/charts-core/docs/comparison.md | 4 ++-- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/.changeset/add-pattern-resources.md b/.changeset/add-pattern-resources.md index 6d719ccc..90913c8b 100644 --- a/.changeset/add-pattern-resources.md +++ b/.changeset/add-pattern-resources.md @@ -8,3 +8,5 @@ Add renderer-neutral pattern resources. Declare repeating tiles in a chart's `linePattern()` and `dotPattern()` build hatch and dot tiles whose paint accepts CSS variables. SVG, Canvas, standalone export, and React Native draw the same tile, with resource IDs scoped by `idPrefix`. +Canvas stops cyclic pattern dependencies and rejects tiles that exceed its +documented allocation limits. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 99f11ff0..9ddf9f0d 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:27:49.446Z", + "generatedAt": "2026-10-13T23:39:24.399Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "df7f1038dac39c559e6177d2de1a4079e1ceba51", - "inputDigest": "sha256:7b928dfd489e148d9f3ed1a902cbbf2efa5401bad37ea36d314c0d729f97751a" + "revision": "7905d23b241fc085c70aae4d01464c28b2d793f1", + "inputDigest": "sha256:e8b0378ee44986b6cf9b35d299c67c3f2f9c4ed5c647f97acf86f6147f7b4e2f" }, "chartjs": { "kind": "package", @@ -36,17 +36,8 @@ } }, "matrix": { - "chartTypes": [ - "line", - "bar", - "area", - "scatter" - ], - "tiers": [ - "basic", - "interactive", - "advanced" - ] + "chartTypes": ["line", "bar", "area", "scatter"], + "tiers": ["basic", "interactive", "advanced"] }, "tolerance": { "relative": 0.03, diff --git a/docs/comparison.md b/docs/comparison.md index 89fcd2fb..cf3d2e24 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 `df7f103` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `7905d23` | | [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 `df7f103`. +at page render time. The measured TanStack workspace revision is `7905d23`. ## Capability matrix diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 89fcd2fb..cf3d2e24 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 `df7f103` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `7905d23` | | [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 `df7f103`. +at page render time. The measured TanStack workspace revision is `7905d23`. ## Capability matrix From e0eb8cd4818ffcf225412742efa32d9abd191ed8 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:45:15 -1000 Subject: [PATCH 10/13] Keep pattern regression compatible with ES2022 test types --- packages/charts-core/src/canvas.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 9ff4123d..68ea192d 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -1184,9 +1184,9 @@ describe('Canvas renderer', () => { const fake = contexts.get(this) if (context && fake) { context.getTransform = () => { - const scale = fake.operations.findLast((op) => - op.startsWith('scale:'), - ) + const scale = [...fake.operations] + .reverse() + .find((op) => op.startsWith('scale:')) const [a, d] = scale?.slice(6).split(',').map(Number) ?? [2, 2] return { a, b: 0, c: 0, d, e: 0, f: 0 } as DOMMatrix } From a71cae1a4c354224506a2b051692974f6dc0a1fe Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:45:41 -1000 Subject: [PATCH 11/13] Refresh preview source hash after test compatibility change --- benchmarks/conformance/previews/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 7d0e2725..a244e22f 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "88d2267eb15d459d995b904fee989cdef17ab5c965c5ce7af77c31b8d8e760a1", + "sourceHash": "e52cab6d30fe8355f88733d01ef174f0d511bf145ad267879af189d2bd830786", "assets": [ { "id": "01-line-gaps", From 281c0f31779ffbf9fb26f800f994babc6945b5cd Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 14:05:31 -1000 Subject: [PATCH 12/13] Refresh pattern measurements against upstream motion fix --- benchmarks/comparison/bundle-baseline.json | 6 +++--- docs/comparison.md | 6 +++--- packages/charts-core/docs/comparison.md | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 9ddf9f0d..9bd23eeb 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:39:24.399Z", + "generatedAt": "2026-10-14T00:03:22.732Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "7905d23b241fc085c70aae4d01464c28b2d793f1", - "inputDigest": "sha256:e8b0378ee44986b6cf9b35d299c67c3f2f9c4ed5c647f97acf86f6147f7b4e2f" + "revision": "c5ad730c9bb380e2c61dd2889969c29eac96d5f8", + "inputDigest": "sha256:47a9f12f28d46c4540ddf478a478e8778985922a42dd8668b1ca871cc4ce6347" }, "chartjs": { "kind": "package", diff --git a/docs/comparison.md b/docs/comparison.md index cf3d2e24..02c45dcf 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 `7905d23` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c5ad730` | | [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 `7905d23`. +at page render time. The measured TanStack workspace revision is `c5ad730`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index cf3d2e24..02c45dcf 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 `7905d23` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `c5ad730` | | [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 `7905d23`. +at page render time. The measured TanStack workspace revision is `c5ad730`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only From ad427019503aa65c469bc047b998ecc015dbf0ea Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 15:00:45 -1000 Subject: [PATCH 13/13] Document proposed pattern costs and matched default-path measurements --- API-FRICTION.md | 24 +++++++++---- benchmarks/bundle-size/README.md | 35 ++++++++++++++----- docs/guides/bundle-size-and-performance.md | 6 ++++ .../guides/bundle-size-and-performance.md | 6 ++++ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 68338fce..87846540 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -8135,12 +8135,24 @@ Each entry records: `Pattern` resources and resolved native paint. The browser Canvas gate rasterizes exported SVG and compares rotated hatch and dot fills with Canvas output: the mean channel delta is about 1 of 255 with no divergent - pixels, and reversing the Canvas rotation fails the gate. Declaring no - patterns costs 7 to 8 gzip bytes in scene-only consumers and 123 to 139 - bytes in static SVG, DOM, and React consumers, comparable to radial - gradients. The Canvas renderer entry grows by about - 320 gzip bytes for tile rasterization. The - factories add about 0.27 KiB under an isolated 0.3 KiB budget. + pixels, and reversing the Canvas rotation fails the gate. +- Proposed bundle increases: matched production builds against main + `5a34b7e1` measure 516 gzip bytes for the Canvas host, 646 for representative + marks with Canvas, and 619 for React line with Canvas. Static SVG adds 120 + bytes, DOM 124, compact React 136, and scene-only consumers 7 to 8. These + are proposed increases pending maintainer approval. Decimal kB divides + bytes by 1000; the source ceilings use KiB (1024 bytes). The factories add + about 0.27 KiB under an isolated 0.3 KiB proposed budget. +- Default-path performance: matched Chromium 151.0.7922.34 production builds + compare main `5a34b7e1` with pattern source `ec695a8b`. Each trial renders + one 960 by 540 scene of 1000 solid-color 6 by 6 rectangles 100 times at + pixel ratio one, with no patterns. Each of two fresh browsers reuses its + variant contexts for three warmups and seven paired trials. Execution order + alternates; the second browser reverses context creation order. Main/PR + medians are 51.8/48.0 ms and 48.7/47.9 ms; paired median PR-minus-main + differences are -0.7 and -0.1 ms. This bounded benchmark reproduces no + consistent slowdown. Other workloads and on-device React Native rendering + remain unverified. ### F-260 — Static guides cannot express stroke treatment diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 333dceb9..f7e1f922 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -60,14 +60,33 @@ Radial resources add 112 gzip bytes to the static SVG consumer and 124 bytes to the DOM host. Scene-only consumers remain unchanged apart from one byte of compression variation. -Pattern resources are part of the default scene and SVG contracts. Copying the -declared list adds 7 to 8 gzip bytes to scene-only consumers. Emitting scoped -tiles through the existing node serializer adds 110 to 139 bytes to static -SVG, DOM, and React consumers. Canvas consumers add 619 to 646 gzip bytes after integration with current upstream -rendering code. These deltas were measured against upstream `f29d6493` with the -same toolchain; affected ceilings retain their existing upstream headroom. The `linePattern()` and `dotPattern()` vocabulary stays in its own -subpath: its static SVG fixture adds about 0.27 KiB under a 0.3 KiB incremental -cap, and the ordinary line consumer forbids it. +Pattern resources are part of the default scene and SVG contracts. Matched +production builds against main `5a34b7e1` measure the following proposed gzip +increases, pending maintainer approval: + +| Consumer | Added bytes | Decimal kB | +| -------------------------------- | ----------: | ---------: | +| Canvas host | 516 | 0.516 | +| Representative marks with Canvas | 646 | 0.646 | +| React line with Canvas | 619 | 0.619 | +| Static SVG line | 120 | 0.120 | +| DOM host | 124 | 0.124 | +| Compact React line | 136 | 0.136 | + +Scene-only consumers add 7 to 8 bytes. Decimal kB uses 1000 bytes; the source +ceilings use KiB (1024 bytes). Proposed ceilings retain the existing upstream +headroom. The `linePattern()` and `dotPattern()` vocabulary stays in its own +subpath: its static SVG fixture adds about 0.27 KiB under a proposed 0.3 KiB +incremental cap, and the ordinary line consumer forbids it. + +For the no-pattern default path, matched Chromium 151.0.7922.34 production +builds rendered 1000 solid-color rectangles in a 960 by 540 scene 100 times +per trial at pixel ratio one. Two fresh browsers each ran three warmups and +seven paired trials in reused variant contexts. Trial execution alternated; +context creation order reversed between browsers. Main/PR medians were +51.8/48.0 ms and 48.7/47.9 ms. Paired median PR-minus-main differences were +-0.7 and -0.1 ms. No consistent slowdown was reproduced in this fixture; it +does not establish performance for other workloads or devices. Every public transform family has an isolated budget and retained-input allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index d0191bc4..ea717a11 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -251,6 +251,12 @@ Measure three layers independently: This separation reveals whether an expensive chart needs a better encoding, a framework-memoized transform, fewer scene nodes, or a different renderer. +When checking an optional renderer feature, include a matched workload that +omits it. Use identical scenes and production builds, warm the contexts used +for measurement, and alternate execution order. Repeat with browser-context +creation order reversed and report paired timing differences. Small differences +can depend on context order, so one pair of absolute medians is not enough. + ## Choose a sane representation The fastest way to render too much data is to avoid rendering it: diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index d0191bc4..ea717a11 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -251,6 +251,12 @@ Measure three layers independently: This separation reveals whether an expensive chart needs a better encoding, a framework-memoized transform, fewer scene nodes, or a different renderer. +When checking an optional renderer feature, include a matched workload that +omits it. Use identical scenes and production builds, warm the contexts used +for measurement, and alternate execution order. Repeat with browser-context +creation order reversed and report paired timing differences. Small differences +can depend on context order, so one pair of absolute medians is not enough. + ## Choose a sane representation The fastest way to render too much data is to avoid rendering it: