diff --git a/.changeset/calm-arcs-render.md b/.changeset/calm-arcs-render.md new file mode 100644 index 00000000..56026e9b --- /dev/null +++ b/.changeset/calm-arcs-render.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': patch +'@tanstack/angular-charts': patch +--- + +Keep animated SVG arc flags valid throughout path interpolation and prevent the +Angular adapter from mounting its browser host during server rendering. diff --git a/API-FRICTION.md b/API-FRICTION.md index ca926dba..a424e447 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -304,6 +304,9 @@ Each entry records: | F-265 | Sunburst motion lost hierarchy across enter and exit | API | resolved | | F-266 | Path-token motion distorted polar sectors | API/Tooling | resolved | | F-267 | Stress timeouts entered a class temporal dead zone | Tooling | resolved | +| F-268 | Animated arc flags became invalid fractional path values | API | resolved | +| F-269 | Angular mounted its browser host during server rendering | API | resolved | +| F-270 | Catalog migration left generated release evidence stale | Tooling | resolved | ## Findings @@ -7898,3 +7901,56 @@ Each entry records: with the expected prototype, name, and duration message. The retry suite, stress-runner syntax check, full repository validation, and rerun GitHub stress partition pass. + +### F-268 — Animated arc flags became invalid fractional path values + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: GitHub issue #71 and the SVG reconciliation regression +- Friction: the default motion renderer interpolated every number in a path's + `d` attribute. SVG arc flags are discrete `0` or `1` values, so a flag change + produced invalid fractional flags and could hide an arc during its tween. +- Decision: identify the large-arc and sweep positions in every `A`/`a` + command, snap those values to the target flag, and continue interpolating the + remaining path geometry. +- Verification: the focused reconciliation test changes both flags while + interpolating arc radii and endpoints, and asserts valid flags at the + midpoint and exact target geometry at completion. A second regression covers + the SVG grammar's adjacent `00` and `01` flag pairs. Bundle review attributes + 250 minified and 133–145 gzip bytes across the four locked DOM consumers to the + shared correctness fix; the exact baselines and six complete-consumer + ceilings record that reviewed cost. + +### F-269 — Angular mounted its browser host during server rendering + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: GitHub issue #56 and Angular server-rendering regression +- Friction: `ngAfterViewInit` runs during Angular SSR, so the adapter mounted + the DOM host against the server element and called browser-only measurement + or mutation methods. +- Decision: keep synchronous SVG prerendering in the shared adapter and defer + only browser-host mounting through Angular's `afterNextRender`, which does + not run on the server. +- Verification: the official Angular `renderApplication` pipeline emits the + complete labeled SVG without mounting the DOM host, while the existing + browser test still covers mount, update, and destroy. + +### F-270 — Catalog migration left generated release evidence stale + +- Status: resolved +- Severity: high +- Owner: Tooling +- Observed in: release validation after the React catalog migration in #84 +- Friction: the migration moved four application shells into React views and + renamed the token-calendar shell to TSX without updating roadmap ownership + or the audit link. It also changed catalog source inputs without refreshing + preview provenance. Current `main` therefore failed both the unit and + catalog-preview release gates. +- Decision: point roadmap ownership at the active `view.tsx` and `shell.tsx` + files, repair the audit link, and regenerate catalog preview provenance. +- Verification: the focused roadmap test and catalog preview check pass. The + only rendered asset change normalizes synchronized-cursor point keys from + timestamp labels to the migrated ISO date labels; geometry is unchanged. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 9c182276..74df5685 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,8 +25,8 @@ may add only its transport module over the tooltip consumer. Ordinary line, compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The -scene has a 10.3 KiB gzip ceiling. The React compact-scale line consumer has a -26.6 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +scene has a 10.55 KiB gzip ceiling. The React compact-scale line consumer has a +27.05 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`. @@ -42,7 +42,7 @@ of the default scene and host contracts. Default static SVG consumes scene clips and gradients. Their reviewed shared-path cost is recorded in the locked entries and the corresponding complete-consumer budgets. Rolling path planning remains confined to the opt-in motion renderer, whose complete SVG budget is -17.2 KiB gzip. +18.2 KiB gzip. Every public transform family has an isolated budget and retained-input allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index b6dee166..fd9f6438 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 26201 }, "TanStack DOM host": { - "bytes": 70320, - "gzip": 24623 + "bytes": 70570, + "gzip": 24758 }, "React adapter": { - "bytes": 72479, - "gzip": 25379 + "bytes": 72729, + "gzip": 25524 }, "React line consumer": { - "bytes": 95673, - "gzip": 34616 + "bytes": 95923, + "gzip": 34754 }, "Compact-scale line scene": { "bytes": 29572, "gzip": 10769 }, "React compact-scale line consumer": { - "bytes": 78174, - "gzip": 27551 + "bytes": 78424, + "gzip": 27684 }, "Custom-scale line scene": { "bytes": 27756, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 6910d01c..fed1b2cf 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-11T00:43:02.982Z", + "generatedAt": "2026-08-11T23:49:36.793Z", "packageVersions": { - "tanstack": "0.10.0", + "tanstack": "0.11.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "7ac3c321ff253f51d2c7df7be6a63c6edb7b771f", - "inputDigest": "sha256:44f03725e05260cba794012df73385ad7df1ccd9a7cd44ad2e560a05ebf682e7" + "revision": "02e24867f1699fa918fd49eaedfad64bc3f4e9e5", + "inputDigest": "sha256:790fc8c0e954afbba943d637d43b665739d2438d2cc6c341e81613baad327508" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 105194, - "gzipBytes": 38218, - "brotliBytes": 33855, - "incrementalGzipBytes": 38218, - "incrementalBrotliBytes": 33855 + "minifiedBytes": 105444, + "gzipBytes": 38355, + "brotliBytes": 33936, + "incrementalGzipBytes": 38355, + "incrementalBrotliBytes": 33936 }, "tanstack-line-interactive": { - "minifiedBytes": 110635, - "gzipBytes": 40001, - "brotliBytes": 35257, - "incrementalGzipBytes": 40001, - "incrementalBrotliBytes": 35257 + "minifiedBytes": 110885, + "gzipBytes": 40139, + "brotliBytes": 35457, + "incrementalGzipBytes": 40139, + "incrementalBrotliBytes": 35457 }, "tanstack-line-advanced": { - "minifiedBytes": 117823, - "gzipBytes": 42310, - "brotliBytes": 37208, - "incrementalGzipBytes": 42310, - "incrementalBrotliBytes": 37208 + "minifiedBytes": 118073, + "gzipBytes": 42438, + "brotliBytes": 37478, + "incrementalGzipBytes": 42438, + "incrementalBrotliBytes": 37478 }, "tanstack-bar-basic": { - "minifiedBytes": 113723, - "gzipBytes": 41389, - "brotliBytes": 36502, - "incrementalGzipBytes": 41389, - "incrementalBrotliBytes": 36502 + "minifiedBytes": 113973, + "gzipBytes": 41522, + "brotliBytes": 36650, + "incrementalGzipBytes": 41522, + "incrementalBrotliBytes": 36650 }, "tanstack-bar-interactive": { - "minifiedBytes": 118015, - "gzipBytes": 42734, - "brotliBytes": 37678, - "incrementalGzipBytes": 42734, - "incrementalBrotliBytes": 37678 + "minifiedBytes": 118265, + "gzipBytes": 42870, + "brotliBytes": 37747, + "incrementalGzipBytes": 42870, + "incrementalBrotliBytes": 37747 }, "tanstack-bar-advanced": { - "minifiedBytes": 118354, - "gzipBytes": 42878, - "brotliBytes": 37752, - "incrementalGzipBytes": 42878, - "incrementalBrotliBytes": 37752 + "minifiedBytes": 118604, + "gzipBytes": 43012, + "brotliBytes": 37919, + "incrementalGzipBytes": 43012, + "incrementalBrotliBytes": 37919 }, "tanstack-area-basic": { - "minifiedBytes": 110281, - "gzipBytes": 40134, - "brotliBytes": 35524, - "incrementalGzipBytes": 40134, - "incrementalBrotliBytes": 35524 + "minifiedBytes": 110531, + "gzipBytes": 40265, + "brotliBytes": 35653, + "incrementalGzipBytes": 40265, + "incrementalBrotliBytes": 35653 }, "tanstack-area-interactive": { - "minifiedBytes": 115722, - "gzipBytes": 41869, - "brotliBytes": 36921, - "incrementalGzipBytes": 41869, - "incrementalBrotliBytes": 36921 + "minifiedBytes": 115972, + "gzipBytes": 42001, + "brotliBytes": 37115, + "incrementalGzipBytes": 42001, + "incrementalBrotliBytes": 37115 }, "tanstack-area-advanced": { - "minifiedBytes": 123092, - "gzipBytes": 44314, - "brotliBytes": 38976, - "incrementalGzipBytes": 44314, - "incrementalBrotliBytes": 38976 + "minifiedBytes": 123342, + "gzipBytes": 44453, + "brotliBytes": 39193, + "incrementalGzipBytes": 44453, + "incrementalBrotliBytes": 39193 }, "tanstack-scatter-basic": { - "minifiedBytes": 106233, - "gzipBytes": 38602, - "brotliBytes": 34108, - "incrementalGzipBytes": 38602, - "incrementalBrotliBytes": 34108 + "minifiedBytes": 106483, + "gzipBytes": 38737, + "brotliBytes": 34248, + "incrementalGzipBytes": 38737, + "incrementalBrotliBytes": 34248 }, "tanstack-scatter-interactive": { - "minifiedBytes": 111674, - "gzipBytes": 40354, - "brotliBytes": 35550, - "incrementalGzipBytes": 40354, - "incrementalBrotliBytes": 35550 + "minifiedBytes": 111924, + "gzipBytes": 40487, + "brotliBytes": 35678, + "incrementalGzipBytes": 40487, + "incrementalBrotliBytes": 35678 }, "tanstack-scatter-advanced": { - "minifiedBytes": 111690, - "gzipBytes": 40358, - "brotliBytes": 35533, - "incrementalGzipBytes": 40358, - "incrementalBrotliBytes": 35533 + "minifiedBytes": 111940, + "gzipBytes": 40494, + "brotliBytes": 35700, + "incrementalGzipBytes": 40494, + "incrementalBrotliBytes": 35700 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md index 856a0138..2aebf161 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md @@ -242,7 +242,7 @@ ownership boundary. | [114 — Spring line](./cases/114-spring-line-motion/tanstack.ts) | Definition now | Line morph and per-series spring overrides are already declarative; demo controls remain outside. | | [115 — Definition motion](./cases/115-definition-motion/tanstack.ts) | Definition now | Chart, mark, datum, axis, tick, and label motion are already definition-owned. | | [117 — Focus cursor motion](./cases/117-focus-cursor-motion/tanstack.ts) | First-party primitive | Add a stable-key focus guide with x/y rules, marker, labels, and normal motion; remove the second SVG and spring loop. | -| [118 — Token calendar](./cases/118-token-usage-calendar/shell.ts) | First-party primitive | Add per-tick text style, anchor, and offset accessors so the first month label needs no post-render DOM mutation. | +| [118 — Token calendar](./cases/118-token-usage-calendar/shell.tsx) | First-party primitive | Add per-tick text style, anchor, and offset accessors so the first month label needs no post-render DOM mutation. | | [119 — Stacked bar cursor](./cases/119-stacked-bar-band-cursor/chart.ts) | First-party primitive | Use renderer-native `crosshair` axes for the categorical band, endpoint rule, labels, grouped focus, and ordinary definition motion; only conformance observation remains outside. | | [120 — Themed area card](./cases/120-themed-interactive-area/chart.ts) | Definition now | Native area, line, points, gradients, sparse guides, tooltip, and keyed spring policy own the chart; the range selector and card remain application UI. | | [121 — Active bar dashboard](./cases/121-active-bar-dashboard/chart.ts) | Definition now | Native gradient bars, focus states, band cursor, tooltip, and spring updates own the chart; KPI totals double as application-owned series controls. | diff --git a/benchmarks/conformance/definition-coverage-roadmap.json b/benchmarks/conformance/definition-coverage-roadmap.json index e4fc2ebc..5e86f3c8 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.json +++ b/benchmarks/conformance/definition-coverage-roadmap.json @@ -2443,7 +2443,7 @@ "evidence": [ "cases/85-scrollable-resource-lanes/tanstack.ts", "cases/85-scrollable-resource-lanes/tanstack.test.ts", - "cases/85-scrollable-resource-lanes/shell.ts", + "cases/85-scrollable-resource-lanes/view.tsx", "DEFINITION-COVERAGE-PLAN.md" ], "work": [ @@ -2452,7 +2452,7 @@ "stage": "post-render", "owner": "application", "coordinateSpace": "dom", - "sources": ["shell.ts"], + "sources": ["view.tsx"], "summary": "Retain native overflow, content-width and scroll-preservation policy, the fixed rail and legend, persistent task summary, and complete schedule alternative." }, { @@ -2460,7 +2460,7 @@ "stage": "post-render", "owner": "application", "coordinateSpace": "dom", - "sources": ["tanstack.ts", "shell.ts"], + "sources": ["tanstack.ts", "view.tsx"], "summary": "Use the chart-owned focused point to reveal an offscreen task in the parent scroll viewport and update the persistent summary." } ] @@ -2529,7 +2529,7 @@ "stage": "post-render", "owner": "application", "coordinateSpace": "dom", - "sources": ["summary.ts"], + "sources": ["view.tsx"], "summary": "Retain the cross-view live value summary as an observer of chart-owned focus and selection events." } ] @@ -2566,7 +2566,7 @@ "stage": "post-render", "owner": "application", "coordinateSpace": "dom", - "sources": ["controls.ts"], + "sources": ["view.tsx"], "summary": "Retain domain-specific semantic sliders and live status text over the same controlled position." } ] @@ -2700,7 +2700,7 @@ "stage": "before-definition", "owner": "application", "coordinateSpace": "none", - "sources": ["tanstack.ts", "controls.ts", "model.ts"], + "sources": ["tanstack.ts", "view.tsx", "model.ts"], "summary": "Retain the date input, validation, event constraint, accepted end, commit transaction, status, and exact-value alternative." } ], @@ -3415,7 +3415,7 @@ "stage": "before-definition", "owner": "application", "coordinateSpace": "dom", - "sources": ["layout.ts", "shell.ts"], + "sources": ["layout.ts", "shell.tsx"], "summary": "Retain width-derived host sizing and tooltip presentation." } ] diff --git a/benchmarks/conformance/definition-coverage-roadmap.test.ts b/benchmarks/conformance/definition-coverage-roadmap.test.ts index 951440b9..4cd59f99 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.test.ts +++ b/benchmarks/conformance/definition-coverage-roadmap.test.ts @@ -316,7 +316,10 @@ describe('definition coverage roadmap', () => { if (source.includes('/')) { expect(source).toMatch(/^(?:benchmarks|docs|packages|scripts)\//u) } else { - expect(authoredClosure.has(source)).toBe(true) + expect( + authoredClosure.has(source), + `${entry.id}: ${source} is not reachable from tanstack.ts`, + ).toBe(true) } expect(existsSync(sourcePath)).toBe(true) } diff --git a/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg b/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg index db67340a..39a29e85 100644 --- a/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg +++ b/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 8154f55d..5f0cc46f 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "f71b56214bc650eab205bd970fb5c9ddabae61717dd5b6378f85a1514081441d", + "sourceHash": "61107a358d4bc33d970663907eb733cf5babfa82a20b18abe4fbcc5e455fee3f", "assets": [ { "id": "01-line-gaps", @@ -386,8 +386,8 @@ }, { "id": "87-echarts-synchronized-cursors", - "sha256": "827f8aa0a720d26a538ac69f7781e7326d34d40348b31e46d3fd85bb1a859b10", - "bytes": 8113 + "sha256": "b801e38c12e28147beba54e0a3b6dcb123d1d49463620b8758ef429ce2478119", + "bytes": 7985 }, { "id": "88-echarts-free-cursor", diff --git a/docs/comparison.md b/docs/comparison.md index a9abfa06..46102d37 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 `7ac3c32` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `02e2486` | | [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 `7ac3c32`. +at page render time. The measured TanStack workspace revision is `02e2486`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 37.32–43.28 KiB | — | Controlled suite | +| TanStack Charts | 37.46–43.41 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/framework/angular/adapter.md b/docs/framework/angular/adapter.md index 82ec6d43..8e3919d3 100644 --- a/docs/framework/angular/adapter.md +++ b/docs/framework/angular/adapter.md @@ -32,17 +32,17 @@ standalone component ships as a partial-compiled Angular package. ## Lifecycle `ngOnChanges` creates or updates one shared adapter controller. -`ngAfterViewInit` mounts it into the prerendered surface, and `ngOnDestroy` -cleans it up. Replace the complete `options` value when chart state changes; -mutating the existing object does not produce an `OnPush` input change. -Callbacks such as `onFocusChange` are functions inside `options`, not Angular -outputs. +Angular's `afterNextRender` mounts it into the prerendered surface in the +browser, and `ngOnDestroy` cleans it up. Replace the complete `options` value +when chart state changes; mutating the existing object does not produce an +`OnPush` input change. Callbacks such as `onFocusChange` are functions inside +`options`, not Angular outputs. ## Browser and server status -The verified package contract covers browser mount, immutable updates, and -teardown. Angular application SSR and hydration are not yet part of the -adapter's tested public contract. +The verified package contract covers complete SVG server rendering through +Angular's `renderApplication`, browser mount, immutable updates, and teardown. +Angular hydration is not yet part of the adapter's tested public contract. ## Presentation and rendering diff --git a/packages/angular-charts/package.json b/packages/angular-charts/package.json index 3929e0cd..4023fec6 100644 --- a/packages/angular-charts/package.json +++ b/packages/angular-charts/package.json @@ -31,6 +31,7 @@ "@angular/core": "^22.0.2", "@angular/platform-browser": "^22.0.2", "@angular/platform-browser-dynamic": "^22.0.2", + "@angular/platform-server": "^22.0.2", "ng-packagr": "^22.0.0" }, "publishConfig": { diff --git a/packages/angular-charts/src/Chart.ssr.test.ts b/packages/angular-charts/src/Chart.ssr.test.ts new file mode 100644 index 00000000..30aa476b --- /dev/null +++ b/packages/angular-charts/src/Chart.ssr.test.ts @@ -0,0 +1,50 @@ +import '@angular/compiler' +import { Component } from '@angular/core' +import { bootstrapApplication } from '@angular/platform-browser' +import { renderApplication } from '@angular/platform-server' +import { describe, expect, it } from 'vitest' +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from 'd3-scale' +import { Chart } from './index' +import type { ChartOptions } from './index' + +const rows = [ + { id: 'a', x: 0, y: 2 }, + { id: 'b', x: 1, y: 4 }, +] +const definition = defineChart({ + marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, +}) + +@Component({ + selector: 'test-server-chart', + standalone: true, + imports: [Chart], + template: '', +}) +class ServerChartHost { + options: ChartOptions<(typeof rows)[number]> = { + definition, + height: 260, + ariaLabel: 'Server revenue', + } +} + +describe('Angular adapter SSR', () => { + it('server-renders complete SVG without mounting the DOM host', async () => { + const html = await renderApplication( + (context) => + bootstrapApplication(ServerChartHost, { providers: [] }, context), + { + document: + '', + }, + ) + + expect(html).toContain(' - implements OnChanges, AfterViewInit, OnDestroy + implements OnChanges, OnDestroy { @Input({ required: true }) declare options: ChartOptions @@ -172,6 +172,12 @@ export class Chart< ChartTooltipBodyTemplateContext > + constructor() { + afterNextRender(() => { + this.adapter?.mount(this.surface.nativeElement) + }) + } + ngOnChanges(_changes: SimpleChanges) { if (!this.options) return const layout = resolveChartAdapterLayout(this.options) @@ -194,10 +200,6 @@ export class Chart< this.updateAdapter() } - ngAfterViewInit() { - this.adapter?.mount(this.surface.nativeElement) - } - ngOnDestroy() { this.adapter?.destroy() this.destroyTooltipBodyView() diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index a9abfa06..46102d37 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 `7ac3c32` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `02e2486` | | [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 `7ac3c32`. +at page render time. The measured TanStack workspace revision is `02e2486`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 37.32–43.28 KiB | — | Controlled suite | +| TanStack Charts | 37.46–43.41 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/framework/angular/adapter.md b/packages/charts-core/docs/framework/angular/adapter.md index 82ec6d43..8e3919d3 100644 --- a/packages/charts-core/docs/framework/angular/adapter.md +++ b/packages/charts-core/docs/framework/angular/adapter.md @@ -32,17 +32,17 @@ standalone component ships as a partial-compiled Angular package. ## Lifecycle `ngOnChanges` creates or updates one shared adapter controller. -`ngAfterViewInit` mounts it into the prerendered surface, and `ngOnDestroy` -cleans it up. Replace the complete `options` value when chart state changes; -mutating the existing object does not produce an `OnPush` input change. -Callbacks such as `onFocusChange` are functions inside `options`, not Angular -outputs. +Angular's `afterNextRender` mounts it into the prerendered surface in the +browser, and `ngOnDestroy` cleans it up. Replace the complete `options` value +when chart state changes; mutating the existing object does not produce an +`OnPush` input change. Callbacks such as `onFocusChange` are functions inside +`options`, not Angular outputs. ## Browser and server status -The verified package contract covers browser mount, immutable updates, and -teardown. Angular application SSR and hydration are not yet part of the -adapter's tested public contract. +The verified package contract covers complete SVG server rendering through +Angular's `renderApplication`, browser mount, immutable updates, and teardown. +Angular hydration is not yet part of the adapter's tested public contract. ## Presentation and rendering diff --git a/packages/charts-core/src/reconcile.test.ts b/packages/charts-core/src/reconcile.test.ts index 3ed75708..dc477d41 100644 --- a/packages/charts-core/src/reconcile.test.ts +++ b/packages/charts-core/src/reconcile.test.ts @@ -83,6 +83,74 @@ describe('keyed SVG reconciliation', () => { cancelFrame.mockRestore() }) + it('keeps SVG arc flags discrete while interpolating path geometry', () => { + const container = document.createElement('div') + const callbacks: FrameRequestCallback[] = [] + const requestFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + callbacks.push(callback) + return callbacks.length + }) + const cancelFrame = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => {}) + reconcileChartSvg( + container, + '', + ) + const arc = container.querySelector('path') + + reconcileChartSvg( + container, + '', + { duration: 100, easing: 'linear' }, + ) + + callbacks.shift()?.(0) + callbacks.shift()?.(50) + expect(arc?.getAttribute('d')).toBe('M 5 0 A 45 45 0 1 1 90 0') + callbacks.shift()?.(100) + expect(arc?.getAttribute('d')).toBe('M 10 0 A 50 50 0 1 1 100 0') + + requestFrame.mockRestore() + cancelFrame.mockRestore() + }) + + it('parses adjacent SVG arc flags as separate values', () => { + const container = document.createElement('div') + const callbacks: FrameRequestCallback[] = [] + const requestFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + callbacks.push(callback) + return callbacks.length + }) + const cancelFrame = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => {}) + reconcileChartSvg( + container, + '', + ) + const arc = container.querySelector('path') + + reconcileChartSvg( + container, + '', + { duration: 100, easing: 'linear' }, + ) + + callbacks.shift()?.(0) + callbacks.shift()?.(50) + expect(arc?.getAttribute('d')).toBe('M 5 0 A 45 45 0 01 90 0') + callbacks.shift()?.(100) + expect(arc?.getAttribute('d')).toBe('M 10 0 A 50 50 0 01 100 0') + + requestFrame.mockRestore() + cancelFrame.mockRestore() + }) + it('interpolates numeric label typography and snaps categorical anchors', () => { const container = document.createElement('div') const callbacks: FrameRequestCallback[] = [] diff --git a/packages/charts-core/src/reconcile.ts b/packages/charts-core/src/reconcile.ts index bda7f3e8..00ef4d50 100644 --- a/packages/charts-core/src/reconcile.ts +++ b/packages/charts-core/src/reconcile.ts @@ -157,7 +157,7 @@ function syncAttributes( previous !== null && target !== null && interpolatedAttributes.has(name) - ? interpolateAttribute(previous, target) + ? interpolateAttribute(name, previous, target) : undefined if (interpolate && tweens) { tweens.push({ element: current, name, interpolate, target }) @@ -247,11 +247,13 @@ function finishTweens(tweens: readonly AttributeTween[]) { } function interpolateAttribute( + name: string, previous: string, next: string, ): ((progress: number) => string) | undefined { - const previousNumbers = extractNumbers(previous) - const nextNumbers = extractNumbers(next) + const path = name === 'd' + const previousNumbers = extractNumbers(previous, path) + const nextNumbers = extractNumbers(next, path) if ( previousNumbers.skeleton !== nextNumbers.skeleton || previousNumbers.values.length !== nextNumbers.values.length || @@ -260,26 +262,53 @@ function interpolateAttribute( return undefined } + const template = nextNumbers.skeleton return (progress) => { let index = 0 - return nextNumbers.skeleton.replaceAll('#', () => { + return template.replaceAll(/[#!]/g, (placeholder) => { const start = previousNumbers.values[index] const end = nextNumbers.values[index] index += 1 - return formatNumber(start + (end - start) * progress) + return formatNumber( + placeholder === '!' ? end : start + (end - start) * progress, + ) }) } } -function extractNumbers(value: string) { +function extractNumbers(value: string, path = false) { const values: number[] = [] - const skeleton = value.replace( - /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi, - (match) => { - values.push(Number(match)) - return '#' - }, - ) + let skeleton = '' + let command = '' + let argument = 0 + let index = 0 + + while (index < value.length) { + const rest = value.slice(index) + const arcPosition = argument % 7 + const arcFlag = + path && /a/i.test(command) && arcPosition > 2 && arcPosition < 5 + const match = arcFlag + ? /^[01]/u.exec(rest) + : /^-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/iu.exec(rest) + + if (match) { + values.push(Number(match[0])) + skeleton += arcFlag ? '!' : '#' + argument += 1 + index += match[0].length + continue + } + + const character = value[index]! + skeleton += character + if (path && /[a-z]/i.test(character)) { + command = character + argument = 0 + } + index += 1 + } + return { skeleton, values } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f4b93d6..a39c5a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,6 +569,9 @@ importers: '@angular/platform-browser-dynamic': specifier: ^22.0.2 version: 22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.0.8)(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(@angular/platform-browser@22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))) + '@angular/platform-server': + specifier: ^22.0.2 + version: 22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.0.8)(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(@angular/platform-browser@22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2)))(rxjs@7.8.2) ng-packagr: specifier: ^22.0.0 version: 22.0.2(@angular/compiler-cli@22.0.8(@angular/compiler@22.0.8)(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(tslib@2.8.1)(typescript@6.0.3) @@ -1018,6 +1021,19 @@ packages: '@angular/animations': optional: true + '@angular/platform-server@22.0.8': + resolution: + { + integrity: sha512-uG2/7vqb4hKnGVYN0khcA0W46AssKnYEa2l1kYoKcjz6JH/3ExHJohY3mgH5gwkpJCXT/xePv9S9AQsvwluh6w==, + } + engines: { node: ^22.22.3 || ^24.15.0 || >=26.0.0 } + peerDependencies: + '@angular/common': 22.0.8 + '@angular/compiler': 22.0.8 + '@angular/core': 22.0.8 + '@angular/platform-browser': 22.0.8 + rxjs: ^6.5.3 || ^7.4.0 + '@asamuzakjp/css-color@5.1.11': resolution: { @@ -9454,6 +9470,13 @@ packages: } engines: { node: '>=10.0.0' } + xhr2@0.2.1: + resolution: + { + integrity: sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==, + } + engines: { node: '>= 6' } + xml-name-validator@5.0.0: resolution: { @@ -9642,6 +9665,16 @@ snapshots: '@angular/core': 22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2) tslib: 2.8.1 + '@angular/platform-server@22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.0.8)(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(@angular/platform-browser@22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/compiler': 22.0.8 + '@angular/core': 22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2) + '@angular/platform-browser': 22.0.8(@angular/common@22.0.8(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.8(@angular/compiler@22.0.8)(rxjs@7.8.2)) + rxjs: 7.8.2 + tslib: 2.8.1 + xhr2: 0.2.1 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -15224,6 +15257,8 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xhr2@0.2.1: {} + xml-name-validator@5.0.0: {} xml2js@0.6.0: diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index c2dc56f9..396dc789 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1623,7 +1623,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 26.95, + 27.05, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1665,7 +1665,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 18.1, + 18.2, { rendererBoundary: 'svg', inputBoundary: { @@ -1771,7 +1771,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 51.05, + 51.2, ), locked( 'Custom-scale line scene', @@ -1822,12 +1822,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 35.3, + 35.4, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 40.5, + 40.7, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2031,7 +2031,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 51.9, + 52.1, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index e3718797..8eb7496a 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -567,10 +567,7 @@ const callbackInventory = { // Consumer-called handles and lifecycle/service protocols are methods, not // application callbacks. serviceMethod: [ - [ - '@tanstack/angular-charts:src/Chart.ts:Chart', - 'ngAfterViewInit ngOnChanges ngOnDestroy', - ], + ['@tanstack/angular-charts:src/Chart.ts:Chart', 'ngOnChanges ngOnDestroy'], [ '@tanstack/angular-charts:src/ChartTooltipBody.ts:ChartTooltipBodyDirective', 'ngTemplateContextGuard',