diff --git a/.changeset/add-pattern-resources.md b/.changeset/add-pattern-resources.md new file mode 100644 index 00000000..90913c8b --- /dev/null +++ b/.changeset/add-pattern-resources.md @@ -0,0 +1,12 @@ +--- +'@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`. +Canvas stops cyclic pattern dependencies and rejects tiles that exceed its +documented allocation limits. diff --git a/API-FRICTION.md b/API-FRICTION.md index 049e9c6e..87846540 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 | @@ -8084,19 +8084,75 @@ 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. 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. +- Safety: Canvas tracks active resource IDs separately from raster resolution + caches. Fractional cyclic tiles stop without repeated allocation. Per-paint + tile dimensions, pixel allocation, and dependency depth are bounded, with + focused cycle and allocation regressions. +- 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. 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. +- 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 aa083186..f7e1f922 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.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`. @@ -60,6 +60,34 @@ 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. 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 `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 d7b15a06..54346e41 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": 53407, - "gzip": 19966 + "bytes": 53427, + "gzip": 19973 }, "D3-scale line + static SVG": { - "bytes": 59562, - "gzip": 22278 + "bytes": 59969, + "gzip": 22398 }, "Representative marks": { - "bytes": 83349, - "gzip": 30414 + "bytes": 83756, + "gzip": 30550 }, "TanStack DOM host": { - "bytes": 82961, - "gzip": 29116 + "bytes": 83368, + "gzip": 29240 }, "React adapter": { - "bytes": 85286, - "gzip": 30070 + "bytes": 85693, + "gzip": 30206 }, "React line consumer": { - "bytes": 108945, - "gzip": 39420 + "bytes": 109352, + "gzip": 39544 }, "Compact-scale line scene": { - "bytes": 35859, - "gzip": 12797 + "bytes": 35879, + "gzip": 12805 }, "React compact-scale line consumer": { - "bytes": 91443, - "gzip": 32305 + "bytes": 91850, + "gzip": 32441 }, "Custom-scale line scene": { - "bytes": 34043, - "gzip": 12064 + "bytes": 34063, + "gzip": 12072 }, "D3 linear-scale line scene": { - "bytes": 53339, - "gzip": 19928 + "bytes": 53359, + "gzip": 19936 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0406421b..9bd23eeb 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:35:26.997Z", + "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": "a691036b07655adda97aa0dc3052428989e2b811", - "inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951" + "revision": "c5ad730c9bb380e2c61dd2889969c29eac96d5f8", + "inputDigest": "sha256:47a9f12f28d46c4540ddf478a478e8778985922a42dd8668b1ca871cc4ce6347" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 119233, - "gzipBytes": 43211, - "brotliBytes": 38200, - "incrementalGzipBytes": 43211, - "incrementalBrotliBytes": 38200 + "minifiedBytes": 119640, + "gzipBytes": 43345, + "brotliBytes": 38299, + "incrementalGzipBytes": 43345, + "incrementalBrotliBytes": 38299 }, "tanstack-line-interactive": { - "minifiedBytes": 124980, - "gzipBytes": 45122, - "brotliBytes": 39727, - "incrementalGzipBytes": 45122, - "incrementalBrotliBytes": 39727 + "minifiedBytes": 125387, + "gzipBytes": 45247, + "brotliBytes": 39873, + "incrementalGzipBytes": 45247, + "incrementalBrotliBytes": 39873 }, "tanstack-line-advanced": { - "minifiedBytes": 132176, - "gzipBytes": 47454, - "brotliBytes": 41771, - "incrementalGzipBytes": 47454, - "incrementalBrotliBytes": 41771 + "minifiedBytes": 132583, + "gzipBytes": 47577, + "brotliBytes": 41851, + "incrementalGzipBytes": 47577, + "incrementalBrotliBytes": 41851 }, "tanstack-bar-basic": { - "minifiedBytes": 131670, - "gzipBytes": 47913, - "brotliBytes": 42147, - "incrementalGzipBytes": 47913, - "incrementalBrotliBytes": 42147 + "minifiedBytes": 132079, + "gzipBytes": 48055, + "brotliBytes": 42278, + "incrementalGzipBytes": 48055, + "incrementalBrotliBytes": 42278 }, "tanstack-bar-interactive": { - "minifiedBytes": 136276, - "gzipBytes": 49307, - "brotliBytes": 43250, - "incrementalGzipBytes": 49307, - "incrementalBrotliBytes": 43250 + "minifiedBytes": 136685, + "gzipBytes": 49446, + "brotliBytes": 43351, + "incrementalGzipBytes": 49446, + "incrementalBrotliBytes": 43351 }, "tanstack-bar-advanced": { - "minifiedBytes": 136615, - "gzipBytes": 49453, - "brotliBytes": 43349, - "incrementalGzipBytes": 49453, - "incrementalBrotliBytes": 43349 + "minifiedBytes": 137024, + "gzipBytes": 49582, + "brotliBytes": 43561, + "incrementalGzipBytes": 49582, + "incrementalBrotliBytes": 43561 }, "tanstack-area-basic": { - "minifiedBytes": 124413, - "gzipBytes": 45239, - "brotliBytes": 39996, - "incrementalGzipBytes": 45239, - "incrementalBrotliBytes": 39996 + "minifiedBytes": 124820, + "gzipBytes": 45366, + "brotliBytes": 40113, + "incrementalGzipBytes": 45366, + "incrementalBrotliBytes": 40113 }, "tanstack-area-interactive": { - "minifiedBytes": 130168, - "gzipBytes": 47088, - "brotliBytes": 41480, - "incrementalGzipBytes": 47088, - "incrementalBrotliBytes": 41480 + "minifiedBytes": 130575, + "gzipBytes": 47214, + "brotliBytes": 41633, + "incrementalGzipBytes": 47214, + "incrementalBrotliBytes": 41633 }, "tanstack-area-advanced": { - "minifiedBytes": 137540, - "gzipBytes": 49496, - "brotliBytes": 43606, - "incrementalGzipBytes": 49496, - "incrementalBrotliBytes": 43606 + "minifiedBytes": 137950, + "gzipBytes": 49624, + "brotliBytes": 43648, + "incrementalGzipBytes": 49624, + "incrementalBrotliBytes": 43648 }, "tanstack-scatter-basic": { - "minifiedBytes": 120286, - "gzipBytes": 43572, - "brotliBytes": 38499, - "incrementalGzipBytes": 43572, - "incrementalBrotliBytes": 38499 + "minifiedBytes": 120693, + "gzipBytes": 43712, + "brotliBytes": 38562, + "incrementalGzipBytes": 43712, + "incrementalBrotliBytes": 38562 }, "tanstack-scatter-interactive": { - "minifiedBytes": 126037, - "gzipBytes": 45469, - "brotliBytes": 40055, - "incrementalGzipBytes": 45469, - "incrementalBrotliBytes": 40055 + "minifiedBytes": 126444, + "gzipBytes": 45609, + "brotliBytes": 40205, + "incrementalGzipBytes": 45609, + "incrementalBrotliBytes": 40205 }, "tanstack-scatter-advanced": { - "minifiedBytes": 126053, - "gzipBytes": 45476, - "brotliBytes": 40107, - "incrementalGzipBytes": 45476, - "incrementalBrotliBytes": 40107 + "minifiedBytes": 126460, + "gzipBytes": 45616, + "brotliBytes": 40176, + "incrementalGzipBytes": 45616, + "incrementalBrotliBytes": 40176 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 300c9bf6..2d656883 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "82eecd6c1fbdca5e857b965e09bb53c550520b2e596fbc4e9850266f03fdb9b4", + "sourceHash": "e3eda18a6f6deb804ab9ac1585736b71976df83ad42289f58943aaa2e78e7bda", "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/docs/comparison.md b/docs/comparison.md index a6ab014c..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 `a691036` | +| [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 `a691036`. +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 @@ -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 | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.34–49.62 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | | Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | 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/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 26324070..ea717a11 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.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,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,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. @@ -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/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 8cc037c5..60afacc9 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -262,6 +262,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 @@ -279,9 +281,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 @@ -290,8 +292,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 6cdf0c9f..50d89633 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. @@ -987,3 +987,71 @@ 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. + +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)`. +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. + +Canvas stops circular pattern dependencies by resource ID. Each paint permits +tiles up to 4096 device pixels per side, 16,777,216 tile pixels in total, and +64 nested pattern dependencies. Exceeding these limits throws a `RangeError` +before allocating the next tile. Reduce the tile size or dependency chain +when rendering externally supplied definitions. + +`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 0e977edd..1d38cc9f 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -278,6 +278,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 4fca1560..478cd317 100644 --- a/llms.txt +++ b/llms.txt @@ -65,10 +65,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/comparison.md b/packages/charts-core/docs/comparison.md index a6ab014c..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 `a691036` | +| [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 `a691036`. +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 @@ -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 | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.34–49.62 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | | Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | 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/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 26324070..ea717a11 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.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,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,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. @@ -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/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 8cc037c5..60afacc9 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -262,6 +262,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 @@ -279,9 +281,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 @@ -290,8 +292,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 6cdf0c9f..50d89633 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. @@ -987,3 +987,71 @@ 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. + +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)`. +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. + +Canvas stops circular pattern dependencies by resource ID. Each paint permits +tiles up to 4096 device pixels per side, 16,777,216 tile pixels in total, and +64 nested pattern dependencies. Exceeding these limits throws a `RangeError` +before allocating the next tile. Reduce the tile size or dependency chain +when rendering externally supplied definitions. + +`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 0e977edd..1d38cc9f 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -278,6 +278,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 4fca1560..478cd317 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -65,10 +65,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/package.json b/packages/charts-core/package.json index ae429406..ec02e042 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 edb81d10..68ea192d 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -23,6 +23,7 @@ import { type InteractiveColorLegendChange, } from './interactive-legend' import { dot } from './dot' +import { linePattern } from './pattern' import { facet } from './facet' import { lineX, lineY } from './line' import { mountChart } from './dom' @@ -52,6 +53,7 @@ interface FakeCanvasContext { fillStyles: Array globalAlphas: number[] arcTos: Array<[number, number, number, number, number]> + patterns: Array<{ source: CanvasImageSource; transforms: DOMMatrix2DInit[] }> context: CanvasRenderingContext2D } @@ -1069,6 +1071,211 @@ 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('bounds fractional pattern cycles by dependency identity', () => { + const getContext = getContextSpy.getMockImplementation() + getContextSpy.mockImplementation(function (this: HTMLCanvasElement) { + if (contexts.size > 8) throw new Error('Repeated tile allocation') + const context = getContext?.call(this) + const fake = contexts.get(this) + if (context && fake) { + context.getTransform = () => { + 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 + } + } + return context ?? null + }) + const container = document.createElement('div') + const surface = createCanvasChartRenderer({ pixelRatio: 2 }).mount( + container, + () => {}, + ) + const tile = (id: string, width: number, fill: string) => ({ + id, + width, + height: width, + nodes: [ + { + kind: 'rect' as const, + key: id, + x: 0, + y: 0, + width, + height: width, + style: { fill }, + }, + ], + }) + const patternedScene = { + ...scene([ + { + kind: 'rect', + key: 'paint', + x: 0, + y: 0, + width: 20, + height: 20, + style: { fill: 'url(#a)' }, + }, + ]), + patterns: [tile('a', 1.1, 'url(#b)'), tile('b', 1.3, 'url(#a)')], + } + expect(() => surface.render(patternedScene, renderOptions())).not.toThrow() + expect( + [...contexts.values()].flatMap((context) => context.patterns), + ).toHaveLength(2) + surface.destroy() + }) + + it.each([{ sizes: [4097] }, { sizes: [4096, 1] }])( + 'rejects excessive pattern tile allocations for $sizes', + ({ sizes }) => { + const container = document.createElement('div') + const surface = createCanvasChartRenderer({ pixelRatio: 1 }).mount( + container, + () => {}, + ) + for (const context of contexts.values()) { + context.context.getTransform = () => + ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) as DOMMatrix + } + const patternedScene = { + ...scene( + sizes.map((_, index) => ({ + kind: 'rect' as const, + key: String(index), + x: 0, + y: 0, + width: 20, + height: 20, + style: { fill: `url(#tile-${index})` }, + })), + ), + patterns: sizes.map((size, index) => ({ + id: `tile-${index}`, + width: size, + height: size, + nodes: [], + })), + } + expect(() => surface.render(patternedScene, renderOptions())).toThrow( + RangeError, + ) + expect( + [...contexts.values()].flatMap((context) => context.patterns).length, + ).toBeLessThan(2) + surface.destroy() + }, + ) + it('normalizes legacy linear coordinates and decreasing stops', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -3309,6 +3516,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 @@ -3347,6 +3555,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 @@ -3470,6 +3693,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 d2565ee4..922633f3 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,13 @@ interface ScenePainter { scene: ChartScene Path: typeof Path2D | undefined font: FontState + document: Document + patterns: Map + patternBudget: { active: Set; pixels: number } } +type CanvasPaint = string | CanvasGradient | CanvasPattern + interface RadialFillPaint { gradient: ChartRadialGradient bounds: ChartBounds @@ -1187,10 +1193,29 @@ 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(), + patternBudget: { active: new Set(), pixels: 0 }, + } } function paintFocusCanvas( @@ -1205,9 +1230,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( @@ -1564,7 +1587,7 @@ function strokeCurrentPath( function configureStroke( context: CanvasRenderingContext2D, state: PaintState, - stroke: string | CanvasGradient, + stroke: CanvasPaint, ): void { context.globalAlpha = state.opacity * state.strokeOpacity context.strokeStyle = stroke @@ -1578,10 +1601,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( @@ -1615,13 +1639,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) @@ -1660,10 +1686,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], ) @@ -1695,6 +1726,84 @@ 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 { + const budget = painter.patternBudget + if (budget.active.has(pattern.id)) return 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) + if (pixelWidth > 4096 || pixelHeight > 4096) { + throw new RangeError( + 'Canvas pattern tiles cannot exceed 4096 device pixels per side', + ) + } + const key = `${pattern.id}:${pixelWidth}:${pixelHeight}` + let canvasPattern = painter.patterns.get(key) + if (canvasPattern === undefined) { + const pixels = pixelWidth * pixelHeight + if (budget.active.size >= 64 || budget.pixels + pixels > 16_777_216) { + throw new RangeError( + 'Canvas patterns exceed the per-paint allocation or dependency limit', + ) + } + budget.pixels += pixels + const canvas = painter.document.createElement('canvas') + canvas.width = pixelWidth + canvas.height = pixelHeight + const tileContext = requiredContext(canvas) + tileContext.scale(pixelWidth / width, pixelHeight / height) + // Dependency identity stays stable when fractional tiles change resolution. + budget.active.add(pattern.id) + try { + paintNodes( + { ...painter, context: tileContext }, + pattern.nodes, + defaultPaint, + ) + } finally { + budget.active.delete(pattern.id) + } + 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..26cb4dfc 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -5,6 +5,9 @@ import type { ChartGradientBase as RootChartGradientBase, ChartLinearGradient as RootChartLinearGradient, ChartRadialGradient as RootChartRadialGradient, + ChartPattern as RootChartPattern, + LinePatternOptions as RootLinePatternOptions, + DotPatternOptions as RootDotPatternOptions, ChartGuideLineStyle as RootChartGuideLineStyle, CreateDotLayoutOptions as RootCreateDotLayoutOptions, DotLayout as RootDotLayout, @@ -15,6 +18,9 @@ import type { ChartGradientBase as UniversalChartGradientBase, ChartLinearGradient as UniversalChartLinearGradient, ChartRadialGradient as UniversalChartRadialGradient, + ChartPattern as UniversalChartPattern, + LinePatternOptions as UniversalLinePatternOptions, + DotPatternOptions as UniversalDotPatternOptions, ChartGuideLineStyle as UniversalChartGuideLineStyle, CreateDotLayoutOptions as UniversalCreateDotLayoutOptions, DotLayout as UniversalDotLayout, @@ -25,6 +31,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 +129,13 @@ describe('public package exports', () => { } }) + it('exports pattern resource types from every authoring barrel', () => { + expectTypeOf().toEqualTypeOf() + 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 bf567da6..a77c504c 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' @@ -375,6 +381,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 40d80b7e..405eb0fd 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -583,6 +583,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..8027a475 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,80 @@ 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: [] }, + { + 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 3169546e..40e0c637 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,21 +11,27 @@ export function renderChartSvg( scene: ChartScene, options: RenderChartSvgOptions, ): string { - const gradientIds = new Set(scene.gradients.map((gradient) => gradient.id)) - return renderChartSvgWithHooks(scene, options, { + const resourceIds = new Set( + [...scene.gradients, ...(scene.patterns ?? [])].map( + (resource) => resource.id, + ), + ) + const hooks: ChartSvgRenderHooks = { renderDefinitions: (currentScene, idPrefix) => - renderGradients(currentScene, sanitizeId(idPrefix)), + renderGradients(currentScene, sanitizeId(idPrefix)) + + renderPatterns(currentScene, sanitizeId(idPrefix), hooks), 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 } : undefined, - } satisfies ChartSvgRenderHooks) + } + return renderChartSvgWithHooks(scene, options, hooks) } function renderGradients(scene: ChartScene, idPrefix: string) { @@ -49,6 +56,20 @@ function renderGradients(scene: ChartScene, idPrefix: string) { .join('')}` } +function renderPatterns( + scene: ChartScene, + idPrefix: string, + hooks: ChartSvgRenderHooks, +) { + if (!scene.patterns?.length) return '' + return `${scene.patterns + .map( + (pattern) => + `${renderSceneNodes(pattern.nodes, idPrefix, hooks)}`, + ) + .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 6e596579..a5458f4a 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -674,6 +674,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, @@ -795,6 +811,7 @@ export interface ChartSpecBase { guides?: boolean color?: ChartColorOptions gradients?: readonly ChartGradient[] + patterns?: readonly ChartPattern[] clip?: boolean margin?: number | Partial theme?: Partial @@ -1617,6 +1634,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 9a0b2e26..5b9ce556 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 { @@ -258,6 +263,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') diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 93578ebc..5a58d592 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -126,6 +126,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, @@ -630,6 +631,7 @@ const entries = [ rendererBoundary: 'svg', inputBoundary: { forbid: [ + 'patternResources', 'd3GeometryRuntime', 'networkForce', 'd3Force', @@ -675,37 +677,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 21.6766015625, + 21.7937890625, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 26.293203125, + 26.4055078125, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 24.301390625, + 24.42834375, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 25.5225078125, + 25.6436015625, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 21.635578125, + 21.7508125, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 25.679484375, + 25.808390625, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 22.055625, + 22.1728125, { inputBoundary: { forbid: [ @@ -765,6 +767,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', @@ -789,7 +814,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 13.0114140625, + 13.1325078125, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -928,7 +953,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 30.4684375, + 30.6041796875, { inputBoundary: { require: [ @@ -1097,7 +1122,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 21.549625, + 21.676578125, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1127,7 +1152,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 21.5255078125, + 21.651484375, { inputBoundary: { forbid: [ @@ -1167,22 +1192,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 22.4925546875, + 22.6175546875, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 21.7214375, + 21.828859375, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 19.00453125, + 19.12953125, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 18.0336953125, + 18.1547890625, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1201,18 +1226,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 27.2196953125, + 27.345671875, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 23.74225, + 23.8701796875, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 27.1224609375, + 27.2484375, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1223,7 +1248,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 28.1496484375, + 28.273671875, ), locked( 'Representative marks', @@ -1250,7 +1275,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 39.350390625, + 39.98125, { rendererBoundary: 'mixed', inputBoundary: { @@ -1364,7 +1389,7 @@ const entries = [ 'Interactive legend + DOM host', 'benchmarks/entries/charts-interactive-legend.ts', 'TanStack DOM host', - 2.861, + 2.8658828125, { rendererBoundary: 'svg', inputBoundary: { @@ -1628,7 +1653,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.547984375, + 46.1524765625, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1708,7 +1733,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 12.497625, + 12.5054375, { inputBoundary: { require: ['compactLinear'], @@ -1727,7 +1752,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.547984375, + 31.680796875, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1789,7 +1814,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 23_240 / 1024, + 22.8037109375, { rendererBoundary: 'svg', inputBoundary: { @@ -1895,7 +1920,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 57.3744609375, + 57.4916484375, ), locked( 'Custom-scale line scene', @@ -1921,37 +1946,37 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 21.7403671875, + 21.747203125, ), budgeted( 'D3 time-scale line scene', 'benchmarks/entries/charts-d3-time-scene.ts', - 24.0255078125, + 24.03234375, ), budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 24.006390625, + 24.127484375, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 23.0404140625, + 23.1693203125, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 26.293203125, + 26.4055078125, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.847984375, + 39.9817734375, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 45.1320546875, + 45.248265625, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2155,7 +2180,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 58.46075, + 58.58965625, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot minimal line', 'benchmarks/entries/minimal-line.ts'),