diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index 7eb0b68..3335067 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -40,8 +40,17 @@ jobs: - run: pnpm benchmark:check - run: pnpm catalog:check - run: pnpm catalog:build + - run: pnpm catalog:deploy:check - run: git diff --check + - name: Upload production catalog + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: charts-catalog-${{ github.sha }} + path: examples/conformance/dist + if-no-files-found: error + compare: needs: validate runs-on: ubuntu-latest @@ -72,7 +81,7 @@ jobs: conformance: needs: validate runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -85,7 +94,12 @@ jobs: - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium - - run: pnpm conformance:quick + - name: Run pull-request conformance + if: github.event_name == 'pull_request' + run: pnpm conformance:quick + - name: Run production conformance + if: github.event_name != 'pull_request' + run: pnpm conformance - run: cat .benchmark-output/conformance/results/plot-catalog.md >> "$GITHUB_STEP_SUMMARY" - name: Upload conformance @@ -136,3 +150,40 @@ jobs: name: chart-library-stress-${{ github.run_id }} path: .benchmark-output/stress/results if-no-files-found: error + + deploy-catalog: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: + - validate + - conformance + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: charts-catalog-production + url: https://tanstack.com/charts/catalog/ + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download validated catalog + uses: actions/download-artifact@v4 + with: + name: charts-catalog-${{ github.sha }} + path: examples/conformance/dist + + - name: Stage catalog below its public path + run: node scripts/stage-conformance-deployment.mjs + + - name: Deploy catalog Worker + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + command: deploy --config wrangler.catalog.jsonc + wranglerVersion: 4.103.0 + + - name: Verify production routes + run: node scripts/check-catalog-deployment.mjs diff --git a/.gitignore b/.gitignore index 5c19ab3..519aba4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules dist .bundle-output .benchmark-output +.catalog-deploy +.catalog-worker +.wrangler coverage *.log tanstack.com-parity/ diff --git a/AGENTS.md b/AGENTS.md index d906610..ba7ad94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,3 +13,7 @@ - Author public documentation only in the root `docs/` tree. The `packages/charts-core/docs` tree and both `llms.txt` files are generated by `pnpm docs:sync`; never edit those generated copies directly. +- In Codex on macOS, run Playwright/Chromium commands with escalated sandbox + permissions on the first attempt. The restricted sandbox blocks Chromium's + Mach bootstrap services; do not probe browser launches inside it. This + includes `benchmark`, `benchmark:stress:*`, and browser-backed `conformance`. diff --git a/API-FRICTION.md b/API-FRICTION.md index 738c4fb..f9e5e18 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -152,6 +152,12 @@ Each entry records: | F-114 | Gradient stop tokens disappeared from standalone exports | API | resolved | | F-115 | Documentation checks did not validate code snippets | Tooling | resolved | | F-116 | Build context was mistaken for resolved plot geometry | Documentation | resolved | +| F-117 | Non-Cartesian examples duplicated coordinate engines | API | resolved | +| F-118 | Serialized SVG discarded interaction semantics | API/Application | monitoring | +| F-119 | Catalog hosting crossed repository ownership | Tooling | resolved | +| F-120 | Key-only focus collapsed duplicate observations | API | resolved | +| F-121 | SVG callback was not a rendering-pipeline boundary | API | resolved | +| F-122 | Dense scene aggregation overflowed the call stack | API | resolved | ## Findings @@ -614,10 +620,17 @@ Each entry records: non-interactively against the existing install. - Decision: pin the repository package manager and lockfile, then use the documented pnpm scripts against that install rather than bypassing package - management with ad hoc binary paths. + management with ad hoc binary paths. Keep CI mode consistent between install + and validation: pnpm 11 changes `enableGlobalVirtualStore` under CI, so + mixing a CI install with a non-CI `pnpm exec` correctly reports the install + as incompatible. - Verification: repeated `pnpm exec prettier`, TypeScript, Vitest, Vite, benchmark, bundle, catalog, and conformance commands now run - non-interactively without requesting a module purge. + non-interactively without requesting a module purge. The polar/geo task + reproduced the warning after a CI install followed by a non-CI exec; + `pnpm_config_verify_deps_before_run=warn` identified the changed global + virtual-store setting, and consistently using `CI=true` restored all + documented commands without another purge. ### F-028 — Field channels accepted incompatible value types @@ -783,12 +796,15 @@ Each entry records: the complete renderer output without assuming either library emits one SVG. - Decision: choose the largest rendered SVG as the primary chart, sum the serialized bytes of every SVG, and test every SVG text element against the - chart container. Plot legends intentionally allow endpoint labels to - overflow their small owner SVG while remaining visible inside the container. + chart container. Exclude labels whose computed display, visibility, or + opacity makes them non-rendered; a hidden SVG text node has no containment + contract. Plot legends intentionally allow endpoint labels to overflow their + small owner SVG while remaining visible inside the container. - Verification: the runner and gallery now aggregate every emitted SVG, while primary geometry uses the chart-sized SVG and multi-SVG legend labels are checked against the same container-level visibility contract as chart - labels. + labels. The label-free Recharts sunburst can now use `display: none` + directly instead of zero-size transparent text. ### F-036 — Presence-only visual checks overstated parity @@ -804,12 +820,17 @@ Each entry records: - Decision: enforce expected primitive counts as minima, normalize diagnostic geometry against the chart SVG, and allow cases to assert categorical guide sequences, maximum label repetition, and corresponding computed data-mark - paints. Equivalent RGB interpolation output may differ by one channel unit - because Plot and direct D3 scale paths round colors differently. + paints. Paint comparison canonicalizes computed `rgb()`/`rgba()` and + driver-reported three-, four-, six-, or eight-digit hex colors. Equivalent + interpolation output may differ by one channel unit because Plot and direct + D3 scale paths round colors differently. - Verification: the corrected histogram boundary contract renders all seven bins, explicit bar domains preserve category order, paired paints pass for all implemented cases, and the Anscombe case now fails specifically because - Charts repeats its shared y-axis four times. + Charts repeats its shared y-axis four times. The ECharts polar line and + scatter drivers report hex colors while TanStack inspection reads computed + RGB; both now pass the six-variant standard paint gate without case-specific + color rewriting. ### F-037 — Facets repeat shared axes in every panel @@ -1232,16 +1253,17 @@ Each entry records: five radial tick labels and placed the six angle labels at a different offset. The case also used a smaller radius and margin than the official reference. -- Decision: keep polar scaffolding in the case-local custom mark, but make the - reference use its documented 80% radius and 20 px margin. Mirror Recharts' - eight-pixel angle-label offset, rotated 30-degree radial axis, and distinct - angle/radius label colors. Assert all eleven labels as data-bearing visual - geometry so the omission cannot pass again. +- Decision: the reference uses its documented 80% radius and 20 px margin. + The native polar composition uses `radialGrid`, `angleGrid`, and + `radialArea`; guide label callbacks preserve Recharts' eight-pixel + angle-label offset, rotated 30-degree radial axis, baselines, and distinct + angle/radius label colors. All eleven labels remain data-bearing visual + geometry so an omission cannot pass again. - Verification: focused responsive initial/update conformance passes at 320 and 640 px with all eleven labels present, matching paints, no overflow, - 100.0% diagnostic geometry similarity, clean strict types, and 0.07× - Recharts gzip. The fix changes only the comparison case; no library bundle - or universal renderer path changed. + 100.0% diagnostic geometry similarity, clean strict types, and 0.16× + Recharts gzip. The standard matrix also passes at 320, 640, and 960 px in + light and dark themes without a case-local coordinate renderer. ### F-059 — Vite cached a newly added package subpath @@ -2486,3 +2508,184 @@ Each entry records: - Verification: the responsive, dynamic-data, faceting, large-data, and custom extension pages use the same boundary, and responsive examples no longer claim that builder dimensions are final inner bounds. + +### F-117 — Non-Cartesian examples duplicated coordinate engines + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: native pie, donut, gauge, radar, polar line/scatter, radial + hierarchy, and atlas-backed GeoJSON catalog expansion +- Friction: scene nodes could already carry arbitrary paths, but the public API + did not own a non-Cartesian coordinate system. The radar case duplicated + responsive centering, angular projection, polygon grids, spokes, and label + placement in a 211-line custom mark; the GeoJSON case separately rebuilt + responsive projection, paths, styling, and identity. Pie, donut, and gauge + examples were absent rather than forcing consumers through more local marks. + Expanding the five seed cases into the expected catalog then exposed two + narrower holes: polar labels and radial segments still required a custom + mark, while projected Point geometry could not pass a datum radius through + D3 `geoPath`. The first numeric polar-line comparison also exposed that the + specialized radial-line group lacked the generic line-role class shared by + catalog and inspection tooling. +- Decision: add opt-in `@tanstack/charts/polar` and + `@tanstack/charts/geo` entry points. `polar` copies configured D3 angle and + radius scales into final responsive bounds and composes D3-backed arcs, + radial lines, radial areas, dots, text, rules, and guides. `geoShape` accepts + a responsive `d3-geo` projection factory, delegates path and centroid + geometry to D3, and maps `r`/`rScale` through `geoPath().pointRadius()`. + Radial line and dot groups also carry their generic geometry-role classes. + Neither capability is re-exported from the package root. Boundary datasets + and TopoJSON conversion stay application-owned inputs rather than package + dependencies. +- Verification: focused core tests cover responsive scale copying, source-scale + immutability, D3 path equality, wrapped seams, advanced per-datum arc + generators, guide layering, semantic role classes, missing-scale errors, + projected GeoJSON paths, and interaction points. The polar expansion covers + labels, center content, rounded and nested rings, rose, needle gauge, + comparative radar, radial bars, sunburst, numeric line, and numeric scatter. + The geographic expansion covers regional and 177-country choropleths, + proportional symbols, a 51-feature Albers USA map, orthographic globe, + projected routes, and a four-projection atlas gallery. Isolated gzip gates + are 10.50 KiB for geo, 8.69 KiB for arcs, 9.12 KiB for D3 pie plus arcs, and + 18.50 KiB for a complete polar line/scatter composition under an 18.75 KiB + ceiling, while atlas data and every locked Cartesian entry remain outside + those bundles. The full scale-backed gauge composition is independently + budgeted at 17.5 KiB gzip. + Numeric polar line/scatter pass the six-variant standard matrix at 100.0% + geometry similarity; country and state atlas cases pass at 99.9%, and the + four-pane projection gallery passes at 99.8%. All 328 tests, strict + typechecking, isolated bundle gates, 64-page documentation checks, packed + exports/declarations/runtime imports, 100 catalog pages and embeds, and all + example builds pass. + +### F-118 — Serialized SVG discarded interaction semantics + +- Status: monitoring +- Severity: medium +- Owner: API/Application +- Observed in: adding tooltips to the TanStack Charts landing-page SVGs +- Friction: `renderChartSvg` preserved mark geometry, axis keys, focus + affordances, and accessible chart text, but not the resolved `ChartPoint` + scene or tooltip runtime. Keeping the marketing assets as portable server + SVG therefore required one application adapter to recover points from + circles, bars, line paths, and axis coordinates. Rounded SVG coordinates + also placed interpolated dates seconds before midnight, so date tooltips + needed exact encoded mark keys when available and UTC-day snapping + otherwise. +- Decision: keep this recovery logic isolated in the landing-page interaction + component and do not present serialized SVG as a generally hydratable chart + contract. Revisit supported interaction metadata or hydration only if + another static-SVG consumer encounters the same boundary. +- Verification: all 14 landing SVG variants and the custom bundle chart expose + formatted pointer and keyboard tooltips; compact and wide revenue tooltips + retain exact dates and grouped series values. Site typechecking, targeted + type-aware lint, unit tests, production build, and desktop/mobile browser + checks pass. + +### F-119 — Catalog hosting crossed repository ownership + +- Status: resolved +- Severity: high +- Owner: Tooling/Integration +- Observed in: publishing the executable catalog at + `https://tanstack.com/charts/catalog/` +- Friction: the catalog source, conformance contract, and production build + belong to the Charts repository, while the public hostname is served by the + separate `tanstack.com` Cloudflare Worker. Copying source or generated assets + into that repository would couple releases and rollbacks, duplicate build + ownership, and make the site bundle responsible for reference libraries it + does not use. Pointing Workers Static Assets directly at the Vite `dist` + directory also fails below a path prefix because asset lookup retains the + complete public request path. +- Decision: deploy a separate assets-only `tanstack-charts-catalog` Worker from + this repository. Its `/charts/catalog*` route takes precedence over the main + Worker and covers query strings on the bare path. A generated, ignored + staging tree mirrors `dist` below `.catalog-deploy/charts/catalog/`; the + Worker version therefore owns its HTML, hashed assets, route manifest, + headers, and rollback atomically without a proxy or a `tanstack.com` source + dependency. Main-branch deployment waits for validation and the unfiltered + conformance matrix. Because Wrangler's deploy dry run does not boot workerd, + the gate also starts the pinned local runtime without persistent state and + applies the production smoke contract before upload. +- Verification: staging rejects the wrong origin or base path, symlinks, more + than 20,000 files, or an asset above 25 MiB. A Wrangler dry run validates the + static deployment. The local runtime caught and corrected a compatibility + date newer than Wrangler 4.103.0's bundled workerd supported. Worker version + `7df8f6a9-103b-40f7-9b24-315908a92ac3` then deployed 945 static assets to the + TanStack account. The live production smoke passed the bare and queried + canonical redirects, root, 100-case metadata, detail, frameable embed, + immutable hashed asset, security headers, and nearest 404 page. Route + propagation exceeded the first 30-second smoke window, so the deploy check + now allows 90 seconds. +- Follow-up: navigation and root-sitemap discovery may remain a small site + integration; neither should own catalog source or artifacts. + +### F-120 — Key-only focus collapsed duplicate observations + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: native line tooltip path-hover regression +- Friction: the renderer host stored only the focused point's public `key`. + When multiple observations in one line shared that key, moving between them + was ignored, and an otherwise no-render host update repainted the first + matching observation instead of the point nearest the pointer. +- Decision: retain the focused `ChartPoint` as the current-scene identity. + Compare its key, mark, and datum index during interaction; on a scene render, + restore an ambiguous key by datum reference, semantic point values, then + datum index. Stable unique datum keys remain the preferred authoring path. +- Verification: the DOM-host regression dispatches pointer movement from the + actual SVG line path to a later duplicate-key point, updates tooltip options + without rendering, forces a responsive render, and moves back to the first + point. The callback, focus marker, and tooltip stay on the exact observation + throughout. All 25 focused runtime tests pass. + +### F-121 — SVG callback was not a rendering-pipeline boundary + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: adding an optional Canvas renderer without changing the default + SVG consumer path +- Friction: `ChartSvgRenderer` replaced scene-to-string serialization, but the + host still owned SVG root discovery, coordinate mapping, focus painting, + keyed reconciliation, and SVG-shaped `onRender` callbacks. A Canvas consumer + could compile `ChartScene`, but preserving responsive sizing, runtime reuse, + pointer and keyboard focus, tooltips, selection, SSR, and framework + lifecycles required reimplementing the host. Adding Canvas to the existing + default adapter would also make an optional renderer reachable from + SVG-only bundles. +- Decision: introduce `ChartRenderer` for deterministic prerendering and + mounted-surface creation, `ChartSurface` for paint, coordinate, focus, and + cleanup ownership, and `mountChartRenderer` for shared host behavior. Keep + `mountChart` and `ChartSvgRenderer` as SVG compatibility APIs. Publish Canvas + through `@tanstack/charts/canvas` and framework `/canvas` entries, while + framework `/core` entries accept an application-supplied renderer. +- Verification: core, React, and Octane tests cover deterministic SSR, + hydration identity, renderer replacement, and shared + pointer/keyboard/tooltip/selection behavior. Native Chromium verification + covers device-pixel-ratio backing stores, `Path2D`, CSS color resolution, + gradients, focus isolation, resizing, and PNG export. Bundle metafiles and + packed-consumer checks enforce that renderer-neutral entries include neither + renderer and Canvas entries include no SVG reconciler. + +### F-122 — Dense scene aggregation overflowed the call stack + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: the one-million-point Canvas stress check +- Friction: scene compilation aggregated channel values and interaction points + with `push(...values)`. Large valid arrays exceeded the JavaScript engine's + argument limit before the selected renderer could paint them. Facet and + polar aggregation used the same unsafe pattern. +- Decision: append dense collections with bounded loops in the shared scene, + facet, and polar compilers. This keeps the public data contract intact and + fixes the failure before it reaches any renderer. +- Verification: a 200,000-value scene regression completes without an + argument-limit error. Native Chromium also compiles and mounts one million + dots through the Canvas renderer with four surface descendants. That check + still retains roughly 427 MiB of JavaScript heap and spends most first-paint + time rasterizing the dense path, so Canvas removes per-mark DOM cost rather + than making unbounded data free. diff --git a/MARKETING.md b/MARKETING.md index 7c7aabe..7d43a0a 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -1,13 +1,14 @@ # TanStack Charts Marketing Strategy -Last updated: 2026-07-27 +Last updated: 2026-07-28 ## Status -TanStack Charts is currently a private `0.0.0` product proof. Until the -production gates in [`PLAN.md`](./PLAN.md) are complete, marketing should invite -people to explore the proof, follow development, or join early access. It -should not imply that the packages are ready for production installation. +TanStack Charts is currently an unpublished, private `0.0.0` package proof in a +public repository. Until the production gates in [`PLAN.md`](./PLAN.md) are +complete, marketing should invite people to explore the proof, follow +development, or join early access. It should not imply that the packages are +ready for production installation. ## Executive summary @@ -30,13 +31,13 @@ an application charting system from D3 or low-level primitives. > library. TanStack Charts lets teams retain D3's algorithms and expressive ceiling while -TanStack owns responsive layout, scene compilation, SVG rendering, SSR and -hydration, themes, accessibility, interaction, animation, export, and -framework lifecycle. +TanStack owns responsive layout, scene compilation, default SVG and opt-in +Canvas rendering, SSR and hydration, themes, accessibility, interaction, +animation, export, and framework lifecycle. ## Product overview -**One-line description:** A tiny TypeScript visualization grammar for +**One-line description:** A lightweight TypeScript visualization grammar for responsive, accessible, server-rendered application charts, powered by native D3 primitives. @@ -156,8 +157,8 @@ system or wait for a fixed chart catalog to expose an upstream D3 capability. TanStack owns the work that D3 deliberately leaves to an application: responsive ranges, automatic guide layout, scene compilation, DOM lifecycle, -keyed reconciliation, interaction, accessibility, themes, SSR, hydration, and -export. +keyed SVG reconciliation, Canvas painting, interaction, accessibility, themes, +SSR, hydration, and export. ### Existing data stays intact @@ -167,9 +168,9 @@ identity survives interaction and callbacks. ### Framework-independent engine -Definitions, scene calculation, static SVG, and the vanilla host do not depend -on React. Framework adapters bind the same host and scene protocol to their -lifecycle. +Definitions, scene calculation, static SVG, the vanilla host, and the optional +Canvas renderer do not depend on React. Framework adapters bind the same host +and scene protocol to their lifecycle. ### Capability-level bundle ownership @@ -181,14 +182,15 @@ side-effectful feature installation. ### Positioning map -| Alternative | Best at | TanStack Charts position | -| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Recharts](https://recharts.github.io/en-US/) and [Nivo](https://nivo.rocks/) | Quickly producing standard React charts | A framework-independent grammar that can continue into bespoke visualization | -| [visx](https://github.com/airbnb/visx) and raw D3 | Maximum low-level visual control | Native D3 power with responsive layout, guides, rendering, interaction, accessibility, SSR, and lifecycle supplied | -| [Observable Plot](https://observablehq.com/plot/) | Concise exploratory visualization through composable marks and channels | The primary conceptual inspiration; an independent implementation for typed application code, explicit D3 ownership, framework lifecycle, and small capability imports | -| [Chart.js](https://www.chartjs.org/) and [Apache ECharts](https://echarts.apache.org/en/index.html) | Broad catalogs and Canvas-oriented rendering | Smaller, SVG-native, composable, and designed for product-specific charts | -| [AG Charts](https://www.ag-grid.com/charts/) | Enterprise breadth, specialized charts, dense data, controls, and support | Open grammar, D3 interoperability, actual SVG SSR, heterogeneous layers, and no global registry | -| [Vega and Vega-Lite](https://vega.github.io/) | Portable declarative specifications and analysis tooling | Ordinary TypeScript and application integration instead of a JSON visualization runtime | +| Alternative | Best at | TanStack Charts position | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Recharts](https://recharts.github.io/en-US/) and [Nivo](https://nivo.rocks/) | Quickly producing standard React charts | A framework-independent grammar that can continue into bespoke visualization | +| [visx](https://github.com/airbnb/visx) and raw D3 | Maximum low-level visual control | Native D3 power with responsive layout, guides, rendering, interaction, accessibility, SSR, and lifecycle supplied | +| [Observable Plot](https://observablehq.com/plot/) | Concise exploratory visualization through composable marks and channels | The primary conceptual inspiration; an independent implementation for typed application code, explicit D3 ownership, framework lifecycle, and capability-level imports | +| [Chart.js](https://www.chartjs.org/) and [Apache ECharts](https://echarts.apache.org/en/index.html) | Broad catalogs and Canvas-oriented rendering | Smaller in the current like-for-like consumer benchmark, SVG by default with opt-in Canvas, composable, and designed for product-specific charts | +| [uPlot](https://github.com/leeoniya/uPlot), [Chartist](https://chartist.dev/), [Frappe Charts](https://github.com/frappe/charts), and [Lightweight Charts](https://github.com/tradingview/lightweight-charts) | Deliberately small or specialized charting surfaces | Competitive in the small-bundle class with a broader grammar; TanStack should claim capability-scaled size, not absolute size leadership | +| [AG Charts](https://www.ag-grid.com/charts/) | Enterprise breadth, specialized charts, dense data, controls, and support | Open grammar, D3 interoperability, actual SVG SSR, heterogeneous layers, and no global registry | +| [Vega and Vega-Lite](https://vega.github.io/) | Portable declarative specifications and analysis tooling | Ordinary TypeScript and application integration instead of a JSON visualization runtime | ### Primary comparisons @@ -220,7 +222,7 @@ pretend to match. See the current **Headline:** D3's power. A chart library's ergonomics. -**Body:** TanStack Charts is a tiny TypeScript visualization grammar for +**Body:** TanStack Charts is a lightweight TypeScript visualization grammar for responsive, accessible, server-rendered charts, deeply inspired by Observable Plot. Compose marks over your existing data, bring native D3 scales and curves, and render the same definition in React, vanilla JavaScript, or Octane. @@ -241,8 +243,8 @@ standard. **Body:** Catalog libraries get the first chart on screen quickly, but product requirements rarely stop there. D3 gives you complete control, but leaves your team responsible for layout, rendering, lifecycle, interaction, -accessibility, and framework integration. TanStack Charts gives you one small -grammar from the common case to the custom one. +accessibility, and framework integration. TanStack Charts gives you one +composable grammar from the common case to the custom one. ### Value section @@ -264,8 +266,8 @@ application runtime, not a second implementation of visualization math. #### Built for applications, not screenshots. Container responsiveness, automatic guide margins, light and dark themes, -keyboard interaction, tooltips, keyed updates, SVG SSR, hydration, and export -are part of the runtime. +keyboard interaction, tooltips, keyed SVG updates, opt-in Canvas painting, SVG +SSR, hydration, and export are part of the runtime. #### Pay only for what you import. @@ -282,7 +284,7 @@ The flagship demonstration should evolve one chart in place: 2. Add an area, baseline, and event data from separate arrays. 3. Add a product-specific custom mark. 4. Make the definition responsive to its container. -5. Render the same definition through React, server SVG, and export. +5. Render the same definition through React, Canvas, server SVG, and export. 6. Show the resulting bundle trace. This demonstration expresses the product thesis better than a gallery of @@ -290,23 +292,104 @@ unrelated chart thumbnails. ### Proof section -**Headline:** Small by construction, measured by consumer. - -The controlled common-chart bundle suite currently measures: - -| Library | Common consumer gzip range | -| ------------------------ | -------------------------: | -| TanStack Charts | 18.5–19.2 kB | -| Chart.js | 45.7–51.5 kB | -| Observable Plot | 85.2–94.1 kB | -| Recharts, React external | 97.1–98.6 kB | -| Apache ECharts | 156.6–163.1 kB | +**Headline:** Complete charts around 18–22 KiB gzip. + +The durable public claim is: + +> Complete tested charts are approximately 18–22 KiB gzip. A static SVG line +> is 13.30 KiB gzip. Consumers pay for the capabilities they import. + +Do not lead with the 4.70 KiB custom-scale scene. It proves the scene compiler +has a low isolated cost when an application supplies its own scale, but it is +not a complete rendered chart. + +#### TanStack consumer boundaries + +| Consumer surface | Gzip size | Evidence status | +| --------------------------------------------- | --------------: | ------------------------------------------------------- | +| Custom-scale line scene, no renderer | 4.70 KiB | Exact byte lock; not a complete chart | +| D3-scale line with static SVG | 13.30 KiB | Exact byte lock | +| Mounted basic line, bar, area, or scatter | 18.08–18.79 KiB | Checked four-chart comparison baseline | +| Mounted chart with legend and pointer tooltip | 18.37–19.08 KiB | Checked four-chart comparison baseline | +| Advanced two-series composition | 18.38–21.26 KiB | Checked four-chart comparison baseline | +| React line consumer, with React externalized | 19.49 KiB | Exact byte lock | +| TanStack Stats parity surface | 30.68 KiB | Isolated measurement under a 30.9 KiB capability budget | + +The exact ordinary-consumer locks live in +[`universal-baseline.json`](./benchmarks/bundle-size/universal-baseline.json). +The isolated feature ceilings and their policy live in +[`measure-bundles.mjs`](./scripts/measure-bundles.mjs). + +#### Popular general-purpose libraries + +The controlled suite covers five libraries, four chart families, and three +capability tiers. Basic means one series with axes and grid. Interactive adds a +legend and pointer tooltip. Advanced adds two-series smoothing, stacking, or +variable point size. + +| Gzip comparison | Basic | Interactive | Advanced | +| -------------------------- | --------------: | --------------: | --------------: | +| TanStack Charts | 18.08–18.79 KiB | 18.37–19.08 KiB | 18.38–21.26 KiB | +| Chart.js | 2.47–2.76× | 2.85–3.13× | 2.68–2.85× | +| Observable Plot | 4.54–5.04× | 4.47–4.95× | 4.31–4.54× | +| Recharts, React external | 5.05–5.33× | 5.75–5.89× | 5.13–5.89× | +| Recharts, full cold bundle | 8.14–8.55× | 8.81–9.06× | 7.88–9.07× | +| Apache ECharts | 8.37–8.74× | 8.96–9.33× | 8.15–9.08× | + +Competitor cells are competitor gzip divided by TanStack gzip, ranged across +the matched line, bar, area, and scatter fixtures. Across every matched fixture +in this controlled suite, TanStack shipped 60–89% less gzipped JavaScript. + +This supports “substantially smaller than the measured mainstream libraries.” +It does not support “smaller than every popular charting library.” Highcharts, +ApexCharts, Nivo, Victory, visx, and Plotly are not yet in the controlled +matrix. Visx is the most important modular challenger to measure next. + +#### Small-library screen + +The following is a dated exploratory screen, not launch-proof evidence. It used +the same minified, tree-shaken browser ESM build target, included required CSS +for Chartist and uPlot, and produced identical output across five builds. + +| Library and fixture | Gzip size | Marketing interpretation | +| ------------------------------------- | --------: | ---------------------------------------------------------- | +| Chartist 1.5.0 basic line | 9.99 KiB | Smaller, with a deliberately narrower behavior surface | +| TanStack Charts basic line | 18.26 KiB | Same complete basic fixture used by the controlled suite | +| Frappe Charts 1.6.2 line | 18.41 KiB | Effectively the same size class | +| TanStack Charts interactive line | 18.55 KiB | Includes legend and pointer tooltip | +| uPlot 1.6.32 time-series line and CSS | 23.46 KiB | TanStack interactive line is approximately 21% smaller | +| Lightweight Charts 5.2.0 line | 52.29 KiB | Specialized financial surface; not a generic feature match | + +Environment: esbuild 0.27.7 from the locked workspace, browser ESM targeting +ES2022, Node `gzipSync`, Node 24.15.0 on macOS arm64, measured 2026-07-28. Move +these fixtures into the canonical harness before using the table in public +copy. + +#### Evidence maturity + +Internal bundle control is strong: eight ordinary TanStack consumers are exact +minified and gzip byte locks, optional capabilities have isolated ceilings, and +the current local checks reproduce those locks. Comparative stability is not +ready for an unqualified public superlative: + +- the small-library fixtures are not yet in the canonical matrix; +- the comparison builds workspace source rather than packed production + packages; +- the toolchain and compression environment are not pinned at full-version + granularity; +- the GitHub workflow has not completed successfully because organization + policy requires third-party Actions to use full commit SHAs; +- there is not yet longitudinal CI history. + +Before launch, pin the workflow Actions and exact toolchain, measure packed +artifacts, add the small-library fixtures, exact-lock the named public claim +consumers, and publish versioned JSON and Markdown results from CI. Every published number must link to the -[`comparison protocol`](./benchmarks/comparison/README.md) and -[`bundle baseline`](./benchmarks/comparison/bundle-baseline.json). Do not turn -the measurements into an unqualified multiple or imply that bundle size alone -represents product quality. +[`comparison protocol`](./benchmarks/comparison/README.md), +[`bundle baseline`](./benchmarks/comparison/bundle-baseline.json), and exact +fixture source. Do not turn the measurements into an unqualified multiple or +imply that bundle size alone represents product quality. Additional proof: @@ -339,7 +422,8 @@ Developer libraries are marketed through proof rather than adjectives. 2. **TanStack Stats migration case study.** Document parity, bundle impact, SSR, interaction behavior, API friction, and remaining limitations. 3. **Reproducible comparison page.** Publish identical line, bar, area, and - scatter consumers with the benchmark methodology and output. + scatter consumers across mainstream libraries and small specialists with + the benchmark methodology and output. 4. **SSR and hydration demo.** Show actual server SVG, hydration adoption, and export from one definition. 5. **Data and composition demo.** Layer unrelated data types and preserve @@ -383,14 +467,14 @@ about being "AI-native." ## Objections -| Objection | Response | -| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Explicit domains and scales are more work than a chart-type component | Correct. TanStack Charts targets teams that want semantic control and a higher customization ceiling. Recipes and tooling should make the common policies obvious without hiding them behind runtime inference. | -| The chart catalog is smaller than AG Charts, ECharts, or Nivo | Also correct. The product thesis is a composable grammar with direct D3 interoperability, not first-party ownership of every specialized chart type. | -| Why not use D3 or visx directly? | They provide the algorithms or primitives. TanStack supplies the application runtime: responsive layout, guides, scene compilation, lifecycle, interaction, accessibility, SSR, hydration, animation, and export. | -| Why not use Observable Plot? | Plot is the primary conceptual inspiration and remains an excellent choice for concise exploratory visualization. TanStack is an independent implementation focused on typed application integration, explicit D3 policy, small capability imports, framework lifecycle, and stable interactive scenes. | -| Is it ready for production? | Not yet. The current package is a private proof with documented release gates. Marketing must state that plainly until those gates close. | -| Can it handle millions of live points? | There is no such claim. Current evidence covers a local 10,000-point proof. Dense-data aggregation and production-browser benchmarks remain separate work. | +| Objection | Response | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explicit domains and scales are more work than a chart-type component | Correct. TanStack Charts targets teams that want semantic control and a higher customization ceiling. Recipes and tooling should make the common policies obvious without hiding them behind runtime inference. | +| The chart catalog is smaller than AG Charts, ECharts, or Nivo | Also correct. The product thesis is a composable grammar with direct D3 interoperability, not first-party ownership of every specialized chart type. | +| Why not use D3 or visx directly? | They provide the algorithms or primitives. TanStack supplies the application runtime: responsive layout, guides, scene compilation, lifecycle, interaction, accessibility, SSR, hydration, animation, and export. | +| Why not use Observable Plot? | Plot is the primary conceptual inspiration and remains an excellent choice for concise exploratory visualization. TanStack is an independent implementation focused on typed application integration, explicit D3 policy, capability-level imports, framework lifecycle, and stable interactive scenes. | +| Is it ready for production? | Not yet. The current package is a private proof with documented release gates. Marketing must state that plainly until those gates close. | +| Can it handle millions of live points? | Canvas is an explicit opt-in and keeps the same definition and interaction API while removing per-mark DOM cost. It still creates scene nodes and interaction points, default focus is linear without a spatial index, and overplotting does not become useful because the pixels are cheaper. Treat million-point streaming as a measured representation problem, not a renderer claim. | ## Anti-personas @@ -398,7 +482,8 @@ TanStack Charts is not currently the best choice for teams that: - Need dozens of specialized finance, map, hierarchy, flow, polar, or gauge chart types immediately. -- Need million-point streaming Canvas rendering and built-in downsampling. +- Need proven million-point streaming throughput, built-in downsampling, or a + GPU rendering pipeline. - Want a no-code dashboard builder. - Need a portable JSON visualization specification. - Want a library to infer every domain, scale, and chart decision. @@ -443,7 +528,8 @@ gates. - Native D3 scales and curves - Framework-independent engine - Thin framework adapters -- Small by construction +- Lightweight and capability-scaled +- Complete chart consumers around 18–22 KiB gzip, with the benchmark link - Pay only for what you import - Built for applications, not screenshots - No customization cliff @@ -456,6 +542,9 @@ gates. - The most powerful chart library - The fastest chart library - The smallest chart library +- Tiny +- Smaller than every chart library +- A sub-5 KiB chart, when referring to a scene-only fixture - Every chart type - D3 without learning D3 - Infinitely customizable @@ -490,12 +579,21 @@ Do not claim: - Complete D3 capability coverage. - Renderer breadth beyond the renderers that actually ship. - Production readiness before the release gates close. -- Bundle advantages without the benchmark protocol and compared consumer - scope. -- Accessibility conformance beyond the tested keyboard and SVG behavior. - -Every new proof claim should point to a reproducible example, benchmark, test, -or production case study. +- Bundle advantages without the benchmark protocol, committed baseline, + compression format, compared consumer scope, and React externalization + policy. +- Comparisons against specialist libraries before they are in the canonical + harness and CI. +- “Tiny” as a primary product descriptor before the comparison suite has + successful CI history. +- A complete-chart size from a scene-only, host-only, or adapter-only fixture. +- Accessibility conformance beyond the tested keyboard and aggregate + SVG/Canvas behavior. + +Every bundle claim should name the capability tier, compression unit, library +version, and whether framework runtimes and required CSS are included. Every +new proof claim should point to a reproducible example, benchmark, test, or +production case study. ## Launch sequence @@ -527,6 +625,8 @@ tracked in [`PLAN.md`](./PLAN.md): - Visual regression suite. - Production-browser benchmark coverage. - Packed-consumer tests. +- A green, fully pinned bundle-comparison workflow and published result + artifacts. - Remaining Plot-backed animated export migration. - Accessibility, locale, and RTL release gates. - Public package, versioning, license packaging, and compatibility policy. @@ -559,5 +659,7 @@ the first chart. - Which TanStack product becomes the launch case study. - Compatibility and support policy. - The minimum chart and interaction profile required for stable release. +- When CI-backed packed-artifact evidence is strong enough to use “tiny” + without weakening the evidence-led positioning. - Which customer phrases emerge from early-adopter interviews and should replace the proposed language in this document. diff --git a/README.md b/README.md index 9fa1273..157d2f0 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ or dropping down to a separate API. - **Build from common to custom.** Layer built-in marks or implement a custom mark against the same public scene protocol. - **Get the application runtime too.** Responsive layout, automatic guide - margins, themes, interaction, animation, accessibility, SVG SSR, hydration, - and export are part of the system. + margins, themes, interaction, animation, accessibility, SVG SSR, opt-in + Canvas painting, hydration, and export are part of the system. - **Pay for what you import.** Marks, renderers, and chart-owned interactions have independent TanStack subpaths; specialized algorithms come directly from granular, tree-shakeable `d3-*` packages. @@ -116,15 +116,29 @@ assigns their responsive pixel ranges, compiles a renderer-neutral keyed scene, and hands that scene to the selected host. Definitions are framework-independent. The same `revenueChart` can render -through React, Octane, the vanilla DOM host, or static SVG. +through React, Octane, the vanilla DOM host, static SVG, or the optional Canvas +renderer. + +When SVG element count becomes the bottleneck, switch the adapter import and +keep the definition and interaction props: + +```tsx +import { Chart } from '@tanstack/react-charts/canvas' +``` + +Canvas stays outside the default bundles. React and Octane also expose a +renderer-neutral `/core` entry when an application owns the surface. Canvas +removes per-mark DOM cost, not scene memory or dense nearest-point work, so +large interactive charts should still use a measured spatial index or a +bounded representation. ## Packages -| Package | Role | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| [`@tanstack/charts`](./packages/charts-core) | Marks, channels, guides, scene compilation, static SVG, vanilla DOM lifecycle, and optional export | -| [`@tanstack/react-charts`](./packages/react-charts) | Thin React lifecycle adapter with SSR and hydration | -| [`@tanstack/octane-charts`](./packages/octane-charts) | Thin Octane lifecycle adapter with equivalent scene output | +| Package | Role | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [`@tanstack/charts`](./packages/charts-core) | Marks, channels, guides, scene compilation, SVG, optional Canvas, vanilla DOM lifecycle, and export | +| [`@tanstack/react-charts`](./packages/react-charts) | Thin React lifecycle adapter with SSR and hydration | +| [`@tanstack/octane-charts`](./packages/octane-charts) | Thin Octane lifecycle adapter with equivalent scene output | The earlier host experiment remains under `@plot-poc/*` for migration evidence and benchmark comparison. The private diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 957529e..ae720be 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -15,6 +15,13 @@ Every package entry in New marks and optional capabilities need an isolated budgeted entry. They must leave the locked entries byte-identical. +Non-cartesian capability subpaths follow the same policy. Polar and geographic +entries measure complete scenes through static SVG: a minimal arc, D3 pie, +full scale-backed gauge composition, a scale-backed polar line and scatter +composition, and projected GeoJSON. Atlas datasets remain application-owned +inputs rather than chart-package dependencies. The locked Cartesian entries +prove those D3 geometry modules remain opt-in. + The advanced custom-mark scale-value factory follows the same rule: its subpath has a dedicated budget, while ordinary chart entries must retain zero bytes from it. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 27ea38e..062d951 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,36 +3,36 @@ "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": 31163, - "gzip": 12478 + "bytes": 31200, + "gzip": 12492 }, "D3-scale line + static SVG": { - "bytes": 34215, - "gzip": 13613 + "bytes": 34252, + "gzip": 13624 }, "Representative marks": { - "bytes": 42525, - "gzip": 15876 + "bytes": 42562, + "gzip": 15889 }, "TanStack DOM host": { - "bytes": 25595, - "gzip": 9952 + "bytes": 28362, + "gzip": 10896 }, "React adapter": { - "bytes": 27600, - "gzip": 10595 + "bytes": 30011, + "gzip": 11437 }, "React line consumer": { - "bytes": 49157, - "gzip": 19097 + "bytes": 51559, + "gzip": 19955 }, "Custom-scale line scene": { - "bytes": 11937, - "gzip": 4795 + "bytes": 11974, + "gzip": 4810 }, "D3 linear-scale line scene": { - "bytes": 31095, - "gzip": 12443 + "bytes": 31132, + "gzip": 12458 } } } diff --git a/benchmarks/comparison/README.md b/benchmarks/comparison/README.md index 40b848a..8544575 100644 --- a/benchmarks/comparison/README.md +++ b/benchmarks/comparison/README.md @@ -7,6 +7,9 @@ scatter charts. ## Commands ```sh +# Install the Playwright version's pinned headless browser +pnpm browser:install + # Bundle size and the standard browser matrix pnpm benchmark diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index a239f06..b89d1dc 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -29,6 +29,9 @@ harness. ## Commands ```sh +# Install the Playwright version's pinned headless browser +pnpm browser:install + # Fast bundle, timing, visual, and type audit pnpm conformance:quick @@ -146,6 +149,42 @@ Adding or changing a case updates every catalog surface automatically. invalid schemas, duplicate IDs or orders, and case IDs that drift from their directory names. +## Production hosting + +The catalog is deployed from this repository as the separate Cloudflare Static +Assets Worker `tanstack-charts-catalog`. Its +`tanstack.com/charts/catalog*` route takes precedence over the main +`tanstack-com` Worker and includes query strings on the bare catalog path. No +catalog source or generated asset is copied into the `tanstack.com` +repository. + +Workers Static Assets matches the complete request path, so `catalog:stage` +mirrors the production build below `.catalog-deploy/charts/catalog/` before +deployment. The ignored staging directory is never committed. Fingerprinted +assets receive immutable caching; HTML and metadata retain Cloudflare's +revalidating default. Catalog pages deny framing, while the explicit embed +routes remain frameable. + +```sh +# Build, mirror the public path, and validate the Worker bundle locally +pnpm catalog:build +pnpm catalog:deploy:check + +# Deploy with an authenticated Wrangler session +pnpm catalog:deploy + +# Verify root, detail, embed, asset, metadata, and 404 behavior +pnpm catalog:smoke +``` + +Main-branch CI deploys only after validation and the unfiltered standard +conformance matrix. The `charts-catalog-production` GitHub environment must +provide a `CLOUDFLARE_API_TOKEN` scoped to the catalog Worker and the +`tanstack.com` route; the non-secret account ID stays in the Wrangler config. +Rollbacks use the prior +`tanstack-charts-catalog` Worker version; the catalog has no mutable runtime +state. + ## What is and is not equivalent Both implementations must satisfy the same data and semantic contract. They do @@ -197,8 +236,8 @@ comparison. ## Current scope -The executable corpus contains 79 paired cases: 61 sourced from Observable -Plot, nine from Recharts, and nine from Apache ECharts. It spans the common +The executable corpus contains 100 paired cases: 68 sourced from Observable +Plot, 21 from Recharts, and 11 from Apache ECharts. It spans the common cartesian vocabulary plus the high-value catalog beyond it: - lines, areas, bars, intervals, heatmaps, histograms, facets, and framed @@ -209,7 +248,13 @@ cartesian vocabulary plus the high-value catalog beyond it: rankings, indexed lines, marginal distributions, ridgelines, violins, Marimekko layouts, and waffles; - pointer, grouped, and Voronoi-nearest tooltips; +- pie, labeled pie, basic/centered/rounded/nested donuts, partial and needle + gauges, single/comparative radar, numeric polar line/scatter, rose, radial + bars, and sunburst layouts; - trees, Delaunay links, force networks, vector fields, and GeoJSON maps. +- regional and world choropleths, proportional symbols, orthographic globe + and graticule layers, projected routes, 177 real country boundaries, 51 US + state/DC boundaries, and a four-projection atlas gallery. Shared facet guides, guide suppression, Plot-style tooltip mapping, exact difference crossings, and responsive fixed-pixel spatial preparation are @@ -218,22 +263,29 @@ merely because it renders. The Recharts tranche adds mixed composition, population pyramids, adjacent-plus-stacked bars, a 1,000-point scatter pressure test, treemaps, -radar, an accessible interactive legend, chart/table selection, and a pinned -tooltip containing a real nested chart. - -The ECharts tranche adds a snapped grouped axis pointer, native horizontal -resource scrolling, streaming-window preservation, synchronized multi-view -cursors, a free two-dimensional cursor, continuous brush selection, wheel -zoom and pan, timeline scrubbing, and editable event ranges. Focus-plus-context -uses Plot as its reference. Together with the original tooltip cases, 16 cases -carry executable interaction scenarios. - -All declared interaction scenarios and the full 79-case standard visual +pie and donut variants, partial and needle gauges, single and comparative +radar, radial bars, rose, sunburst, an accessible interactive legend, +chart/table selection, and a pinned tooltip containing a real nested chart. + +The ECharts tranche adds numeric polar line/scatter, a snapped grouped axis +pointer, native horizontal resource scrolling, streaming-window preservation, +synchronized multi-view cursors, a free two-dimensional cursor, continuous +brush selection, wheel zoom and pan, timeline scrubbing, and editable event +ranges. Focus-plus-context uses Plot as its reference. Together with the +original tooltip cases, 16 cases carry executable interaction scenarios. + +All declared interaction scenarios and the prior 79-case full-corpus visual matrix pass across both renderers, initial/revised data, 320/640/960 px, and -light/dark themes. That result proves the declared contracts, not production -interaction quality; the interaction UX audit preserves the original gaps and -their implementation follow-through. The full run covers all standard widths -and themes. The final quick verification measures 0.21× the +light/dark themes. The nine added polar cases pass their focused standard +matrix with 100.0% mean frame-relative geometry similarity; all four added +geographic cases pass the same matrix at 99.9% each. Those results prove the +declared contracts, not production interaction quality. The numeric polar +line/scatter additions pass their focused standard matrix at 100.0%; the +country and state atlas cases pass at 99.9%, and the four-pane projection +gallery passes at 99.8%, for 99.9% across the five-case expansion. The +interaction UX audit preserves the original gaps and their implementation +follow-through. +The prior full-corpus baseline measures 0.21× the selected-reference gzip, 0.57× mount time, 0.66× update time, and 97.8% mean diagnostic geometry similarity. The transitive authored-source ratio is 1.07×. Strict case sources have zero diagnostics, unsafe assertions, or suppressions. diff --git a/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts b/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts index cf5da3b..4bb0c66 100644 --- a/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts +++ b/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts @@ -51,4 +51,17 @@ const definition = defineChart()(({ input }) => { export const mount: ConformanceMount = tanstackMount( definition, 'Weekly low-to-high temperature range', + { + format: ({ datum }) => + `${datum.date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + })} · ${datum.low.toLocaleString('en-US', { + maximumFractionDigits: 1, + })}–${datum.high.toLocaleString('en-US', { + maximumFractionDigits: 1, + })} °F`, + }, ) diff --git a/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts b/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts index 4676079..4787eeb 100644 --- a/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts +++ b/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts @@ -67,6 +67,17 @@ const definition = defineChart()(({ input }) => { export const mount: ConformanceMount = tanstackMount( definition, 'Three stacked time-series areas', + { + format: ({ datum }) => + `${datum.series} · ${datum.date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + })} · ${datum.value.toLocaleString('en-US', { + maximumFractionDigits: 1, + })} index points`, + }, ) function stackRows(rows: readonly TimePoint[]): readonly StackedTimePoint[] { diff --git a/benchmarks/conformance/cases/100-radial-bars/case.json b/benchmarks/conformance/cases/100-radial-bars/case.json new file mode 100644 index 0000000..8691ca1 --- /dev/null +++ b/benchmarks/conformance/cases/100-radial-bars/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1000, + "id": "100-radial-bars", + "title": "Concentric radial bars", + "family": "polar", + "intent": "Compare four bounded values through concentric rounded radial bars on one shared angular scale.", + "support": "native", + "features": [ + "polar coordinates", + "concentric radial bars", + "authored D3 arc generator", + "data-driven per-datum radii", + "rounded arc caps", + "fixed angular domain" + ], + "geometry": [{ "role": "arc", "count": 4, "maxCount": 4 }], + "minimumGeometrySimilarity": 0.995, + "source": { + "title": "Recharts Simple Radial Bar Chart", + "url": "https://recharts.github.io/en-US/examples/SimpleRadialBarChart/" + }, + "ai": { + "create": "Create four concentric radial bars with a fixed zero-to-one-hundred angular domain, responsive ring radii, stable category keys, and rounded D3 arc geometry.", + "maintain": "Update values without changing ring order or the shared angular domain; keep each D3 radius accessor responsive and preserve exact per-category fills." + } +} diff --git a/benchmarks/conformance/cases/100-radial-bars/data.ts b/benchmarks/conformance/cases/100-radial-bars/data.ts new file mode 100644 index 0000000..113d2a2 --- /dev/null +++ b/benchmarks/conformance/cases/100-radial-bars/data.ts @@ -0,0 +1,26 @@ +export interface RadialBarDatum { + id: string + label: string + value: number + ring: number + fill: string +} + +const initialData: readonly RadialBarDatum[] = [ + { id: 'api', label: 'API', value: 92, ring: 0, fill: '#7c3aed' }, + { id: 'web', label: 'Web', value: 76, ring: 1, fill: '#0ea5e9' }, + { id: 'worker', label: 'Worker', value: 61, ring: 2, fill: '#14b8a6' }, + { id: 'mobile', label: 'Mobile', value: 44, ring: 3, fill: '#f59e0b' }, +] + +export function radialBarData(revision = 0): readonly RadialBarDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => + row.id === 'worker' + ? { ...row, value: 73 } + : row.id === 'mobile' + ? { ...row, value: 57 } + : row, + ) +} diff --git a/benchmarks/conformance/cases/100-radial-bars/recharts.ts b/benchmarks/conformance/cases/100-radial-bars/recharts.ts new file mode 100644 index 0000000..01bad5b --- /dev/null +++ b/benchmarks/conformance/cases/100-radial-bars/recharts.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { Cell, PolarAngleAxis, RadialBar, RadialBarChart } from 'recharts' +import { radialBarData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +const innerRadiusRatio = 0.2 +const barRatio = 0.62 + +function chart(input: ConformanceInput) { + const data = radialBarData(input.revision) + const radius = Math.min(input.width, input.height) * 0.42 + const innerRadius = radius * innerRadiusRatio + const band = (radius - innerRadius) / data.length + const barSize = band * barRatio + + return createElement( + RadialBarChart, + { + width: input.width, + height: input.height, + data, + cx: input.width / 2, + cy: input.height / 2, + innerRadius, + outerRadius: radius, + startAngle: 90, + endAngle: -270, + barSize, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + [ + createElement(PolarAngleAxis, { + key: 'angle', + type: 'number', + domain: [0, 100], + hide: true, + }), + createElement( + RadialBar, + { + key: 'bars', + dataKey: 'value', + cornerRadius: barSize / 2, + background: false, + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + }), + ), + ), + ], + ) +} + +export const mount = rechartsMount(chart, 'Concentric radial bars') diff --git a/benchmarks/conformance/cases/100-radial-bars/tanstack.ts b/benchmarks/conformance/cases/100-radial-bars/tanstack.ts new file mode 100644 index 0000000..97a3d45 --- /dev/null +++ b/benchmarks/conformance/cases/100-radial-bars/tanstack.ts @@ -0,0 +1,51 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { arc } from 'd3-shape' +import { radialBarData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { RadialBarDatum } from './data' +import type { ConformanceInput } from '../../types' + +const maximumValue = 100 +const innerRadiusRatio = 0.2 +const barRatio = 0.62 + +const definition = defineChart()(({ input }) => { + const data = radialBarData(input.revision) + + return { + marks: [ + polar({ + radiusRatio: 0.84, + marks: [ + radialArc(data, { + key: 'id', + className: 'ts-chart__radial-bars', + generator: ({ radius }) => { + const innerRadius = radius * innerRadiusRatio + const band = (radius - innerRadius) / data.length + const barSize = band * barRatio + const offset = Math.round((band - barSize) / 2) + + return arc() + .startAngle(0) + .endAngle((row) => (row.value / maximumValue) * Math.PI * 2) + .innerRadius((row) => innerRadius + row.ring * band + offset) + .outerRadius( + (row) => innerRadius + row.ring * band + offset + barSize, + ) + .cornerRadius(barSize / 2) + }, + fill: (row: RadialBarDatum) => row.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Concentric radial bars') diff --git a/benchmarks/conformance/cases/101-sunburst/case.json b/benchmarks/conformance/cases/101-sunburst/case.json new file mode 100644 index 0000000..d431607 --- /dev/null +++ b/benchmarks/conformance/cases/101-sunburst/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 1010, + "id": "101-sunburst", + "title": "Sunburst hierarchy", + "family": "hierarchy", + "intent": "Show part-to-whole relationships across two visible hierarchy depths using nested annular sectors.", + "support": "native", + "features": [ + "polar coordinates", + "d3-hierarchy partition", + "hierarchical angle allocation", + "authored D3 arc generator", + "data-driven per-depth radii", + "inherited branch color" + ], + "geometry": [{ "role": "arc", "count": 10, "maxCount": 10 }], + "minimumGeometrySimilarity": 0.995, + "source": { + "title": "Recharts Sunburst Chart Example", + "url": "https://recharts.github.io/en-US/examples/SunburstChartExample/" + }, + "ai": { + "create": "Create a two-ring sunburst from a typed hierarchy using d3-hierarchy partition output and a responsive D3 arc generator with inherited branch colors.", + "maintain": "Update leaf values while preserving hierarchy order, stable ancestry-path keys, global angular allocation, responsive ring thickness, and branch color inheritance." + } +} diff --git a/benchmarks/conformance/cases/101-sunburst/data.ts b/benchmarks/conformance/cases/101-sunburst/data.ts new file mode 100644 index 0000000..2691849 --- /dev/null +++ b/benchmarks/conformance/cases/101-sunburst/data.ts @@ -0,0 +1,49 @@ +export interface SunburstNode { + [key: string]: unknown + name: string + value: number + fill?: string + children?: SunburstNode[] +} + +function leaf(name: string, value: number): SunburstNode { + return { name, value } +} + +function branch( + name: string, + fill: string, + children: SunburstNode[], +): SunburstNode { + return { + name, + value: children.reduce((total, child) => total + child.value, 0), + fill, + children, + } +} + +export function sunburstData(revision = 0): SunburstNode { + const changed = revision % 2 !== 0 + const children = [ + branch('Platform', '#7c3aed', [ + leaf('Browser', changed ? 36 : 30), + leaf('Server', 20), + leaf('Mobile', changed ? 14 : 10), + ]), + branch('Data', '#0ea5e9', [ + leaf('Queries', changed ? 21 : 25), + leaf('Mutations', 15), + ]), + branch('Tools', '#14b8a6', [ + leaf('Devtools', 12), + leaf('CLI', changed ? 12 : 8), + ]), + ] + + return { + name: 'All errors', + value: children.reduce((total, child) => total + child.value, 0), + children, + } +} diff --git a/benchmarks/conformance/cases/101-sunburst/recharts.ts b/benchmarks/conformance/cases/101-sunburst/recharts.ts new file mode 100644 index 0000000..0894b74 --- /dev/null +++ b/benchmarks/conformance/cases/101-sunburst/recharts.ts @@ -0,0 +1,27 @@ +import { createElement } from 'react' +import { SunburstChart } from 'recharts' +import { sunburstData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const radius = Math.min(input.width, input.height) * 0.44 + + return createElement(SunburstChart, { + width: input.width, + height: input.height, + data: sunburstData(input.revision), + cx: input.width / 2, + cy: input.height / 2, + innerRadius: radius * 0.14, + outerRadius: radius, + startAngle: 0, + endAngle: 360, + padding: 2, + ringPadding: 2, + stroke: '#ffffff', + textOptions: { display: 'none' }, + }) +} + +export const mount = rechartsMount(chart, 'Sunburst hierarchy') diff --git a/benchmarks/conformance/cases/101-sunburst/tanstack.ts b/benchmarks/conformance/cases/101-sunburst/tanstack.ts new file mode 100644 index 0000000..1cc177b --- /dev/null +++ b/benchmarks/conformance/cases/101-sunburst/tanstack.ts @@ -0,0 +1,106 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { hierarchy, partition } from 'd3-hierarchy' +import { arc } from 'd3-shape' +import { sunburstData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { SunburstNode } from './data' +import type { ConformanceInput } from '../../types' +import type { HierarchyRectangularNode } from 'd3-hierarchy' + +interface SunburstArcDatum { + id: string + name: string + value: number + depth: number + startAngle: number + endAngle: number + fill: string +} + +function inheritedFill(node: HierarchyRectangularNode): string { + let current: HierarchyRectangularNode | null = node + + while (current) { + if (current.data.fill) return current.data.fill + current = current.parent + } + + return '#64748b' +} + +function nodeKey(node: HierarchyRectangularNode): string { + return node + .ancestors() + .reverse() + .map(({ data }) => data.name) + .join('/') +} + +const definition = defineChart()(({ input }) => { + const tree = sunburstData(input.revision) + const root = hierarchy(tree).sum((node) => + node.children?.length ? 0 : node.value, + ) + const layout = partition().size([Math.PI * 2, root.height + 1])( + root, + ) + const data: SunburstArcDatum[] = layout + .descendants() + .filter((node) => node.depth > 0) + .map((node) => ({ + id: nodeKey(node), + name: node.data.name, + value: node.value ?? 0, + depth: node.depth, + startAngle: Math.PI / 2 - node.x0, + endAngle: Math.PI / 2 - node.x1, + fill: inheritedFill(node), + })) + + return { + marks: [ + polar({ + radiusRatio: 0.88, + marks: [ + radialArc(data, { + key: 'id', + className: 'ts-chart__sunburst', + generator: ({ radius }) => { + const innerRadius = radius * 0.14 + const treeDepth = root.height + 1 + const thickness = (radius - innerRadius) / treeDepth + const ringPadding = 2 + + return arc() + .startAngle((node) => node.startAngle) + .endAngle((node) => node.endAngle) + .innerRadius( + (node) => + innerRadius + (node.depth - 1) * (thickness + ringPadding), + ) + .outerRadius( + (node) => + innerRadius + + (node.depth - 1) * (thickness + ringPadding) + + thickness, + ) + }, + fill: (node: SunburstArcDatum) => node.fill, + stroke: '#ffffff', + strokeWidth: 2, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Sunburst hierarchy', { + format: ({ datum }) => + `${datum.id.replaceAll('/', ' › ')} · ${datum.value} errors`, +}) diff --git a/benchmarks/conformance/cases/102-world-choropleth/case.json b/benchmarks/conformance/cases/102-world-choropleth/case.json new file mode 100644 index 0000000..53b3b47 --- /dev/null +++ b/benchmarks/conformance/cases/102-world-choropleth/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1020, + "id": "102-world-choropleth", + "title": "Equal Earth world choropleth", + "family": "geography", + "intent": "Compare values across recognizable world regions with an equal-area projection.", + "support": "native", + "features": [ + "GeoJSON polygons", + "Equal Earth projection", + "responsive projection fitting", + "quantitative region fills", + "d3-geo" + ], + "geometry": [{ "role": "geo", "count": 7, "maxCount": 7 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot projections", + "url": "https://observablehq.com/plot/features/projections" + }, + "ai": { + "create": "Create a seven-region world choropleth using the same GeoJSON and Equal Earth projection for Observable Plot and TanStack geoShape.", + "maintain": "Preserve geographic coordinates, responsive projection fitting, stable region keys, literal fills, and one projected path per region." + } +} diff --git a/benchmarks/conformance/cases/102-world-choropleth/geo-data.ts b/benchmarks/conformance/cases/102-world-choropleth/geo-data.ts new file mode 100644 index 0000000..ddabf8f --- /dev/null +++ b/benchmarks/conformance/cases/102-world-choropleth/geo-data.ts @@ -0,0 +1,339 @@ +import { geoEqualEarth, geoGraticule10, geoOrthographic } from 'd3-geo' +import type { + ExtendedFeature, + ExtendedFeatureCollection, + GeoGeometryObjects, + GeoProjection, + GeoSphere, +} from 'd3-geo' + +type Polygon = Extract +type Point = Extract +type LineString = Extract + +export interface WorldRegionProperties { + id: + | 'north-america' + | 'south-america' + | 'europe' + | 'africa' + | 'asia' + | 'australia' + | 'greenland' + name: string + value: number + fill: string +} + +export type WorldRegion = ExtendedFeature + +export interface WorldPlaceProperties { + id: + | 'san-francisco' + | 'new-york' + | 'sao-paulo' + | 'london' + | 'lagos' + | 'dubai' + | 'singapore' + | 'tokyo' + | 'sydney' + name: string + value: number + fill: string +} + +export type WorldPlace = ExtendedFeature + +export interface WorldRouteProperties { + id: 'sf-london' | 'new-york-lagos' | 'london-dubai' | 'dubai-tokyo' + label: string + stroke: string +} + +export type WorldRoute = ExtendedFeature + +interface ProjectionBounds { + x: number + y: number + width: number + height: number +} + +const fills = [ + '#2563eb', + '#7c3aed', + '#db2777', + '#e11d48', + '#ea580c', + '#ca8a04', + '#0891b2', +] as const + +const regionShapes: readonly WorldRegion[] = [ + region( + 'north-america', + 'North America', + 72, + fills[0], + [ + [-168, 72], + [-140, 70], + [-125, 50], + [-118, 32], + [-105, 22], + [-90, 18], + [-82, 25], + [-65, 45], + [-52, 58], + [-70, 75], + [-100, 82], + [-140, 80], + [-168, 72], + ], + true, + ), + region('south-america', 'South America', 51, fills[1], [ + [-82, 12], + [-70, 10], + [-52, 5], + [-35, -5], + [-43, -25], + [-55, -50], + [-68, -55], + [-75, -25], + [-82, 12], + ]), + region( + 'europe', + 'Europe', + 64, + fills[2], + [ + [-11, 36], + [5, 35], + [22, 40], + [40, 55], + [30, 70], + [10, 72], + [-10, 58], + [-11, 36], + ], + true, + ), + region('africa', 'Africa', 43, fills[3], [ + [-18, 35], + [15, 37], + [42, 12], + [52, -12], + [35, -35], + [18, -35], + [0, -28], + [-12, 5], + [-18, 35], + ]), + region('asia', 'Asia', 87, fills[4], [ + [35, 35], + [45, 60], + [75, 75], + [120, 72], + [170, 60], + [145, 40], + [120, 20], + [105, 5], + [75, 8], + [55, 25], + [35, 35], + ]), + region('australia', 'Australia', 35, fills[5], [ + [112, -10], + [154, -10], + [153, -40], + [130, -44], + [112, -25], + [112, -10], + ]), + region( + 'greenland', + 'Greenland', + 22, + fills[6], + [ + [-73, 60], + [-48, 58], + [-20, 72], + [-35, 83], + [-60, 82], + [-73, 60], + ], + true, + ), +] + +const placeShapes: readonly WorldPlace[] = [ + place('san-francisco', 'San Francisco', 42, '#f97316', [-122.42, 37.77]), + place('new-york', 'New York', 68, '#f43f5e', [-74, 40.71]), + place('sao-paulo', 'São Paulo', 79, '#a855f7', [-46.63, -23.55]), + place('london', 'London', 58, '#6366f1', [-0.13, 51.5]), + place('lagos', 'Lagos', 73, '#0ea5e9', [3.38, 6.52]), + place('dubai', 'Dubai', 31, '#14b8a6', [55.27, 25.2]), + place('singapore', 'Singapore', 49, '#22c55e', [103.82, 1.35]), + place('tokyo', 'Tokyo', 91, '#eab308', [139.69, 35.68]), + place('sydney', 'Sydney', 37, '#ef4444', [151.21, -33.87]), +] + +export const worldSphere: GeoSphere = { type: 'Sphere' } +export const worldGraticule = geoGraticule10() + +export function worldRegions(revision = 0): readonly WorldRegion[] { + if (revision % 2 === 0) return regionShapes + return regionShapes.map((feature) => + feature.properties.id === 'africa' + ? { + ...feature, + properties: { + ...feature.properties, + value: 57, + fill: '#0d9488', + }, + } + : feature, + ) +} + +export function worldCollection( + revision = 0, +): ExtendedFeatureCollection { + return { + type: 'FeatureCollection', + features: [...worldRegions(revision)], + } +} + +export function worldPlaces(revision = 0): readonly WorldPlace[] { + if (revision % 2 === 0) return placeShapes + return placeShapes.map((feature) => + feature.properties.id === 'tokyo' + ? { + ...feature, + properties: { ...feature.properties, value: 76 }, + } + : feature, + ) +} + +export function worldRoutes(revision = 0): readonly WorldRoute[] { + const tokyo: [number, number] = + revision % 2 === 0 ? [139.69, 35.68] : [103.82, 1.35] + return [ + route( + 'sf-london', + 'San Francisco to London', + '#f97316', + [-122.42, 37.77], + [-0.13, 51.5], + ), + route( + 'new-york-lagos', + 'New York to Lagos', + '#ec4899', + [-74, 40.71], + [3.38, 6.52], + ), + route( + 'london-dubai', + 'London to Dubai', + '#8b5cf6', + [-0.13, 51.5], + [55.27, 25.2], + ), + route( + 'dubai-tokyo', + revision % 2 === 0 ? 'Dubai to Tokyo' : 'Dubai to Singapore', + '#06b6d4', + [55.27, 25.2], + tokyo, + ), + ] +} + +export function equalEarthProjection({ + x, + y, + width, + height, +}: ProjectionBounds): GeoProjection { + return geoEqualEarth().fitExtent( + [ + [x, y], + [x + width, y + height], + ], + worldSphere, + ) +} + +export function orthographicProjection({ + x, + y, + width, + height, +}: ProjectionBounds): GeoProjection { + return geoOrthographic() + .rotate([-20, -15]) + .fitExtent( + [ + [x, y], + [x + width, y + height], + ], + worldSphere, + ) +} + +function region( + id: WorldRegionProperties['id'], + name: string, + value: number, + fill: string, + coordinates: [number, number][], + reverseWinding = false, +): WorldRegion { + return { + type: 'Feature', + id, + properties: { id, name, value, fill }, + geometry: { + type: 'Polygon', + coordinates: [reverseWinding ? [...coordinates].reverse() : coordinates], + }, + } +} + +function place( + id: WorldPlaceProperties['id'], + name: string, + value: number, + fill: string, + coordinates: [number, number], +): WorldPlace { + return { + type: 'Feature', + id, + properties: { id, name, value, fill }, + geometry: { type: 'Point', coordinates }, + } +} + +function route( + id: WorldRouteProperties['id'], + label: string, + stroke: string, + source: [number, number], + target: [number, number], +): WorldRoute { + return { + type: 'Feature', + id, + properties: { id, label, stroke }, + geometry: { type: 'LineString', coordinates: [source, target] }, + } +} diff --git a/benchmarks/conformance/cases/102-world-choropleth/plot.ts b/benchmarks/conformance/cases/102-world-choropleth/plot.ts new file mode 100644 index 0000000..c91d58a --- /dev/null +++ b/benchmarks/conformance/cases/102-world-choropleth/plot.ts @@ -0,0 +1,29 @@ +import * as Plot from '@observablehq/plot' +import { equalEarthProjection, worldRegions } from './geo-data' +import { mountObservablePlot } from '../../shared/mount' +import type { WorldRegion } from './geo-data' +import type { ConformanceMount } from '../../types' + +const margin = 10 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'Equal Earth world choropleth', + projection: { + type: ({ width, height }: { width: number; height: number }) => + equalEarthProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo(worldRegions(nextInput.revision), { + fill: (feature: WorldRegion) => feature.properties.fill, + stroke: '#f8fafc', + strokeWidth: 1, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/102-world-choropleth/tanstack.ts b/benchmarks/conformance/cases/102-world-choropleth/tanstack.ts new file mode 100644 index 0000000..d490522 --- /dev/null +++ b/benchmarks/conformance/cases/102-world-choropleth/tanstack.ts @@ -0,0 +1,26 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { equalEarthProjection, worldRegions } from './geo-data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape(worldRegions(input.revision), { + key: (feature) => feature.properties.id, + projection: ({ chart }) => equalEarthProjection(chart), + fill: (feature) => feature.properties.fill, + stroke: '#f8fafc', + strokeWidth: 1, + }), + ], + x: null, + y: null, + guides: false, + margin: 10, +})) + +export const mount = tanstackMount(definition, 'Equal Earth world choropleth', { + format: ({ datum }) => + `${datum.properties.name} · Regional value ${datum.properties.value}`, +}) diff --git a/benchmarks/conformance/cases/103-bubble-map/case.json b/benchmarks/conformance/cases/103-bubble-map/case.json new file mode 100644 index 0000000..924de8e --- /dev/null +++ b/benchmarks/conformance/cases/103-bubble-map/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1030, + "id": "103-bubble-map", + "title": "Projected proportional-symbol map", + "family": "geography", + "intent": "Compare place magnitudes with area-scaled circles over a projected world map.", + "support": "native", + "features": [ + "GeoJSON Point features", + "data-driven point radius", + "D3 square-root radius scale", + "Equal Earth projection", + "layered geographic marks" + ], + "geometry": [{ "role": "geo", "count": 16, "maxCount": 16 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot geo mark", + "url": "https://observablehq.com/plot/marks/geo" + }, + "ai": { + "create": "Create a proportional-symbol world map from seven region polygons and nine GeoJSON places, using the same Equal Earth projection and square-root radius scale in both renderers.", + "maintain": "Keep longitude and latitude semantic, sort-independent, and projection-backed; preserve stable place keys and area-scaled D3 point radii." + } +} diff --git a/benchmarks/conformance/cases/103-bubble-map/plot.ts b/benchmarks/conformance/cases/103-bubble-map/plot.ts new file mode 100644 index 0000000..b7cb6af --- /dev/null +++ b/benchmarks/conformance/cases/103-bubble-map/plot.ts @@ -0,0 +1,44 @@ +import * as Plot from '@observablehq/plot' +import { + equalEarthProjection, + worldPlaces, + worldRegions, +} from '../102-world-choropleth/geo-data' +import { mountObservablePlot } from '../../shared/mount' +import type { WorldPlace } from '../102-world-choropleth/geo-data' +import type { ConformanceMount } from '../../types' + +const margin = 10 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'Projected proportional-symbol map', + projection: { + type: ({ width, height }: { width: number; height: number }) => + equalEarthProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + r: { + type: 'sqrt', + domain: [0, 100], + range: [0, 14], + }, + marks: [ + Plot.geo(worldRegions(nextInput.revision), { + fill: '#e2e8f0', + stroke: '#ffffff', + strokeWidth: 1, + }), + Plot.geo(worldPlaces(nextInput.revision), { + r: (feature: WorldPlace) => feature.properties.value, + fill: (feature: WorldPlace) => feature.properties.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/103-bubble-map/tanstack.ts b/benchmarks/conformance/cases/103-bubble-map/tanstack.ts new file mode 100644 index 0000000..b74cf21 --- /dev/null +++ b/benchmarks/conformance/cases/103-bubble-map/tanstack.ts @@ -0,0 +1,48 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { scaleSqrt } from 'd3-scale' +import { + equalEarthProjection, + worldPlaces, + worldRegions, +} from '../102-world-choropleth/geo-data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const radius = scaleSqrt().domain([0, 100]).range([0, 14]) + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape(worldRegions(input.revision), { + key: (feature) => feature.properties.id, + projection: ({ chart }) => equalEarthProjection(chart), + fill: '#e2e8f0', + stroke: '#ffffff', + strokeWidth: 1, + }), + geoShape(worldPlaces(input.revision), { + key: (feature) => feature.properties.id, + projection: ({ chart }) => equalEarthProjection(chart), + r: (feature) => feature.properties.value, + rScale: radius, + fill: (feature) => feature.properties.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + x: null, + y: null, + guides: false, + margin: 10, +})) + +export const mount = tanstackMount( + definition, + 'Projected proportional-symbol map', + { + format: ({ datum }) => + datum.geometry.type === 'Point' + ? `${datum.properties.name} · Magnitude ${datum.properties.value}` + : `${datum.properties.name} · Regional value ${datum.properties.value}`, + }, +) diff --git a/benchmarks/conformance/cases/104-orthographic-globe/case.json b/benchmarks/conformance/cases/104-orthographic-globe/case.json new file mode 100644 index 0000000..a740f83 --- /dev/null +++ b/benchmarks/conformance/cases/104-orthographic-globe/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1040, + "id": "104-orthographic-globe", + "title": "Orthographic globe with graticule", + "family": "geography", + "intent": "Show projected land on a clipped globe with a geographic coordinate grid.", + "support": "native", + "features": [ + "orthographic projection", + "Sphere geometry", + "D3 graticule", + "projection clipping", + "layered geographic paths" + ], + "geometry": [{ "role": "geo", "count": 3, "maxCount": 3 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot projections", + "url": "https://observablehq.com/plot/features/projections" + }, + "ai": { + "create": "Create an orthographic globe from a Sphere, a D3 graticule, and one GeoJSON land collection using the same rotated projection in Observable Plot and TanStack.", + "maintain": "Keep the sphere, graticule, land, rotation, clipping, responsive fit, and paint order aligned without drawing projection geometry manually." + } +} diff --git a/benchmarks/conformance/cases/104-orthographic-globe/plot.ts b/benchmarks/conformance/cases/104-orthographic-globe/plot.ts new file mode 100644 index 0000000..924c451 --- /dev/null +++ b/benchmarks/conformance/cases/104-orthographic-globe/plot.ts @@ -0,0 +1,45 @@ +import * as Plot from '@observablehq/plot' +import { + orthographicProjection, + worldCollection, + worldGraticule, + worldSphere, +} from '../102-world-choropleth/geo-data' +import { mountObservablePlot } from '../../shared/mount' +import type { ConformanceMount } from '../../types' + +const margin = 10 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'Orthographic globe with graticule', + projection: { + type: ({ width, height }: { width: number; height: number }) => + orthographicProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo([worldSphere], { + fill: '#dbeafe', + stroke: '#64748b', + strokeWidth: 1.25, + }), + Plot.geo([worldGraticule], { + fill: 'none', + stroke: '#94a3b8', + strokeOpacity: 0.5, + strokeWidth: 0.75, + }), + Plot.geo([worldCollection(nextInput.revision)], { + fill: nextInput.revision % 2 === 0 ? '#22c55e' : '#0d9488', + fillOpacity: 0.82, + stroke: '#f8fafc', + strokeWidth: 0.75, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/104-orthographic-globe/tanstack.ts b/benchmarks/conformance/cases/104-orthographic-globe/tanstack.ts new file mode 100644 index 0000000..7fb6366 --- /dev/null +++ b/benchmarks/conformance/cases/104-orthographic-globe/tanstack.ts @@ -0,0 +1,44 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { + orthographicProjection, + worldCollection, + worldGraticule, + worldSphere, +} from '../102-world-choropleth/geo-data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape([worldSphere], { + projection: ({ chart }) => orthographicProjection(chart), + fill: '#dbeafe', + stroke: '#64748b', + strokeWidth: 1.25, + }), + geoShape([worldGraticule], { + projection: ({ chart }) => orthographicProjection(chart), + fill: 'none', + stroke: '#94a3b8', + strokeOpacity: 0.5, + strokeWidth: 0.75, + }), + geoShape([worldCollection(input.revision)], { + projection: ({ chart }) => orthographicProjection(chart), + fill: input.revision % 2 === 0 ? '#22c55e' : '#0d9488', + fillOpacity: 0.82, + stroke: '#f8fafc', + strokeWidth: 0.75, + }), + ], + x: null, + y: null, + guides: false, + margin: 10, +})) + +export const mount = tanstackMount( + definition, + 'Orthographic globe with graticule', +) diff --git a/benchmarks/conformance/cases/105-route-map/case.json b/benchmarks/conformance/cases/105-route-map/case.json new file mode 100644 index 0000000..f144d60 --- /dev/null +++ b/benchmarks/conformance/cases/105-route-map/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1050, + "id": "105-route-map", + "title": "Great-circle route map", + "family": "geography", + "intent": "Trace long-distance routes and endpoints over a projected world.", + "support": "native", + "features": [ + "GeoJSON LineString routes", + "great-circle resampling", + "projected Point endpoints", + "Equal Earth projection", + "layered geographic paths" + ], + "geometry": [{ "role": "geo", "count": 11, "maxCount": 11 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot geo mark", + "url": "https://observablehq.com/plot/marks/geo" + }, + "ai": { + "create": "Create four great-circle routes with six projected endpoints over one world land path, using GeoJSON and the same Equal Earth projection in both renderers.", + "maintain": "Preserve route keys, endpoint coordinates, D3 geographic resampling, responsive projection fitting, and explicit stroke and point styles." + } +} diff --git a/benchmarks/conformance/cases/105-route-map/data.ts b/benchmarks/conformance/cases/105-route-map/data.ts new file mode 100644 index 0000000..42349d6 --- /dev/null +++ b/benchmarks/conformance/cases/105-route-map/data.ts @@ -0,0 +1,19 @@ +import { worldPlaces, worldRoutes } from '../102-world-choropleth/geo-data' +import type { WorldPlace } from '../102-world-choropleth/geo-data' + +export { worldRoutes } + +export function routePlaces(revision = 0): readonly WorldPlace[] { + const finalStop = revision % 2 === 0 ? 'tokyo' : 'singapore' + const ids = new Set([ + 'san-francisco', + 'new-york', + 'london', + 'lagos', + 'dubai', + finalStop, + ]) + return worldPlaces(revision).filter((feature) => + ids.has(feature.properties.id), + ) +} diff --git a/benchmarks/conformance/cases/105-route-map/plot.ts b/benchmarks/conformance/cases/105-route-map/plot.ts new file mode 100644 index 0000000..cfb3da4 --- /dev/null +++ b/benchmarks/conformance/cases/105-route-map/plot.ts @@ -0,0 +1,45 @@ +import * as Plot from '@observablehq/plot' +import { + equalEarthProjection, + worldCollection, +} from '../102-world-choropleth/geo-data' +import { routePlaces, worldRoutes } from './data' +import { mountObservablePlot } from '../../shared/mount' +import type { WorldPlace, WorldRoute } from '../102-world-choropleth/geo-data' +import type { ConformanceMount } from '../../types' + +const margin = 10 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'Great-circle route map', + projection: { + type: ({ width, height }: { width: number; height: number }) => + equalEarthProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo([worldCollection(nextInput.revision)], { + fill: '#e2e8f0', + stroke: '#ffffff', + strokeWidth: 0.75, + }), + Plot.geo(worldRoutes(nextInput.revision), { + fill: 'none', + stroke: (feature: WorldRoute) => feature.properties.stroke, + strokeWidth: 2, + strokeOpacity: 0.9, + }), + Plot.geo(routePlaces(nextInput.revision), { + r: 3.5, + fill: (feature: WorldPlace) => feature.properties.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/105-route-map/tanstack.ts b/benchmarks/conformance/cases/105-route-map/tanstack.ts new file mode 100644 index 0000000..957c403 --- /dev/null +++ b/benchmarks/conformance/cases/105-route-map/tanstack.ts @@ -0,0 +1,42 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { + equalEarthProjection, + worldCollection, +} from '../102-world-choropleth/geo-data' +import { routePlaces, worldRoutes } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape([worldCollection(input.revision)], { + projection: ({ chart }) => equalEarthProjection(chart), + fill: '#e2e8f0', + stroke: '#ffffff', + strokeWidth: 0.75, + }), + geoShape(worldRoutes(input.revision), { + key: (feature) => feature.properties.id, + projection: ({ chart }) => equalEarthProjection(chart), + fill: 'none', + stroke: (feature) => feature.properties.stroke, + strokeWidth: 2, + strokeOpacity: 0.9, + }), + geoShape(routePlaces(input.revision), { + key: (feature) => feature.properties.id, + projection: ({ chart }) => equalEarthProjection(chart), + r: 3.5, + fill: (feature) => feature.properties.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + x: null, + y: null, + guides: false, + margin: 10, +})) + +export const mount = tanstackMount(definition, 'Great-circle route map') diff --git a/benchmarks/conformance/cases/106-polar-line/case.json b/benchmarks/conformance/cases/106-polar-line/case.json new file mode 100644 index 0000000..b586102 --- /dev/null +++ b/benchmarks/conformance/cases/106-polar-line/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "echarts", + "order": 1060, + "id": "106-polar-line", + "title": "Numeric polar line", + "family": "polar", + "intent": "Follow one cyclic quantitative profile through a continuous angular domain.", + "support": "native", + "features": [ + "polar coordinates", + "continuous angle scale", + "continuous radius scale", + "D3 radial line", + "circular grid", + "responsive radius" + ], + "geometry": [{ "role": "line", "count": 1, "maxCount": 1 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Apache ECharts Polar Line", + "url": "https://echarts.apache.org/examples/en/editor.html?c=line-polar" + }, + "ai": { + "create": "Create one straight-segment polar line over a zero-to-three-hundred-sixty-degree angle domain and a fixed zero-to-one-hundred radius domain.", + "maintain": "Update radius values while preserving angular order, explicit domains, responsive center and radius, stable point identities, and the closing endpoint." + } +} diff --git a/benchmarks/conformance/cases/106-polar-line/data.ts b/benchmarks/conformance/cases/106-polar-line/data.ts new file mode 100644 index 0000000..1b88839 --- /dev/null +++ b/benchmarks/conformance/cases/106-polar-line/data.ts @@ -0,0 +1,25 @@ +export interface PolarLineDatum { + id: string + angle: number + radius: number +} + +const initialRadii = [42, 57, 78, 66, 84, 62, 48, 58, 75, 88, 69, 50, 42] + +export function polarLineData(revision = 0): readonly PolarLineDatum[] { + return initialRadii.map((initialRadius, index) => { + const angle = index * 30 + let radius = initialRadius + if (revision % 2 !== 0) { + if (angle === 0 || angle === 360) radius = 50 + else if (angle === 60) radius = 70 + else if (angle === 150) radius = 76 + else if (angle === 270) radius = 80 + } + return { + id: `angle-${angle}`, + angle, + radius, + } + }) +} diff --git a/benchmarks/conformance/cases/106-polar-line/echarts.ts b/benchmarks/conformance/cases/106-polar-line/echarts.ts new file mode 100644 index 0000000..41291c0 --- /dev/null +++ b/benchmarks/conformance/cases/106-polar-line/echarts.ts @@ -0,0 +1,181 @@ +import { LineChart } from 'echarts/charts' +import { AriaComponent, PolarComponent } from 'echarts/components' +import { use } from 'echarts/core' +import { SVGRenderer } from 'echarts/renderers' +import { echartsMount } from '../../shared/echarts-mount' +import { polarLineData } from './data' +import type { EChartsCoreOption, EChartsType } from 'echarts/core' +import type { PolarLineDatum } from './data' +import type { + ConformanceGeometryQuery, + ConformanceGeometrySample, + ConformanceInput, + ConformanceTestDriver, +} from '../../types' + +use([LineChart, PolarComponent, AriaComponent, SVGRenderer]) + +const lineColor = '#0f766e' +const gridColor = '#94a3b8' + +export const mount = echartsMount( + polarLineOption, + 'Numeric polar line', + ({ chart, surface, getInput }) => staticLineDriver(chart, surface, getInput), +) + +function polarLineOption(input: ConformanceInput): EChartsCoreOption { + const rows = polarLineData(input.revision) + + return { + animation: false, + aria: { + enabled: true, + description: + 'One cyclic line over a continuous angle and radius coordinate system.', + }, + polar: { + center: ['50%', '50%'], + radius: '72%', + }, + angleAxis: { + type: 'value', + min: 0, + max: 360, + interval: 45, + startAngle: 90, + clockwise: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: gridColor, + opacity: 0.35, + width: 1, + }, + }, + }, + radiusAxis: { + type: 'value', + min: 0, + max: 100, + interval: 25, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: gridColor, + opacity: 0.35, + width: 1, + }, + }, + }, + series: { + id: 'polar-line', + type: 'line', + coordinateSystem: 'polar', + data: rows.map((row) => [row.radius, row.angle]), + showSymbol: false, + smooth: false, + clip: true, + lineStyle: { + color: lineColor, + width: 2.5, + }, + itemStyle: { color: lineColor }, + emphasis: { disabled: true }, + animation: false, + }, + } +} + +function staticLineDriver( + chart: EChartsType, + surface: HTMLDivElement, + getInput: () => ConformanceInput, +): ConformanceTestDriver { + return { + resolveTarget() { + return null + }, + readState() { + return {} + }, + geometry(query) { + return lineGeometry(chart, surface, getInput(), query) + }, + settle() { + chart.getZr().flush() + }, + } +} + +function lineGeometry( + chart: EChartsType, + surface: HTMLDivElement, + input: ConformanceInput, + query: ConformanceGeometryQuery, +): readonly ConformanceGeometrySample[] { + if ( + (query.view !== undefined && query.view !== 'main') || + query.role !== 'line' + ) { + return [] + } + const points = polarLineData(input.revision).flatMap((row) => { + const point = polarPoint(chart, row) + return point ? [point] : [] + }) + const sample = pointsBounds( + points, + surface.getBoundingClientRect(), + lineColor, + ) + return sample ? [sample] : [] +} + +function polarPoint( + chart: EChartsType, + row: PolarLineDatum, +): readonly [number, number] | null { + const point = chart.convertToPixel({ seriesIndex: 0 }, [ + row.radius, + row.angle, + ]) + if ( + !Array.isArray(point) || + point.length < 2 || + typeof point[0] !== 'number' || + typeof point[1] !== 'number' || + !Number.isFinite(point[0]) || + !Number.isFinite(point[1]) + ) { + return null + } + return [point[0], point[1]] +} + +function pointsBounds( + points: readonly (readonly [number, number])[], + surfaceBounds: DOMRect, + paint: string, +): ConformanceGeometrySample | null { + if (!points.length) return null + const xs = points.map(([x]) => x) + const ys = points.map(([, y]) => y) + const left = Math.min(...xs) + const right = Math.max(...xs) + const top = Math.min(...ys) + const bottom = Math.max(...ys) + return { + x: surfaceBounds.left + left, + y: surfaceBounds.top + top, + width: right - left, + height: bottom - top, + paint, + } +} diff --git a/benchmarks/conformance/cases/106-polar-line/tanstack.ts b/benchmarks/conformance/cases/106-polar-line/tanstack.ts new file mode 100644 index 0000000..8eb37d1 --- /dev/null +++ b/benchmarks/conformance/cases/106-polar-line/tanstack.ts @@ -0,0 +1,61 @@ +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' +import { polarLineData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const angleDomain = [0, 360] as const +const radiusDomain = [0, 100] as const +const angleGridValues = [0, 45, 90, 135, 180, 225, 270, 315] as const +const radiusGridValues = [25, 50, 75, 100] as const +const lineColor = '#0f766e' +const gridColor = '#94a3b8' + +const definition = defineChart()(({ input }) => { + const rows = polarLineData(input.revision) + + return { + marks: [ + polar({ + radiusRatio: 0.72, + angle: { scale: scaleLinear().domain(angleDomain) }, + radius: { scale: scaleLinear().domain(radiusDomain) }, + guides: [ + radialGrid({ + values: radiusGridValues, + labels: false, + stroke: gridColor, + strokeOpacity: 0.35, + }), + angleGrid({ + values: angleGridValues, + labels: false, + stroke: gridColor, + strokeOpacity: 0.35, + }), + ], + marks: [ + radialLine(rows, { + angle: 'angle', + radius: 'radius', + key: 'id', + stroke: lineColor, + strokeWidth: 2.5, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Numeric polar line') diff --git a/benchmarks/conformance/cases/107-polar-scatter/case.json b/benchmarks/conformance/cases/107-polar-scatter/case.json new file mode 100644 index 0000000..624ae58 --- /dev/null +++ b/benchmarks/conformance/cases/107-polar-scatter/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "echarts", + "order": 1070, + "id": "107-polar-scatter", + "title": "Numeric polar scatter", + "family": "polar", + "intent": "Compare independent observations by angular position and radial magnitude.", + "support": "native", + "features": [ + "polar coordinates", + "continuous angle scale", + "continuous radius scale", + "radial dots", + "circular grid", + "responsive radius" + ], + "geometry": [{ "role": "dot", "count": 14, "maxCount": 14 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Apache ECharts Polar Scatter Punch Card", + "url": "https://echarts.apache.org/examples/en/editor.html?c=scatter-polar-punchCard" + }, + "ai": { + "create": "Create fourteen equally sized points over continuous zero-to-three-hundred-sixty-degree angle and zero-to-one-hundred radius domains.", + "maintain": "Update radial values while preserving angles, fixed domains, responsive layout, stable observation keys, and constant symbol size." + } +} diff --git a/benchmarks/conformance/cases/107-polar-scatter/data.ts b/benchmarks/conformance/cases/107-polar-scatter/data.ts new file mode 100644 index 0000000..352d051 --- /dev/null +++ b/benchmarks/conformance/cases/107-polar-scatter/data.ts @@ -0,0 +1,34 @@ +export interface PolarScatterDatum { + id: string + angle: number + radius: number +} + +const initialData: readonly PolarScatterDatum[] = [ + { id: 'event-01', angle: 12, radius: 28 }, + { id: 'event-02', angle: 37, radius: 72 }, + { id: 'event-03', angle: 63, radius: 45 }, + { id: 'event-04', angle: 88, radius: 84 }, + { id: 'event-05', angle: 116, radius: 61 }, + { id: 'event-06', angle: 143, radius: 36 }, + { id: 'event-07', angle: 169, radius: 77 }, + { id: 'event-08', angle: 197, radius: 52 }, + { id: 'event-09', angle: 221, radius: 91 }, + { id: 'event-10', angle: 248, radius: 43 }, + { id: 'event-11', angle: 274, radius: 68 }, + { id: 'event-12', angle: 301, radius: 31 }, + { id: 'event-13', angle: 327, radius: 81 }, + { id: 'event-14', angle: 348, radius: 57 }, +] + +export function polarScatterData(revision = 0): readonly PolarScatterDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'event-02') return { ...row, radius: 64 } + if (row.id === 'event-05') return { ...row, radius: 74 } + if (row.id === 'event-09') return { ...row, radius: 79 } + if (row.id === 'event-12') return { ...row, radius: 46 } + return row + }) +} diff --git a/benchmarks/conformance/cases/107-polar-scatter/echarts.ts b/benchmarks/conformance/cases/107-polar-scatter/echarts.ts new file mode 100644 index 0000000..38afdf3 --- /dev/null +++ b/benchmarks/conformance/cases/107-polar-scatter/echarts.ts @@ -0,0 +1,166 @@ +import { ScatterChart } from 'echarts/charts' +import { AriaComponent, PolarComponent } from 'echarts/components' +import { use } from 'echarts/core' +import { SVGRenderer } from 'echarts/renderers' +import { echartsMount } from '../../shared/echarts-mount' +import { polarScatterData } from './data' +import type { EChartsCoreOption, EChartsType } from 'echarts/core' +import type { PolarScatterDatum } from './data' +import type { + ConformanceGeometryQuery, + ConformanceGeometrySample, + ConformanceInput, + ConformanceTestDriver, +} from '../../types' + +use([ScatterChart, PolarComponent, AriaComponent, SVGRenderer]) + +const dotColor = '#e11d48' +const gridColor = '#94a3b8' +const symbolRadius = 4.5 + +export const mount = echartsMount( + polarScatterOption, + 'Numeric polar scatter', + ({ chart, surface, getInput }) => + staticScatterDriver(chart, surface, getInput), +) + +function polarScatterOption(input: ConformanceInput): EChartsCoreOption { + const rows = polarScatterData(input.revision) + + return { + animation: false, + aria: { + enabled: true, + description: + 'Fourteen observations positioned by continuous angle and radial magnitude.', + }, + polar: { + center: ['50%', '50%'], + radius: '72%', + }, + angleAxis: { + type: 'value', + min: 0, + max: 360, + interval: 45, + startAngle: 90, + clockwise: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: gridColor, + opacity: 0.35, + width: 1, + }, + }, + }, + radiusAxis: { + type: 'value', + min: 0, + max: 100, + interval: 25, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { + show: true, + lineStyle: { + color: gridColor, + opacity: 0.35, + width: 1, + }, + }, + }, + series: { + id: 'polar-scatter', + type: 'scatter', + coordinateSystem: 'polar', + data: rows.map((row) => [row.radius, row.angle]), + symbol: 'circle', + symbolSize: symbolRadius * 2, + itemStyle: { + color: dotColor, + borderColor: '#ffffff', + borderWidth: 1, + }, + emphasis: { disabled: true }, + animation: false, + }, + } +} + +function staticScatterDriver( + chart: EChartsType, + surface: HTMLDivElement, + getInput: () => ConformanceInput, +): ConformanceTestDriver { + return { + resolveTarget() { + return null + }, + readState() { + return {} + }, + geometry(query) { + return scatterGeometry(chart, surface, getInput(), query) + }, + settle() { + chart.getZr().flush() + }, + } +} + +function scatterGeometry( + chart: EChartsType, + surface: HTMLDivElement, + input: ConformanceInput, + query: ConformanceGeometryQuery, +): readonly ConformanceGeometrySample[] { + if ( + (query.view !== undefined && query.view !== 'main') || + query.role !== 'dot' + ) { + return [] + } + const surfaceBounds = surface.getBoundingClientRect() + return polarScatterData(input.revision).flatMap((row) => { + const point = polarPoint(chart, row) + return point + ? [ + { + x: surfaceBounds.left + point[0] - symbolRadius, + y: surfaceBounds.top + point[1] - symbolRadius, + width: symbolRadius * 2, + height: symbolRadius * 2, + paint: dotColor, + }, + ] + : [] + }) +} + +function polarPoint( + chart: EChartsType, + row: PolarScatterDatum, +): readonly [number, number] | null { + const point = chart.convertToPixel({ seriesIndex: 0 }, [ + row.radius, + row.angle, + ]) + if ( + !Array.isArray(point) || + point.length < 2 || + typeof point[0] !== 'number' || + typeof point[1] !== 'number' || + !Number.isFinite(point[0]) || + !Number.isFinite(point[1]) + ) { + return null + } + return [point[0], point[1]] +} diff --git a/benchmarks/conformance/cases/107-polar-scatter/tanstack.ts b/benchmarks/conformance/cases/107-polar-scatter/tanstack.ts new file mode 100644 index 0000000..0378366 --- /dev/null +++ b/benchmarks/conformance/cases/107-polar-scatter/tanstack.ts @@ -0,0 +1,58 @@ +import { defineChart } from '@tanstack/charts' +import { angleGrid, polar, radialDot, radialGrid } from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' +import { polarScatterData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const angleDomain = [0, 360] as const +const radiusDomain = [0, 100] as const +const angleGridValues = [0, 45, 90, 135, 180, 225, 270, 315] as const +const radiusGridValues = [25, 50, 75, 100] as const +const dotColor = '#e11d48' +const gridColor = '#94a3b8' + +const definition = defineChart()(({ input }) => { + const rows = polarScatterData(input.revision) + + return { + marks: [ + polar({ + radiusRatio: 0.72, + angle: { scale: scaleLinear().domain(angleDomain) }, + radius: { scale: scaleLinear().domain(radiusDomain) }, + guides: [ + radialGrid({ + values: radiusGridValues, + labels: false, + stroke: gridColor, + strokeOpacity: 0.35, + }), + angleGrid({ + values: angleGridValues, + labels: false, + stroke: gridColor, + strokeOpacity: 0.35, + }), + ], + marks: [ + radialDot(rows, { + angle: 'angle', + radius: 'radius', + key: 'id', + r: 4.5, + fill: dotColor, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Numeric polar scatter') diff --git a/benchmarks/conformance/cases/108-country-choropleth/atlas-data.ts b/benchmarks/conformance/cases/108-country-choropleth/atlas-data.ts new file mode 100644 index 0000000..243b174 --- /dev/null +++ b/benchmarks/conformance/cases/108-country-choropleth/atlas-data.ts @@ -0,0 +1,163 @@ +import worldAtlas from 'world-atlas/countries-110m.json' +import { feature } from 'topojson-client' +import { geoEqualEarth } from 'd3-geo' +import type { + ExtendedFeature, + ExtendedFeatureCollection, + GeoGeometryObjects, + GeoProjection, + GeoSphere, +} from 'd3-geo' + +export type CountryGeometry = Extract< + GeoGeometryObjects, + { type: 'Polygon' | 'MultiPolygon' } +> +type AtlasTopology = Parameters[0] + +export interface CountryProperties { + id: string + name: string + value: number + fill: string +} + +export type CountryFeature = ExtendedFeature + +interface ProjectionBounds { + x: number + y: number + width: number + height: number +} + +const fills = ['#dbeafe', '#93c5fd', '#60a5fa', '#2563eb', '#1e3a8a'] as const + +export const countrySphere: GeoSphere = { type: 'Sphere' } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isAtlasTopology(value: unknown): value is AtlasTopology { + return ( + isRecord(value) && + value.type === 'Topology' && + isRecord(value.objects) && + Array.isArray(value.arcs) + ) +} + +function countryValue(id: string, name: string): number { + let hash = Number.parseInt(id, 10) + if (!Number.isFinite(hash)) { + hash = [...name].reduce( + (total, character) => total + (character.codePointAt(0) ?? 0), + 0, + ) + } + return 16 + ((hash * 37) % 85) +} + +function countryFill(value: number): string { + const index = Math.min(fills.length - 1, Math.floor(value / 21)) + return fills[index] ?? fills[0] +} + +function loadCountries(): readonly CountryFeature[] { + const source: unknown = worldAtlas + if (!isAtlasTopology(source)) { + throw new Error('world-atlas countries-110m is not valid TopoJSON.') + } + + const countriesObject = source.objects.countries + if (!countriesObject) { + throw new Error('world-atlas is missing its countries object.') + } + + const converted = feature(source, countriesObject) + if (converted.type !== 'FeatureCollection') { + throw new Error('world-atlas countries did not produce a collection.') + } + + const countries: CountryFeature[] = [] + converted.features.forEach((entry) => { + const geometry = entry.geometry + const properties = entry.properties + if ( + !geometry || + (geometry.type !== 'Polygon' && geometry.type !== 'MultiPolygon') || + !isRecord(properties) || + typeof properties.name !== 'string' + ) { + return + } + + const id = + entry.id === undefined + ? properties.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-') + : String(entry.id) + const value = countryValue(id, properties.name) + countries.push({ + type: 'Feature', + id, + geometry, + properties: { + id, + name: properties.name, + value, + fill: countryFill(value), + }, + }) + }) + + if (countries.length !== 177) { + throw new Error( + `Expected 177 world-atlas countries, received ${countries.length}.`, + ) + } + + return countries +} + +const atlasCountries = loadCountries() +const revisedCountries = new Set(['076', '356', '840']) + +export function countryFeatures(revision = 0): readonly CountryFeature[] { + if (revision % 2 === 0) return atlasCountries + + return atlasCountries.map((country) => { + if (!revisedCountries.has(country.properties.id)) return country + const value = Math.min(100, country.properties.value + 18) + return { + ...country, + properties: { + ...country.properties, + value, + fill: countryFill(value), + }, + } + }) +} + +export function countryCollection( + revision = 0, +): ExtendedFeatureCollection { + return { + type: 'FeatureCollection', + features: [...countryFeatures(revision)], + } +} + +export function equalEarthCountryProjection( + { x, y, width, height }: ProjectionBounds, + inset = 0, +): GeoProjection { + return geoEqualEarth().fitExtent( + [ + [x + inset, y + inset], + [x + width - inset, y + height - inset], + ], + countrySphere, + ) +} diff --git a/benchmarks/conformance/cases/108-country-choropleth/case.json b/benchmarks/conformance/cases/108-country-choropleth/case.json new file mode 100644 index 0000000..d4e22e8 --- /dev/null +++ b/benchmarks/conformance/cases/108-country-choropleth/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "order": 1080, + "id": "108-country-choropleth", + "title": "World country choropleth", + "family": "geography", + "intent": "Compare values across real country boundaries with an equal-area world projection.", + "support": "native", + "features": [ + "world-atlas 110m countries", + "TopoJSON conversion", + "177 keyed GeoJSON features", + "Equal Earth projection", + "responsive projection fitting", + "theme-aware country borders" + ], + "geometry": [{ "role": "geo", "count": 177, "maxCount": 177 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot projections", + "url": "https://observablehq.com/plot/features/projections" + }, + "ai": { + "create": "Create a country-level Equal Earth choropleth from world-atlas countries-110m, converting TopoJSON once and rendering all 177 countries as keyed native geo marks.", + "maintain": "Update country values without replacing boundary geometry; preserve atlas identity, responsive projection fitting, stable country keys, binned fills, and theme-aware borders." + } +} diff --git a/benchmarks/conformance/cases/108-country-choropleth/plot.ts b/benchmarks/conformance/cases/108-country-choropleth/plot.ts new file mode 100644 index 0000000..c534cac --- /dev/null +++ b/benchmarks/conformance/cases/108-country-choropleth/plot.ts @@ -0,0 +1,30 @@ +import * as Plot from '@observablehq/plot' +import { countryFeatures, equalEarthCountryProjection } from './atlas-data' +import { mountObservablePlot } from '../../shared/mount' +import type { CountryFeature } from './atlas-data' +import type { ConformanceMount } from '../../types' + +const margin = 12 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'World country choropleth', + projection: { + type: ({ width, height }: { width: number; height: number }) => + equalEarthCountryProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo(countryFeatures(nextInput.revision), { + fill: (country: CountryFeature) => country.properties.fill, + stroke: 'currentColor', + strokeOpacity: 0.34, + strokeWidth: 0.55, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/108-country-choropleth/tanstack.ts b/benchmarks/conformance/cases/108-country-choropleth/tanstack.ts new file mode 100644 index 0000000..df5b503 --- /dev/null +++ b/benchmarks/conformance/cases/108-country-choropleth/tanstack.ts @@ -0,0 +1,25 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { countryFeatures, equalEarthCountryProjection } from './atlas-data' +import { tanstackMount } from '../../shared/mount' +import type { CountryFeature } from './atlas-data' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape(countryFeatures(input.revision), { + key: (country) => country.properties.id, + projection: ({ chart }) => equalEarthCountryProjection(chart), + fill: (country: CountryFeature) => country.properties.fill, + stroke: 'currentColor', + strokeOpacity: 0.34, + strokeWidth: 0.55, + }), + ], + x: null, + y: null, + guides: false, + margin: 12, +})) + +export const mount = tanstackMount(definition, 'World country choropleth') diff --git a/benchmarks/conformance/cases/109-us-state-choropleth/case.json b/benchmarks/conformance/cases/109-us-state-choropleth/case.json new file mode 100644 index 0000000..11641c0 --- /dev/null +++ b/benchmarks/conformance/cases/109-us-state-choropleth/case.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "order": 1090, + "id": "109-us-state-choropleth", + "title": "United States state choropleth", + "family": "geography", + "intent": "Compare values across actual United States state and district boundaries.", + "support": "native", + "features": [ + "us-atlas 3.0.1", + "TopoJSON conversion", + "Albers USA composite projection", + "Alaska and Hawaii insets", + "51 state and district paths" + ], + "geometry": [{ "role": "geo", "count": 51, "maxCount": 51 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Observable Plot projections", + "url": "https://observablehq.com/plot/features/projections" + }, + "ai": { + "create": "Create a 51-feature United States choropleth from us-atlas TopoJSON using the same responsive Albers USA projection and literal state fills in Observable Plot and TanStack geoShape.", + "maintain": "Preserve actual atlas boundaries, stable FIPS keys, the 50 states plus District of Columbia, Alaska and Hawaii insets, revision-driven fills, and one projected path per feature." + } +} diff --git a/benchmarks/conformance/cases/109-us-state-choropleth/data.ts b/benchmarks/conformance/cases/109-us-state-choropleth/data.ts new file mode 100644 index 0000000..00a0fac --- /dev/null +++ b/benchmarks/conformance/cases/109-us-state-choropleth/data.ts @@ -0,0 +1,146 @@ +import { geoAlbersUsa } from 'd3-geo' +import { feature } from 'topojson-client' +import statesAtlasJson from 'us-atlas/states-10m.json' +import type { + ExtendedFeature, + ExtendedFeatureCollection, + GeoGeometryObjects, + GeoProjection, +} from 'd3-geo' + +interface ProjectionBounds { + x: number + y: number + width: number + height: number +} + +type AtlasTopology = Parameters[0] +type StateGeometry = Extract< + GeoGeometryObjects, + { type: 'Polygon' | 'MultiPolygon' } +> + +interface BaseStateProperties { + id: string + name: string +} + +type BaseState = ExtendedFeature + +export interface UsStateProperties extends BaseStateProperties { + value: number + fill: string +} + +export type UsState = ExtendedFeature + +const fills = [ + '#dbeafe', + '#93c5fd', + '#38bdf8', + '#0ea5e9', + '#2563eb', + '#1d4ed8', +] as const + +const atlasSource: unknown = statesAtlasJson + +if (!isAtlasTopology(atlasSource)) { + throw new TypeError('Invalid us-atlas states topology') +} + +const statesObject = atlasSource.objects.states +if (!statesObject) { + throw new TypeError('us-atlas is missing its states object') +} + +const convertedStates = feature(atlasSource, statesObject) +if (convertedStates.type !== 'FeatureCollection') { + throw new TypeError('us-atlas states did not produce a feature collection') +} + +const baseStates = convertedStates.features.flatMap((state) => { + const numericId = Number(state.id) + if ( + state.id === undefined || + !isStateGeometry(state.geometry) || + !isRecord(state.properties) || + typeof state.properties.name !== 'string' || + !Number.isInteger(numericId) || + numericId >= 60 + ) { + return [] + } + + const id = String(state.id).padStart(2, '0') + return [ + { + type: 'Feature', + id, + geometry: state.geometry, + properties: { + id, + name: state.properties.name, + }, + }, + ] +}) + +if (baseStates.length !== 51) { + throw new TypeError(`Expected 51 US state features, got ${baseStates.length}`) +} + +const stateCollection: ExtendedFeatureCollection = { + type: 'FeatureCollection', + features: [...baseStates], +} + +export function usStates(revision = 0): readonly UsState[] { + return baseStates.map((state) => { + const value = stateValue(state.properties.id, revision) + return { + ...state, + properties: { + ...state.properties, + value, + fill: fills[Math.min(fills.length - 1, Math.floor(value / 17))], + }, + } + }) +} + +export function albersUsaProjection({ + x, + y, + width, + height, +}: ProjectionBounds): GeoProjection { + return geoAlbersUsa().fitExtent( + [ + [x, y], + [x + width, y + height], + ], + stateCollection, + ) +} + +function stateValue(id: string, revision: number): number { + const base = (Number(id) * 37 + 19) % 101 + return revision % 2 === 0 ? base : (base + 29) % 101 +} + +function isStateGeometry( + geometry: GeoGeometryObjects, +): geometry is StateGeometry { + return geometry.type === 'Polygon' || geometry.type === 'MultiPolygon' +} + +function isAtlasTopology(value: unknown): value is AtlasTopology { + if (!isRecord(value) || value.type !== 'Topology') return false + return Array.isArray(value.arcs) && isRecord(value.objects) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/benchmarks/conformance/cases/109-us-state-choropleth/plot.ts b/benchmarks/conformance/cases/109-us-state-choropleth/plot.ts new file mode 100644 index 0000000..6ca4642 --- /dev/null +++ b/benchmarks/conformance/cases/109-us-state-choropleth/plot.ts @@ -0,0 +1,29 @@ +import * as Plot from '@observablehq/plot' +import { albersUsaProjection, usStates } from './data' +import { mountObservablePlot } from '../../shared/mount' +import type { UsState } from './data' +import type { ConformanceMount } from '../../types' + +const margin = 10 + +export const mount: ConformanceMount = (container, input) => + mountObservablePlot(container, input, (nextInput) => + Plot.plot({ + width: nextInput.width, + height: nextInput.height, + margin, + ariaLabel: 'United States state choropleth', + projection: { + type: ({ width, height }: { width: number; height: number }) => + albersUsaProjection({ x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo(usStates(nextInput.revision), { + fill: (state: UsState) => state.properties.fill, + stroke: '#f8fafc', + strokeWidth: 0.75, + }), + ], + }), + ) diff --git a/benchmarks/conformance/cases/109-us-state-choropleth/tanstack.ts b/benchmarks/conformance/cases/109-us-state-choropleth/tanstack.ts new file mode 100644 index 0000000..969cadd --- /dev/null +++ b/benchmarks/conformance/cases/109-us-state-choropleth/tanstack.ts @@ -0,0 +1,23 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { albersUsaProjection, usStates } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => ({ + marks: [ + geoShape(usStates(input.revision), { + key: (state) => state.properties.id, + projection: ({ chart }) => albersUsaProjection(chart), + fill: (state) => state.properties.fill, + stroke: '#f8fafc', + strokeWidth: 0.75, + }), + ], + x: null, + y: null, + guides: false, + margin: 10, +})) + +export const mount = tanstackMount(definition, 'United States state choropleth') diff --git a/benchmarks/conformance/cases/110-projection-gallery/case.json b/benchmarks/conformance/cases/110-projection-gallery/case.json new file mode 100644 index 0000000..57825d6 --- /dev/null +++ b/benchmarks/conformance/cases/110-projection-gallery/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "order": 1100, + "id": "110-projection-gallery", + "title": "Standard world projection gallery", + "family": "geography", + "intent": "Compare how four standard projections reshape the same real country atlas.", + "support": "native", + "features": [ + "four responsive projection panes", + "Equal Earth projection", + "Natural Earth projection", + "Mercator projection", + "equirectangular projection", + "world-atlas country collection", + "theme-aware sphere and country borders" + ], + "geometry": [{ "role": "geo", "count": 8, "maxCount": 8 }], + "minimumGeometrySimilarity": 0.998, + "source": { + "title": "Observable Plot projections", + "url": "https://observablehq.com/plot/features/projections" + }, + "ai": { + "create": "Create a two-by-two projection gallery showing the same world-atlas country collection in Equal Earth, Natural Earth, Mercator, and equirectangular projections.", + "maintain": "Preserve pane order, shared atlas geometry, per-projection identity, responsive fitting, sphere outlines, and theme-aware borders while updating paint." + } +} diff --git a/benchmarks/conformance/cases/110-projection-gallery/plot.ts b/benchmarks/conformance/cases/110-projection-gallery/plot.ts new file mode 100644 index 0000000..aa2f49c --- /dev/null +++ b/benchmarks/conformance/cases/110-projection-gallery/plot.ts @@ -0,0 +1,81 @@ +import * as Plot from '@observablehq/plot' +import { + countryCollection, + countrySphere, +} from '../108-country-choropleth/atlas-data' +import { + fitGalleryProjection, + projectionGalleryData, + projectionPane, +} from './projection-data' +import type { ConformanceInput, ConformanceMount } from '../../types' + +const svgNamespace = 'http://www.w3.org/2000/svg' + +function render(input: ConformanceInput): SVGSVGElement { + const root = document.createElementNS(svgNamespace, 'svg') + root.setAttribute('width', String(input.width)) + root.setAttribute('height', String(input.height)) + root.setAttribute('viewBox', `0 0 ${input.width} ${input.height}`) + root.setAttribute('role', 'img') + root.setAttribute('aria-label', 'Standard world projection gallery') + root.style.color = 'inherit' + + const countries = countryCollection(input.revision) + projectionGalleryData(input.revision).forEach((entry, index) => { + const pane = projectionPane( + { x: 0, y: 0, width: input.width, height: input.height }, + index, + ) + const plot = Plot.plot({ + width: pane.width, + height: pane.height, + margin: 0, + projection: { + type: ({ width, height }: { width: number; height: number }) => + fitGalleryProjection(entry.create(), { x: 0, y: 0, width, height }), + clip: false, + }, + marks: [ + Plot.geo([countrySphere], { + fill: 'none', + stroke: 'currentColor', + strokeOpacity: 0.5, + strokeWidth: 0.8, + }), + Plot.geo([countries], { + fill: entry.fill, + fillOpacity: 0.78, + stroke: 'currentColor', + strokeOpacity: 0.28, + strokeWidth: 0.45, + }), + ], + }) + plot.setAttribute('x', String(pane.x)) + plot.setAttribute('y', String(pane.y)) + plot.setAttribute('aria-hidden', 'true') + plot.removeAttribute('aria-label') + plot.removeAttribute('role') + plot.removeAttribute('style') + root.append(plot) + }) + + return root +} + +export const mount: ConformanceMount = (container, input) => { + let root = render(input) + container.append(root) + + return { + update(nextInput) { + const nextRoot = render(nextInput) + root.replaceWith(nextRoot) + root = nextRoot + }, + destroy() { + root.remove() + }, + } +} diff --git a/benchmarks/conformance/cases/110-projection-gallery/projection-data.ts b/benchmarks/conformance/cases/110-projection-gallery/projection-data.ts new file mode 100644 index 0000000..e8fe1be --- /dev/null +++ b/benchmarks/conformance/cases/110-projection-gallery/projection-data.ts @@ -0,0 +1,94 @@ +import { + geoEqualEarth, + geoEquirectangular, + geoMercator, + geoNaturalEarth1, +} from 'd3-geo' +import { countrySphere } from '../108-country-choropleth/atlas-data' +import type { GeoProjection } from 'd3-geo' + +export interface ProjectionGalleryDatum { + id: 'equal-earth' | 'natural-earth' | 'mercator' | 'equirectangular' + label: string + fill: string + create: () => GeoProjection +} + +export interface ProjectionPane { + x: number + y: number + width: number + height: number +} + +const definitions = [ + { + id: 'equal-earth', + label: 'Equal Earth', + create: geoEqualEarth, + fills: ['#2563eb', '#1d4ed8'], + }, + { + id: 'natural-earth', + label: 'Natural Earth', + create: geoNaturalEarth1, + fills: ['#7c3aed', '#6d28d9'], + }, + { + id: 'mercator', + label: 'Mercator', + create: geoMercator, + fills: ['#0891b2', '#0e7490'], + }, + { + id: 'equirectangular', + label: 'Equirectangular', + create: geoEquirectangular, + fills: ['#ea580c', '#c2410c'], + }, +] as const + +export function projectionGalleryData( + revision = 0, +): readonly ProjectionGalleryDatum[] { + const fillIndex = revision % 2 + return definitions.map((definition) => ({ + id: definition.id, + label: definition.label, + create: definition.create, + fill: definition.fills[fillIndex] ?? definition.fills[0], + })) +} + +export function projectionPane( + bounds: ProjectionPane, + index: number, +): ProjectionPane { + const leftWidth = Math.floor(bounds.width / 2) + const topHeight = Math.floor(bounds.height / 2) + const column = index % 2 + const row = Math.floor(index / 2) + const width = column === 0 ? leftWidth : bounds.width - leftWidth + const height = row === 0 ? topHeight : bounds.height - topHeight + + return { + x: bounds.x + (column === 0 ? 0 : leftWidth), + y: bounds.y + (row === 0 ? 0 : topHeight), + width, + height, + } +} + +export function fitGalleryProjection( + projection: GeoProjection, + { x, y, width, height }: ProjectionPane, + inset = 8, +): GeoProjection { + return projection.fitExtent( + [ + [x + inset, y + inset], + [x + width - inset, y + height - inset], + ], + countrySphere, + ) +} diff --git a/benchmarks/conformance/cases/110-projection-gallery/tanstack.ts b/benchmarks/conformance/cases/110-projection-gallery/tanstack.ts new file mode 100644 index 0000000..256b0a9 --- /dev/null +++ b/benchmarks/conformance/cases/110-projection-gallery/tanstack.ts @@ -0,0 +1,51 @@ +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { + countryCollection, + countrySphere, +} from '../108-country-choropleth/atlas-data' +import { + fitGalleryProjection, + projectionGalleryData, + projectionPane, +} from './projection-data' +import { tanstackMount } from '../../shared/mount' +import type { ConformanceInput } from '../../types' + +const definition = defineChart()(({ input }) => { + const countries = countryCollection(input.revision) + const projections = projectionGalleryData(input.revision) + + return { + marks: projections.flatMap((entry, index) => [ + geoShape([countrySphere], { + id: `${entry.id}-sphere`, + projection: ({ chart }) => + fitGalleryProjection(entry.create(), projectionPane(chart, index)), + fill: 'none', + stroke: 'currentColor', + strokeOpacity: 0.5, + strokeWidth: 0.8, + }), + geoShape([countries], { + id: `${entry.id}-countries`, + projection: ({ chart }) => + fitGalleryProjection(entry.create(), projectionPane(chart, index)), + fill: entry.fill, + fillOpacity: 0.78, + stroke: 'currentColor', + strokeOpacity: 0.28, + strokeWidth: 0.45, + }), + ]), + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount( + definition, + 'Standard world projection gallery', +) diff --git a/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts b/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts index 5ba963d..f2fe552 100644 --- a/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts +++ b/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts @@ -46,4 +46,7 @@ const definition = defineChart()(({ input }) => ({ }, })) -export const mount = tanstackMount(definition, 'Project interval timeline') +export const mount = tanstackMount(definition, 'Project interval timeline', { + format: (point) => + `${point.datum.task} · ${point.datum.phase} phase · Project days ${point.datum.start}–${point.datum.end} · Duration: ${point.datum.end - point.datum.start} days`, +}) diff --git a/benchmarks/conformance/cases/14-error-bars/tanstack.ts b/benchmarks/conformance/cases/14-error-bars/tanstack.ts index 1fb6a9c..7684692 100644 --- a/benchmarks/conformance/cases/14-error-bars/tanstack.ts +++ b/benchmarks/conformance/cases/14-error-bars/tanstack.ts @@ -4,6 +4,10 @@ import { errorData } from './data' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' +const estimate = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}) const categories = [ 'Query', 'Router', @@ -64,4 +68,8 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Point estimates with error bars', + { + format: (point) => + `${point.datum.category} · Estimate: ${estimate.format(point.datum.mean)} · Range: ${estimate.format(point.datum.low)}–${estimate.format(point.datum.high)}`, + }, ) diff --git a/benchmarks/conformance/cases/15-boxplot/tanstack.ts b/benchmarks/conformance/cases/15-boxplot/tanstack.ts index 2cec62a..eff0f45 100644 --- a/benchmarks/conformance/cases/15-boxplot/tanstack.ts +++ b/benchmarks/conformance/cases/15-boxplot/tanstack.ts @@ -95,4 +95,17 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Grouped boxplots') +export const mount = tanstackMount(definition, 'Grouped boxplots', { + format: ({ datum }) => + 'median' in datum + ? `${datum.group} · median ${datum.median.toLocaleString('en-US', { + maximumFractionDigits: 1, + })} · IQR ${datum.q1.toLocaleString('en-US', { + maximumFractionDigits: 1, + })}–${datum.q3.toLocaleString('en-US', { + maximumFractionDigits: 1, + })}` + : `${datum.group} outlier · ${datum.value.toLocaleString('en-US', { + maximumFractionDigits: 1, + })}`, +}) diff --git a/benchmarks/conformance/cases/16-lollipop/tanstack.ts b/benchmarks/conformance/cases/16-lollipop/tanstack.ts index 94587d8..61ba817 100644 --- a/benchmarks/conformance/cases/16-lollipop/tanstack.ts +++ b/benchmarks/conformance/cases/16-lollipop/tanstack.ts @@ -38,4 +38,7 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Ranked lollipop chart') +export const mount = tanstackMount(definition, 'Ranked lollipop chart', { + format: ({ datum }) => + `${datum.category} · ${datum.value.toLocaleString('en-US')} total`, +}) diff --git a/benchmarks/conformance/cases/17-dumbbell/tanstack.ts b/benchmarks/conformance/cases/17-dumbbell/tanstack.ts index cb30db4..08bbc95 100644 --- a/benchmarks/conformance/cases/17-dumbbell/tanstack.ts +++ b/benchmarks/conformance/cases/17-dumbbell/tanstack.ts @@ -57,4 +57,10 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Desktop and mobile dumbbell comparison', + { + format: ({ datum }) => + `${datum.category} · Desktop ${datum.desktop.toLocaleString( + 'en-US', + )} · Mobile ${datum.mobile.toLocaleString('en-US')}`, + }, ) diff --git a/benchmarks/conformance/cases/21-streamgraph/tanstack.ts b/benchmarks/conformance/cases/21-streamgraph/tanstack.ts index fbba3a1..897a08d 100644 --- a/benchmarks/conformance/cases/21-streamgraph/tanstack.ts +++ b/benchmarks/conformance/cases/21-streamgraph/tanstack.ts @@ -65,6 +65,17 @@ const definition = defineChart()(({ input }) => { export const mount: ConformanceMount = tanstackMount( definition, 'Three-series streamgraph', + { + format: ({ datum }) => + `${datum.series} · ${datum.date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + })} · ${datum.value.toLocaleString('en-US', { + maximumFractionDigits: 1, + })} index points`, + }, ) function streamIntervals(rows: readonly TimePoint[]): readonly StreamPoint[] { diff --git a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts index f6b85a6..fe77006 100644 --- a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts +++ b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts @@ -82,4 +82,8 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Quantitative two-dimensional binned heatmap', + { + format: (point) => + `Latency: ${point.datum.x1}–${point.datum.x2} · Throughput: ${point.datum.y1}–${point.datum.y2} · Observations: ${point.datum.count}`, + }, ) diff --git a/benchmarks/conformance/cases/28-candlestick/tanstack.ts b/benchmarks/conformance/cases/28-candlestick/tanstack.ts index 0ca039b..8ba6f6b 100644 --- a/benchmarks/conformance/cases/28-candlestick/tanstack.ts +++ b/benchmarks/conformance/cases/28-candlestick/tanstack.ts @@ -4,6 +4,17 @@ import { candleData, candleDomain } from './data' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' +const candleDate = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}) +const price = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + const definition = defineChart()(({ input }) => { const rows = candleData(input.revision) const gains = rows.filter((row) => row.close >= row.open) @@ -47,4 +58,7 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Daily candlestick chart') +export const mount = tanstackMount(definition, 'Daily candlestick chart', { + format: (point) => + `${candleDate.format(point.datum.date)} · Open: ${price.format(point.datum.open)} · High: ${price.format(point.datum.high)} · Low: ${price.format(point.datum.low)} · Close: ${price.format(point.datum.close)}`, +}) diff --git a/benchmarks/conformance/cases/29-waterfall/tanstack.ts b/benchmarks/conformance/cases/29-waterfall/tanstack.ts index 3ffdb28..e62f7d9 100644 --- a/benchmarks/conformance/cases/29-waterfall/tanstack.ts +++ b/benchmarks/conformance/cases/29-waterfall/tanstack.ts @@ -20,6 +20,9 @@ const kinds: readonly WaterfallPoint['kind'][] = [ 'total', ] const colors = ['#10b981', '#ef4444', '#2563eb'] +const signedAmount = new Intl.NumberFormat('en-US', { + signDisplay: 'always', +}) const definition = defineChart()(({ input, width }) => ({ marks: [ @@ -50,4 +53,11 @@ const definition = defineChart()(({ input, width }) => ({ }, })) -export const mount = tanstackMount(definition, 'Contribution waterfall chart') +export const mount = tanstackMount(definition, 'Contribution waterfall chart', { + format: ({ datum }) => + datum.kind === 'total' + ? `${datum.label} · ${datum.end.toLocaleString('en-US')} total` + : `${datum.label} · ${signedAmount.format( + datum.end - datum.start, + )} · ${datum.end.toLocaleString('en-US')} running total`, +}) diff --git a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts index 0c71440..e77545e 100644 --- a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts +++ b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts @@ -62,4 +62,16 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Scatterplot with linear regression', + { + format: ({ datum }) => + 'group' in datum + ? `${datum.group} · X ${datum.x.toLocaleString( + 'en-US', + )} · Y ${datum.y.toLocaleString('en-US')}` + : `Regression · X ${datum.x.toLocaleString( + 'en-US', + )} · predicted Y ${datum.y.toLocaleString('en-US', { + maximumFractionDigits: 1, + })}`, + }, ) diff --git a/benchmarks/conformance/cases/36-hierarchy-tree/tanstack.ts b/benchmarks/conformance/cases/36-hierarchy-tree/tanstack.ts index fe4004d..2a48102 100644 --- a/benchmarks/conformance/cases/36-hierarchy-tree/tanstack.ts +++ b/benchmarks/conformance/cases/36-hierarchy-tree/tanstack.ts @@ -16,6 +16,8 @@ interface TreeNodeRow { interface TreeLinkRow { id: string + sourceLabel: string + targetLabel: string x1: number y1: number x2: number @@ -41,6 +43,8 @@ const definition = defineChart()(({ input }) => { .links() .map(({ source, target }) => ({ id: `${source.data.id}:${target.data.id}`, + sourceLabel: source.data.label, + targetLabel: target.data.label, x1: source.y, y1: -source.x, x2: target.y, @@ -82,4 +86,9 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Tidy product hierarchy') +export const mount = tanstackMount(definition, 'Tidy product hierarchy', { + format: ({ datum }) => + 'label' in datum + ? `${datum.label} · ${datum.internal ? 'Group' : 'Leaf'}` + : `${datum.sourceLabel} → ${datum.targetLabel}`, +}) diff --git a/benchmarks/conformance/cases/39-density-contours/tanstack.test.ts b/benchmarks/conformance/cases/39-density-contours/tanstack.test.ts new file mode 100644 index 0000000..416cad0 --- /dev/null +++ b/benchmarks/conformance/cases/39-density-contours/tanstack.test.ts @@ -0,0 +1,39 @@ +import { createChartRuntime } from '@tanstack/charts' +import { describe, expect, it } from 'vitest' +import { densityDefinition, type DensityContourDatum } from './tanstack' +import { densityThresholds, densityXDomain, densityYDomain } from './data' +import type { ConformanceInput } from '../../types' + +describe('density contour interaction points', () => { + it('emits one semantic centroid and density value per contour', () => { + const runtime = createChartRuntime< + DensityContourDatum, + ConformanceInput, + number, + number + >() + const scene = runtime.render( + densityDefinition, + { width: 640, height: 400, revision: 0, interactive: true }, + { width: 640, height: 400 }, + ) + + expect(scene.points).toHaveLength(densityThresholds.length) + expect(scene.points.map((point) => point.datum.density)).toEqual( + densityThresholds.map((threshold) => threshold / 100), + ) + + for (const point of scene.points) { + expect(point.xValue).toBe(point.datum.centroidX) + expect(point.yValue).toBe(point.datum.centroidY) + expect(point.datum.centroidX).toBeGreaterThanOrEqual(densityXDomain[0]) + expect(point.datum.centroidX).toBeLessThanOrEqual(densityXDomain[1]) + expect(point.datum.centroidY).toBeGreaterThanOrEqual(densityYDomain[0]) + expect(point.datum.centroidY).toBeLessThanOrEqual(densityYDomain[1]) + expect(point.x).toBeGreaterThanOrEqual(0) + expect(point.x).toBeLessThanOrEqual(scene.width) + expect(point.y).toBeGreaterThanOrEqual(0) + expect(point.y).toBeLessThanOrEqual(scene.height) + } + }) +}) diff --git a/benchmarks/conformance/cases/39-density-contours/tanstack.ts b/benchmarks/conformance/cases/39-density-contours/tanstack.ts index c335ecc..cb4c054 100644 --- a/benchmarks/conformance/cases/39-density-contours/tanstack.ts +++ b/benchmarks/conformance/cases/39-density-contours/tanstack.ts @@ -13,9 +13,27 @@ import { tanstackMount } from '../../shared/mount' import type { ContourMultiPolygon } from 'd3-contour' import type { DensityPoint } from './data' import type { ConformanceInput } from '../../types' -import type { SceneNode } from '@tanstack/charts' +import type { ChartPoint, SceneNode } from '@tanstack/charts' -const definition = defineChart()(({ input }) => { +export interface DensityContourDatum { + id: string + centroidX: number + centroidY: number + density: number +} + +const densityPercent = new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) +const densityCoordinate = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}) + +export const densityDefinition = defineChart()(({ + input, +}) => { const points = densityPoints(input.revision) return { @@ -32,7 +50,7 @@ const definition = defineChart()(({ input }) => { }) function densityMark(data: DensityPoint[]) { - return createMark(({ markIndex }) => { + return createMark(({ markIndex }) => { const id = `density-${markIndex}` return { @@ -52,14 +70,23 @@ function densityMark(data: DensityPoint[]) { const path = geoPath() const children: SceneNode[] = [] + const points: ChartPoint[] = [] + const centroidXScale = scaleLinear() + .domain(densityXDomain) + .range([0, chart.width]) + const centroidYScale = scaleLinear() + .domain(densityYDomain) + .range([chart.height, 0]) + for (let index = 0; index < geometry.length; index++) { const contour = geometry[index] if (contour === undefined) continue const pathData = path(contour) if (pathData === null) continue + const key = `${id}:${index}` children.push({ kind: 'area', - key: `${id}:${index}`, + key, points: [], path: pathData, style: { @@ -69,6 +96,29 @@ function densityMark(data: DensityPoint[]) { strokeWidth: 1, }, }) + + const [x, y] = path.centroid(contour) + if (!Number.isFinite(x) || !Number.isFinite(y)) continue + const centroidX = centroidXScale.invert(x) + const centroidY = centroidYScale.invert(y) + points.push({ + key, + markId: id, + group: contour.value, + groupLabel: 'Density contour', + datum: { + id: key, + centroidX, + centroidY, + density: contour.value, + }, + datumIndex: index, + xValue: centroidX, + yValue: centroidY, + x, + y, + color: '#2563eb', + }) } return { @@ -81,10 +131,18 @@ function densityMark(data: DensityPoint[]) { children, }, ], + points, } }, } }) } -export const mount = tanstackMount(definition, 'Point density contours') +export const mount = tanstackMount( + densityDefinition, + 'Point density contours', + { + format: (point) => + `Density: ${densityPercent.format(point.datum.density)} · Centroid: (${densityCoordinate.format(point.datum.centroidX)}, ${densityCoordinate.format(point.datum.centroidY)})`, + }, +) diff --git a/benchmarks/conformance/cases/40-force-directed-network/tanstack.ts b/benchmarks/conformance/cases/40-force-directed-network/tanstack.ts index 5c8dab2..ef284f6 100644 --- a/benchmarks/conformance/cases/40-force-directed-network/tanstack.ts +++ b/benchmarks/conformance/cases/40-force-directed-network/tanstack.ts @@ -5,6 +5,10 @@ import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' import type { NetworkGroup } from './data' +function networkNodeLabel(id: string): string { + return `${id.slice(0, 1).toUpperCase()}${id.slice(1)}` +} + const definition = defineChart()(({ input }) => { const graph = networkLayout(input.revision) @@ -57,4 +61,12 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Force-directed service dependency network', + { + format: ({ datum }) => + 'label' in datum + ? `${datum.label} · ${datum.group}` + : `${networkNodeLabel(datum.source)} → ${networkNodeLabel( + datum.target, + )} · Weight ${datum.weight}`, + }, ) diff --git a/benchmarks/conformance/cases/40-geojson-map/case.json b/benchmarks/conformance/cases/40-geojson-map/case.json index 5608dad..48a4d01 100644 --- a/benchmarks/conformance/cases/40-geojson-map/case.json +++ b/benchmarks/conformance/cases/40-geojson-map/case.json @@ -5,13 +5,13 @@ "title": "Regional GeoJSON choropleth", "family": "geography", "intent": "Compare regional values using a compact GeoJSON choropleth map.", - "support": "composed", + "support": "native", "features": [ "geo mark", "GeoJSON", "identity projection", "d3-geo", - "custom mark" + "geoShape" ], "geometry": [{ "role": "geo", "count": 6 }], "source": { @@ -19,7 +19,7 @@ "url": "https://observablehq.com/plot/marks/geo" }, "ai": { - "create": "Create a six-region choropleth from a typed GeoJSON feature collection. Plot should use geo with an identity projection; TanStack should inject d3-geo and render one path per feature through a local custom mark.", + "create": "Create a six-region choropleth from a typed GeoJSON feature collection. Plot should use geo with an identity projection; TanStack should inject d3-geo and render one path per feature through geoShape.", "maintain": "Keep projection fitting responsive, preserve feature identity and literal fills, and leave geographic projection code outside the ordinary cartesian bundle." } } diff --git a/benchmarks/conformance/cases/40-geojson-map/data.ts b/benchmarks/conformance/cases/40-geojson-map/data.ts index 966d5f7..23499fa 100644 --- a/benchmarks/conformance/cases/40-geojson-map/data.ts +++ b/benchmarks/conformance/cases/40-geojson-map/data.ts @@ -1,25 +1,20 @@ +import type { + ExtendedFeature, + ExtendedFeatureCollection, + GeoGeometryObjects, +} from 'd3-geo' + export interface RegionProperties { name: string value: number fill: string } -export interface RegionPolygon { - type: 'Polygon' - coordinates: number[][][] -} - -export interface RegionFeature { - type: 'Feature' +export type RegionPolygon = Extract +export type RegionFeature = ExtendedFeature & { id: string - properties: RegionProperties - geometry: RegionPolygon -} - -export interface RegionFeatureCollection { - type: 'FeatureCollection' - features: RegionFeature[] } +export type RegionFeatureCollection = ExtendedFeatureCollection const regionShapes: readonly { id: string diff --git a/benchmarks/conformance/cases/40-geojson-map/tanstack.ts b/benchmarks/conformance/cases/40-geojson-map/tanstack.ts index 28db638..84658e8 100644 --- a/benchmarks/conformance/cases/40-geojson-map/tanstack.ts +++ b/benchmarks/conformance/cases/40-geojson-map/tanstack.ts @@ -1,16 +1,30 @@ -import { createMark, defineChart } from '@tanstack/charts' -import { geoIdentity, geoPath } from 'd3-geo' +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { geoIdentity } from 'd3-geo' import { regionCollection } from './data' import { tanstackMount } from '../../shared/mount' -import type { RegionFeature, RegionFeatureCollection } from './data' import type { ConformanceInput } from '../../types' -import type { SceneNode } from '@tanstack/charts' const definition = defineChart()(({ input }) => { const collection = regionCollection(input.revision) return { - marks: [geoMark(collection)], + marks: [ + geoShape(collection.features, { + key: (feature) => feature.id, + projection: ({ chart }) => + geoIdentity().fitExtent( + [ + [chart.x, chart.y], + [chart.x + chart.width, chart.y + chart.height], + ], + collection, + ), + fill: (feature) => feature.properties.fill, + stroke: '#f8fafc', + strokeWidth: 1.5, + }), + ], x: null, y: null, guides: false, @@ -18,55 +32,4 @@ const definition = defineChart()(({ input }) => { } }) -function geoMark(collection: RegionFeatureCollection) { - return createMark(({ markIndex }) => { - const id = `geo-${markIndex}` - - return { - id, - channels: {}, - render: ({ chart }) => { - const projection = geoIdentity().fitExtent( - [ - [chart.x, chart.y], - [chart.x + chart.width, chart.y + chart.height], - ], - collection, - ) - const path = geoPath(projection) - const children: SceneNode[] = [] - - for (const feature of collection.features) { - const pathData = path(feature) - if (pathData === null) continue - children.push({ - kind: 'area', - key: `${id}:${feature.id}`, - points: [], - path: pathData, - style: { - fill: feature.properties.fill, - stroke: '#f8fafc', - strokeWidth: 1.5, - lineJoin: 'round', - }, - }) - } - - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__area', - ariaHidden: true, - children, - }, - ], - } - }, - } - }) -} - export const mount = tanstackMount(definition, 'Regional GeoJSON choropleth') diff --git a/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts b/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts index 8ea66c1..6c0ac5a 100644 --- a/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts +++ b/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts @@ -14,6 +14,9 @@ interface HexbinCell { } const margin = { top: 20, right: 20, bottom: 40, left: 48 } as const +const coordinate = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}) const definition = defineChart()(({ input }) => { const xScale = scaleLinear() @@ -66,6 +69,10 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Hexagonally binned point density', + { + format: (point) => + `Bin center: (${coordinate.format(point.datum.x)}, ${coordinate.format(point.datum.y)}) · Points: ${point.datum.count}`, + }, ) function countColor(count: number): string { diff --git a/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts b/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts index 93d7225..a99325e 100644 --- a/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts +++ b/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts @@ -1,13 +1,14 @@ import { defineChart, facet, rect } from '@tanstack/charts' import { bin } from 'd3-array' import { scaleLinear } from 'd3-scale' -import { facetedDistributionData } from './data' -import type { FacetedDistributionPoint } from './data' +import { distributionGroups, facetedDistributionData } from './data' +import type { DistributionGroup, FacetedDistributionPoint } from './data' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' interface DistributionBin { id: string + group: DistributionGroup x1: number x2: number proportion: number @@ -25,56 +26,57 @@ const percent = new Intl.NumberFormat('en-US', { const definition = defineChart()(({ input }) => { const rows = facetedDistributionData(input.revision) + const bins: readonly DistributionBin[] = distributionGroups.flatMap( + (group) => { + const groupRows = rows.filter((row) => row.group === group) + return createBins(groupRows).flatMap((bucket, index) => + bucket.x0 === undefined || bucket.x1 === undefined + ? [] + : [ + { + id: `${group}:${index}`, + group, + x1: bucket.x0, + x2: bucket.x1, + proportion: bucket.length / groupRows.length, + }, + ], + ) + }, + ) return { marks: [ - facet(rows, { + facet(bins, { by: 'group', columns: 1, gap: 8, label: (group) => String(group), - chart: (facetRows, group) => { - const bins: readonly DistributionBin[] = createBins( - facetRows, - ).flatMap((bucket, index) => - bucket.x0 === undefined || bucket.x1 === undefined - ? [] - : [ - { - id: `${String(group)}:${index}`, - x1: bucket.x0, - x2: bucket.x1, - proportion: bucket.length / facetRows.length, - }, - ], - ) - - return { - marks: [ - rect(bins, { - x1: 'x1', - x2: 'x2', - y1: () => 0, - y2: 'proportion', - key: 'id', - fill: '#8b5cf6', - inset: 0.75, - }), - ], - x: { - scale: scaleLinear().domain([0, 100]), - grid: true, - label: 'Observed value', - }, - y: { - scale: scaleLinear().domain([0, 0.25]), - grid: true, - ticks: 3, - label: 'Proportion', - format: (value) => percent.format(value), - }, - } - }, + chart: (facetBins) => ({ + marks: [ + rect(facetBins, { + x1: 'x1', + x2: 'x2', + y1: () => 0, + y2: 'proportion', + key: 'id', + fill: '#8b5cf6', + inset: 0.75, + }), + ], + x: { + scale: scaleLinear().domain([0, 100]), + grid: true, + label: 'Observed value', + }, + y: { + scale: scaleLinear().domain([0, 0.25]), + grid: true, + ticks: 3, + label: 'Proportion', + format: (value) => percent.format(value), + }, + }), }), ], guides: false, @@ -87,4 +89,8 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Faceted distribution comparison', + { + format: (point) => + `${point.datum.group} · Observed value: ${point.datum.x1}–${point.datum.x2} · Proportion: ${percent.format(point.datum.proportion)}`, + }, ) diff --git a/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts b/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts index 5b77a7d..e20aae4 100644 --- a/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts +++ b/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts @@ -59,6 +59,14 @@ const definition = defineChart()(({ input }) => { export const mount = tanstackMount( definition, 'Indexed performance from first observation', + { + format: ({ datum }) => + `${datum.series} · ${datum.date.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + })} · ${formatIndex(datum.indexed)} from start`, + }, ) function indexFromFirst( diff --git a/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts b/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts index dde9886..aafffbb 100644 --- a/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts +++ b/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts @@ -59,4 +59,19 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Violin distribution comparison') +export const mount = tanstackMount( + definition, + 'Violin distribution comparison', + { + format: ({ datum }) => + 'median' in datum + ? `${datum.cohort} · median score ${datum.median.toLocaleString( + 'en-US', + { maximumFractionDigits: 1 }, + )}` + : `${datum.cohort} · distribution score ${datum.value.toLocaleString( + 'en-US', + { maximumFractionDigits: 1 }, + )}`, + }, +) diff --git a/benchmarks/conformance/cases/75-radar/case.json b/benchmarks/conformance/cases/75-radar/case.json index 8a55aa6..9097be3 100644 --- a/benchmarks/conformance/cases/75-radar/case.json +++ b/benchmarks/conformance/cases/75-radar/case.json @@ -6,13 +6,13 @@ "title": "Simple radar chart", "family": "polar", "intent": "Compare one profile across six equally spaced qualitative dimensions.", - "support": "composed", + "support": "native", "features": [ "polar coordinates", "radial scale", "polygon grid", "radial tick labels", - "filled profile" + "closed radial area" ], "geometry": [ { "role": "radar", "count": 1 }, diff --git a/benchmarks/conformance/cases/75-radar/tanstack.ts b/benchmarks/conformance/cases/75-radar/tanstack.ts index 2807e6c..2be5c46 100644 --- a/benchmarks/conformance/cases/75-radar/tanstack.ts +++ b/benchmarks/conformance/cases/75-radar/tanstack.ts @@ -1,211 +1,92 @@ -import { createMark, defineChart } from '@tanstack/charts' -import { radarData } from './data' +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialArea, + radialGrid, +} from '@tanstack/charts/polar' +import { scaleLinear, scalePoint } from 'd3-scale' +import { curveLinearClosed } from 'd3-shape' +import { radarData, radarSubjects } from './data' import { tanstackMount } from '../../shared/mount' -import type { RadarDatum } from './data' import type { ConformanceInput } from '../../types' -import type { SceneNode } from '@tanstack/charts' +import type { PolarGuideLabelContext } from '@tanstack/charts/polar' const maximumScore = 150 const ringValues = [30, 60, 90, 120, 150] as const -const angleLabelOffset = 8 -const radialAxisAngle = 30 +const angleScale = scalePoint().domain(radarSubjects) +const radiusScale = scaleLinear().domain([0, maximumScore]) -function polarPointAtAngle( - centerX: number, - centerY: number, - radius: number, - angleDegrees: number, -): readonly [number, number] { - const angle = (-angleDegrees * Math.PI) / 180 - return [ - centerX + Math.cos(angle) * radius, - centerY + Math.sin(angle) * radius, - ] +function angleLabelIsTopOrBottom(angle: number): boolean { + return Math.abs(Math.sin(angle)) <= Math.SQRT1_2 } -function polarPoint( - centerX: number, - centerY: number, - radius: number, - index: number, - count: number, -): readonly [number, number] { - return polarPointAtAngle(centerX, centerY, radius, 90 - (index / count) * 360) +function angleLabelBaseline({ + angle, + y, +}: PolarGuideLabelContext): 'auto' | 'middle' | 'hanging' { + if (!angleLabelIsTopOrBottom(angle)) return 'middle' + return y > 0 ? 'hanging' : 'auto' } -function closedPolygon( - centerX: number, - centerY: number, - radius: number, - count: number, -): readonly (readonly [number, number])[] { - const points = Array.from({ length: count }, (_, index) => - polarPoint(centerX, centerY, radius, index, count), - ) - const first = points[0] - return first === undefined ? points : [...points, first] +function angleLabelDy({ angle, y }: PolarGuideLabelContext): number { + if (!angleLabelIsTopOrBottom(angle)) return 1.1 + return y > 0 ? -1.1 : 0 } -function radarMark(data: readonly RadarDatum[]) { - return createMark(({ markIndex }) => { - const id = `radar-${markIndex}` +const definition = defineChart()(({ input }) => { + const data = radarData(input.revision) - return { - id, - channels: {}, - render: ({ chart }) => { - const centerX = chart.x + chart.width / 2 - const centerY = chart.y + chart.height / 2 - const radius = Math.min(chart.width, chart.height) * 0.4 - const count = data.length - const gridNodes: SceneNode[] = ringValues.map((value) => ({ - kind: 'polyline', - key: `${id}:ring:${value}`, - points: closedPolygon( - centerX, - centerY, - radius * (value / maximumScore), - count, - ), - style: { - fill: 'none', + return { + marks: [ + polar({ + angle: { scale: angleScale, wrap: true }, + radius: { scale: radiusScale }, + inset: 0, + radiusRatio: 0.8, + guides: [ + radialGrid({ + values: ringValues, + shape: 'polygon', + labels: true, + labelAngle: Math.PI / 3, + labelRotate: 60, + labelBaseline: 'auto', + labelFill: '#cccccc', stroke: '#cbd5e1', - strokeWidth: 1, - }, - })) - - for (let index = 0; index < count; index++) { - const endpoint = polarPoint(centerX, centerY, radius, index, count) - gridNodes.push({ - kind: 'rule', - key: `${id}:spoke:${index}`, - x1: centerX, - y1: centerY, - x2: endpoint[0], - y2: endpoint[1], - style: { stroke: '#cbd5e1', strokeWidth: 1 }, - }) - } - - const profile = data.map((row, index) => - polarPoint( - centerX, - centerY, - radius * (row.score / maximumScore), - index, - count, - ), - ) - const labels: SceneNode[] = data.map((row, index) => { - const position = polarPoint( - centerX, - centerY, - radius + angleLabelOffset, - index, - count, - ) - const horizontal = position[0] - centerX - const vertical = position[1] - centerY - const polarAngle = - (-Math.PI / 2 + (index / count) * Math.PI * 2) % (Math.PI * 2) - const topOrBottom = Math.abs(Math.cos(polarAngle)) <= Math.SQRT1_2 - const verticalTextAdjustment = topOrBottom - ? vertical > 0 - ? -1.1 - : 0 - : 1.1 - - return { - kind: 'label', - key: `${id}:label:${row.subject}`, - x: position[0], - y: position[1] + verticalTextAdjustment, - text: row.subject, - anchor: - Math.abs(horizontal) < 1 - ? 'middle' - : horizontal < 0 - ? 'end' - : 'start', - baseline: topOrBottom - ? vertical > 0 - ? 'hanging' - : 'auto' - : 'middle', - fontSize: 12, - style: { fill: '#808080' }, - } - }) - const radialLabels: SceneNode[] = ringValues.map((value) => { - const position = polarPointAtAngle( - centerX, - centerY, - radius * (value / maximumScore), - radialAxisAngle, - ) - - return { - kind: 'label', - key: `${id}:radius:${value}`, - x: position[0], - y: position[1], - text: String(value), - anchor: 'start', - baseline: 'auto', - rotate: 90 - radialAxisAngle, - fontSize: 12, - style: { fill: '#cccccc' }, - } - }) - - return { - nodes: [ - { - kind: 'group', - key: `${id}:grid`, - ariaHidden: true, - children: gridNodes, - }, - { - kind: 'group', - key: `${id}:profile`, - className: 'ts-chart__radar', - ariaHidden: true, - children: [ - { - kind: 'area', - key: `${id}:profile-area`, - points: profile, - style: { - fill: '#8884d8', - fillOpacity: 0.6, - stroke: '#8884d8', - strokeWidth: 2, - lineJoin: 'round', - }, - }, - ], - }, - { - kind: 'group', - key: `${id}:labels`, - className: 'ts-chart__text', - ariaHidden: true, - children: [...labels, ...radialLabels], - }, - ], - } - }, - } - }) -} - -const definition = defineChart()(({ input }) => ({ - marks: [radarMark(radarData(input.revision))], - x: null, - y: null, - guides: false, - margin: 20, -})) + }), + angleGrid({ + values: radarSubjects, + labels: true, + labelOffset: 8, + labelBaseline: angleLabelBaseline, + labelDy: angleLabelDy, + labelFill: '#808080', + stroke: '#cbd5e1', + }), + ], + marks: [ + radialArea(data, { + angle: 'subject', + radius: 'score', + key: 'subject', + className: 'ts-chart__radar', + curve: curveLinearClosed, + fill: '#8884d8', + fillOpacity: 0.6, + stroke: '#8884d8', + strokeWidth: 2, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 20, + } +}) -export const mount = tanstackMount(definition, 'Simple radar chart') +export const mount = tanstackMount(definition, 'Simple radar chart', { + format: ({ datum }) => `${datum.subject} · ${datum.score} / ${maximumScore}`, +}) diff --git a/benchmarks/conformance/cases/76-pie/case.json b/benchmarks/conformance/cases/76-pie/case.json new file mode 100644 index 0000000..6b4a0a6 --- /dev/null +++ b/benchmarks/conformance/cases/76-pie/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 760, + "id": "76-pie", + "title": "Categorical pie chart", + "family": "polar", + "intent": "Compare four categories as parts of one whole with a full-circle angular layout.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "keyed arc sectors", + "categorical fills", + "responsive radius" + ], + "geometry": [{ "role": "arc", "count": 4, "maxCount": 4 }], + "minimumGeometrySimilarity": 0.9999, + "source": { + "title": "Recharts Straight Angle Pie Chart", + "url": "https://recharts.github.io/en-US/examples/StraightAnglePieChart/" + }, + "ai": { + "create": "Create a four-slice pie chart from raw category totals using D3 pie layout and keyed TanStack arc geometry.", + "maintain": "Update category totals while preserving slice order, full-circle orientation, responsive radius, stable identities, and exact categorical fills." + } +} diff --git a/benchmarks/conformance/cases/76-pie/data.ts b/benchmarks/conformance/cases/76-pie/data.ts new file mode 100644 index 0000000..209c2cb --- /dev/null +++ b/benchmarks/conformance/cases/76-pie/data.ts @@ -0,0 +1,24 @@ +export interface PieDatum { + id: 'ingest' | 'query' | 'alerts' | 'other' + label: string + value: number + fill: string +} + +const initialData: readonly PieDatum[] = [ + { id: 'ingest', label: 'Ingest', value: 42, fill: '#2563eb' }, + { id: 'query', label: 'Query', value: 28, fill: '#7c3aed' }, + { id: 'alerts', label: 'Alerts', value: 18, fill: '#db2777' }, + { id: 'other', label: 'Other', value: 12, fill: '#f59e0b' }, +] + +export function pieData(revision = 0): readonly PieDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'ingest') return { ...row, value: 35 } + if (row.id === 'query') return { ...row, value: 31 } + if (row.id === 'alerts') return { ...row, value: 22 } + return row + }) +} diff --git a/benchmarks/conformance/cases/76-pie/recharts.ts b/benchmarks/conformance/cases/76-pie/recharts.ts new file mode 100644 index 0000000..516b219 --- /dev/null +++ b/benchmarks/conformance/cases/76-pie/recharts.ts @@ -0,0 +1,45 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { pieData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = pieData(input.revision) + const radius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: 0, + outerRadius: radius, + startAngle: 90, + endAngle: -270, + stroke: 'none', + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Categorical pie chart') diff --git a/benchmarks/conformance/cases/76-pie/tanstack.ts b/benchmarks/conformance/cases/76-pie/tanstack.ts new file mode 100644 index 0000000..920f79e --- /dev/null +++ b/benchmarks/conformance/cases/76-pie/tanstack.ts @@ -0,0 +1,42 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' +import { pieData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { PieDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...pieData(input.revision)]) + + return { + marks: [ + polar({ + inset: 0, + radiusRatio: 0.8, + marks: [ + radialArc(arcs, { + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Categorical pie chart', { + format: ({ datum }) => `${datum.data.label} · ${datum.data.value}%`, +}) diff --git a/benchmarks/conformance/cases/77-donut/case.json b/benchmarks/conformance/cases/77-donut/case.json new file mode 100644 index 0000000..6e163a1 --- /dev/null +++ b/benchmarks/conformance/cases/77-donut/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 770, + "id": "77-donut", + "title": "Categorical donut chart", + "family": "polar", + "intent": "Compare five categories as parts of one whole while preserving a readable center.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "annular arc sectors", + "inner and outer radii", + "responsive radius" + ], + "geometry": [{ "role": "arc", "count": 5, "maxCount": 5 }], + "minimumGeometrySimilarity": 0.9999, + "source": { + "title": "Recharts Pie Chart in Grid", + "url": "https://recharts.github.io/en-US/examples/PieChartInGrid/" + }, + "ai": { + "create": "Create a five-slice donut chart from raw category totals using D3 pie layout and one responsive annular arc mark.", + "maintain": "Update category totals while preserving the inner-radius ratio, clockwise slice order, stable identities, and exact categorical fills." + } +} diff --git a/benchmarks/conformance/cases/77-donut/data.ts b/benchmarks/conformance/cases/77-donut/data.ts new file mode 100644 index 0000000..51bdd8e --- /dev/null +++ b/benchmarks/conformance/cases/77-donut/data.ts @@ -0,0 +1,25 @@ +export interface DonutDatum { + id: 'browser' | 'node' | 'edge' | 'mobile' | 'other' + label: string + value: number + fill: string +} + +const initialData: readonly DonutDatum[] = [ + { id: 'browser', label: 'Browser', value: 36, fill: '#0ea5e9' }, + { id: 'node', label: 'Node', value: 27, fill: '#6366f1' }, + { id: 'edge', label: 'Edge', value: 17, fill: '#a855f7' }, + { id: 'mobile', label: 'Mobile', value: 13, fill: '#ec4899' }, + { id: 'other', label: 'Other', value: 7, fill: '#f97316' }, +] + +export function donutData(revision = 0): readonly DonutDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'browser') return { ...row, value: 30 } + if (row.id === 'node') return { ...row, value: 31 } + if (row.id === 'edge') return { ...row, value: 19 } + return row + }) +} diff --git a/benchmarks/conformance/cases/77-donut/recharts.ts b/benchmarks/conformance/cases/77-donut/recharts.ts new file mode 100644 index 0000000..64ed1da --- /dev/null +++ b/benchmarks/conformance/cases/77-donut/recharts.ts @@ -0,0 +1,45 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { donutData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = donutData(input.revision) + const outerRadius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: outerRadius * 0.58, + outerRadius, + startAngle: 90, + endAngle: -270, + stroke: 'none', + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Categorical donut chart') diff --git a/benchmarks/conformance/cases/77-donut/tanstack.ts b/benchmarks/conformance/cases/77-donut/tanstack.ts new file mode 100644 index 0000000..f125ba8 --- /dev/null +++ b/benchmarks/conformance/cases/77-donut/tanstack.ts @@ -0,0 +1,41 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' +import { donutData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { DonutDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...donutData(input.revision)]) + + return { + marks: [ + polar({ + inset: 0, + radiusRatio: 0.8, + marks: [ + radialArc(arcs, { + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + innerRadius: ({ radius }: { radius: number }) => radius * 0.58, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Categorical donut chart') diff --git a/benchmarks/conformance/cases/78-gauge/case.json b/benchmarks/conformance/cases/78-gauge/case.json new file mode 100644 index 0000000..aa1af32 --- /dev/null +++ b/benchmarks/conformance/cases/78-gauge/case.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 780, + "id": "78-gauge", + "title": "Partial-circle gauge", + "family": "polar", + "intent": "Show one bounded proportion across a 270-degree annular sweep.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "partial angular range", + "annular arc sectors", + "bounded proportion", + "responsive radius" + ], + "geometry": [{ "role": "arc", "count": 2, "maxCount": 2 }], + "minimumGeometrySimilarity": 0.9999, + "source": { + "title": "Recharts Pie Chart With Needle", + "url": "https://recharts.github.io/en-US/examples/PieChartWithNeedle/" + }, + "ai": { + "create": "Create a 270-degree gauge from a bounded value and its remainder using two keyed annular arcs.", + "maintain": "Update the bounded value while preserving the partial sweep, zero baseline, responsive radii, stable value and remainder identities, and exact fills." + } +} diff --git a/benchmarks/conformance/cases/78-gauge/data.ts b/benchmarks/conformance/cases/78-gauge/data.ts new file mode 100644 index 0000000..921adff --- /dev/null +++ b/benchmarks/conformance/cases/78-gauge/data.ts @@ -0,0 +1,20 @@ +export interface GaugeDatum { + id: 'value' | 'remainder' + label: string + value: number + fill: string +} + +export function gaugeData(revision = 0): readonly GaugeDatum[] { + const value = revision % 2 === 0 ? 72 : 84 + + return [ + { id: 'value', label: 'Value', value, fill: '#ef4444' }, + { + id: 'remainder', + label: 'Remainder', + value: 100 - value, + fill: '#e2e8f0', + }, + ] +} diff --git a/benchmarks/conformance/cases/78-gauge/recharts.ts b/benchmarks/conformance/cases/78-gauge/recharts.ts new file mode 100644 index 0000000..e3982b7 --- /dev/null +++ b/benchmarks/conformance/cases/78-gauge/recharts.ts @@ -0,0 +1,45 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { gaugeData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = gaugeData(input.revision) + const outerRadius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: outerRadius * 0.72, + outerRadius, + startAngle: 225, + endAngle: -45, + stroke: 'none', + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Partial-circle gauge') diff --git a/benchmarks/conformance/cases/78-gauge/tanstack.ts b/benchmarks/conformance/cases/78-gauge/tanstack.ts new file mode 100644 index 0000000..be6c0be --- /dev/null +++ b/benchmarks/conformance/cases/78-gauge/tanstack.ts @@ -0,0 +1,45 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' +import { gaugeData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { GaugeDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const startAngle = (-Math.PI * 3) / 4 +const endAngle = (Math.PI * 3) / 4 +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + .startAngle(startAngle) + .endAngle(endAngle) + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...gaugeData(input.revision)]) + + return { + marks: [ + polar({ + inset: 0, + radiusRatio: 0.8, + marks: [ + radialArc(arcs, { + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + innerRadius: ({ radius }: { radius: number }) => radius * 0.72, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Partial-circle gauge') diff --git a/benchmarks/conformance/cases/93-labeled-pie/case.json b/benchmarks/conformance/cases/93-labeled-pie/case.json new file mode 100644 index 0000000..fc8255a --- /dev/null +++ b/benchmarks/conformance/cases/93-labeled-pie/case.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 930, + "id": "93-labeled-pie", + "title": "Pie with outside labels", + "family": "polar", + "intent": "Identify each contribution around a part-to-whole pie without relying on a separate legend.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "outside radial labels", + "leader lines", + "responsive label radius", + "data-driven text anchors" + ], + "geometry": [ + { "role": "arc", "count": 4, "maxCount": 4 }, + { "role": "rule", "count": 4, "maxCount": 4 }, + { "role": "text", "count": 4, "maxCount": 4 } + ], + "minimumGeometrySimilarity": 0.995, + "source": { + "title": "Recharts Pie Chart With Customized Label", + "url": "https://recharts.github.io/en-US/examples/PieChartWithCustomizedLabel/" + }, + "ai": { + "create": "Create a four-slice pie with stable categorical colors, radial leader lines, and one short outside label at each arc midpoint.", + "maintain": "Update totals while preserving source order, arc identity, responsive leader and label offsets, side-aware text anchors, and label-to-slice color." + } +} diff --git a/benchmarks/conformance/cases/93-labeled-pie/data.ts b/benchmarks/conformance/cases/93-labeled-pie/data.ts new file mode 100644 index 0000000..6e12d39 --- /dev/null +++ b/benchmarks/conformance/cases/93-labeled-pie/data.ts @@ -0,0 +1,24 @@ +export interface LabeledPieDatum { + id: 'api' | 'worker' | 'browser' | 'other' + label: string + value: number + fill: string +} + +const initialData: readonly LabeledPieDatum[] = [ + { id: 'api', label: 'API', value: 38, fill: '#2563eb' }, + { id: 'worker', label: 'Worker', value: 27, fill: '#7c3aed' }, + { id: 'browser', label: 'Browser', value: 21, fill: '#db2777' }, + { id: 'other', label: 'Other', value: 14, fill: '#f59e0b' }, +] + +export function labeledPieData(revision = 0): readonly LabeledPieDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'api') return { ...row, value: 32 } + if (row.id === 'worker') return { ...row, value: 30 } + if (row.id === 'browser') return { ...row, value: 24 } + return row + }) +} diff --git a/benchmarks/conformance/cases/93-labeled-pie/recharts.ts b/benchmarks/conformance/cases/93-labeled-pie/recharts.ts new file mode 100644 index 0000000..e505192 --- /dev/null +++ b/benchmarks/conformance/cases/93-labeled-pie/recharts.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { labeledPieData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' +import type { PieLabelRenderProps } from 'recharts' + +const radiusRatio = 0.56 + +function renderLabel({ name }: PieLabelRenderProps): string { + return String(name ?? '') +} + +function chart(input: ConformanceInput) { + const data = labeledPieData(input.revision) + const radius = (Math.min(input.width, input.height) / 2) * radiusRatio + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: 0, + outerRadius: radius, + startAngle: 90, + endAngle: -270, + label: renderLabel, + labelLine: { + stroke: '#94a3b8', + strokeWidth: 1, + }, + fontSize: 12, + fontWeight: 500, + stroke: 'none', + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Pie with outside labels') diff --git a/benchmarks/conformance/cases/93-labeled-pie/tanstack.ts b/benchmarks/conformance/cases/93-labeled-pie/tanstack.ts new file mode 100644 index 0000000..8ca151a --- /dev/null +++ b/benchmarks/conformance/cases/93-labeled-pie/tanstack.ts @@ -0,0 +1,88 @@ +import { defineChart } from '@tanstack/charts' +import { + polar, + radialArc, + radialRule, + radialText, +} from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' +import { pie } from 'd3-shape' +import { labeledPieData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { LabeledPieDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const tau = Math.PI * 2 +const radiusRatio = 0.56 +const labelOffset = 20 +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + +interface PieLabelDatum { + id: LabeledPieDatum['id'] + label: string + fill: string + angle: number + radius: number +} + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...labeledPieData(input.revision)]) + const outerRadius = (Math.min(input.width, input.height) / 2) * radiusRatio + const labels: readonly PieLabelDatum[] = arcs.map((slice) => ({ + id: slice.data.id, + label: slice.data.label, + fill: slice.data.fill, + angle: (slice.startAngle + slice.endAngle) / 2, + radius: 1 + labelOffset / Math.max(1, outerRadius), + })) + + return { + marks: [ + polar({ + radiusRatio, + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + marks: [ + radialArc(arcs, { + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + radialRule(labels, { + angle: 'angle', + radius1: 1, + radius2: 'radius', + key: 'id', + stroke: '#94a3b8', + strokeWidth: 1, + }), + radialText(labels, { + angle: 'angle', + radius: 'radius', + text: 'label', + key: 'id', + fill: ({ fill }) => fill, + fontSize: 12, + fontWeight: 500, + anchor: ({ angle }) => { + const horizontal = Math.sin(angle) + return Math.abs(horizontal) < 1e-6 + ? 'middle' + : horizontal < 0 + ? 'end' + : 'start' + }, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Pie with outside labels') diff --git a/benchmarks/conformance/cases/94-center-donut/case.json b/benchmarks/conformance/cases/94-center-donut/case.json new file mode 100644 index 0000000..e1066b0 --- /dev/null +++ b/benchmarks/conformance/cases/94-center-donut/case.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 940, + "id": "94-center-donut", + "title": "Donut with center total", + "family": "polar", + "intent": "Show a categorical composition while keeping its aggregate total visible in the center.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "annular arc sectors", + "center label", + "responsive radius" + ], + "geometry": [ + { "role": "arc", "count": 3, "maxCount": 3 }, + { "role": "text", "count": 1, "maxCount": 1 } + ], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Recharts Pie API", + "url": "https://recharts.github.io/en-US/api/Pie/" + }, + "ai": { + "create": "Create a three-slice donut with a derived total centered in the readable inner ring.", + "maintain": "Recompute both the arcs and center total from the same revision while preserving stable category identities and the inner-radius ratio." + } +} diff --git a/benchmarks/conformance/cases/94-center-donut/data.ts b/benchmarks/conformance/cases/94-center-donut/data.ts new file mode 100644 index 0000000..98ab933 --- /dev/null +++ b/benchmarks/conformance/cases/94-center-donut/data.ts @@ -0,0 +1,22 @@ +export interface CenterDonutDatum { + id: 'ingest' | 'query' | 'store' + label: string + value: number + fill: string +} + +const initialData: readonly CenterDonutDatum[] = [ + { id: 'ingest', label: 'Ingest', value: 42, fill: '#0ea5e9' }, + { id: 'query', label: 'Query', value: 32, fill: '#6366f1' }, + { id: 'store', label: 'Store', value: 22, fill: '#a855f7' }, +] + +export function centerDonutData(revision = 0): readonly CenterDonutDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'ingest') return { ...row, value: 47 } + if (row.id === 'query') return { ...row, value: 35 } + return { ...row, value: 26 } + }) +} diff --git a/benchmarks/conformance/cases/94-center-donut/recharts.ts b/benchmarks/conformance/cases/94-center-donut/recharts.ts new file mode 100644 index 0000000..63f328f --- /dev/null +++ b/benchmarks/conformance/cases/94-center-donut/recharts.ts @@ -0,0 +1,56 @@ +import { createElement } from 'react' +import { Cell, Label, Pie, PieChart } from 'recharts' +import { centerDonutData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = centerDonutData(input.revision) + const total = data.reduce((sum, row) => sum + row.value, 0) + const outerRadius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: outerRadius * 0.62, + outerRadius, + startAngle: 90, + endAngle: -270, + stroke: 'none', + isAnimationActive: false, + }, + [ + ...data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + createElement(Label, { + key: 'total', + value: `${total}k`, + position: 'center', + fill: '#0f172a', + fontSize: 20, + fontWeight: 700, + }), + ], + ), + ) +} + +export const mount = rechartsMount(chart, 'Donut with center total') diff --git a/benchmarks/conformance/cases/94-center-donut/tanstack.ts b/benchmarks/conformance/cases/94-center-donut/tanstack.ts new file mode 100644 index 0000000..6e7ebb5 --- /dev/null +++ b/benchmarks/conformance/cases/94-center-donut/tanstack.ts @@ -0,0 +1,58 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc, radialText } from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' +import { pie } from 'd3-shape' +import { centerDonutData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { CenterDonutDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const tau = Math.PI * 2 +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + +const definition = defineChart()(({ input }) => { + const data = centerDonutData(input.revision) + const arcs = pieLayout([...data]) + const total = data.reduce((sum, row) => sum + row.value, 0) + const center = [{ id: 'total', angle: 0, radius: 0, text: `${total}k` }] + + return { + marks: [ + polar({ + radiusRatio: 0.8, + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + marks: [ + radialArc(arcs, { + innerRadius: ({ radius }) => radius * 0.62, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + radialText(center, { + angle: 'angle', + radius: 'radius', + text: 'text', + key: 'id', + fill: '#0f172a', + fontSize: 20, + fontWeight: 700, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Donut with center total', { + format: ({ datum }) => + 'data' in datum + ? `${datum.data.label} · ${datum.data.value}k` + : `Total · ${datum.text}`, +}) diff --git a/benchmarks/conformance/cases/95-rounded-donut/case.json b/benchmarks/conformance/cases/95-rounded-donut/case.json new file mode 100644 index 0000000..4b808c2 --- /dev/null +++ b/benchmarks/conformance/cases/95-rounded-donut/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 950, + "id": "95-rounded-donut", + "title": "Rounded donut with gaps", + "family": "polar", + "intent": "Separate a compact categorical composition with deliberate angular gaps and rounded arc ends.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "angular padding", + "rounded arc corners", + "responsive annular radii" + ], + "geometry": [{ "role": "arc", "count": 5, "maxCount": 5 }], + "minimumGeometrySimilarity": 0.995, + "source": { + "title": "Recharts Pie Chart With Padding Angle", + "url": "https://recharts.github.io/en-US/examples/PieChartWithPaddingAngle/" + }, + "ai": { + "create": "Create a five-slice donut with three-degree gaps, eight-pixel corners, and stable categorical fills.", + "maintain": "Update category totals while preserving explicit gap size, responsive radii, rounded corners, source order, and stable arc keys." + } +} diff --git a/benchmarks/conformance/cases/95-rounded-donut/data.ts b/benchmarks/conformance/cases/95-rounded-donut/data.ts new file mode 100644 index 0000000..481ae69 --- /dev/null +++ b/benchmarks/conformance/cases/95-rounded-donut/data.ts @@ -0,0 +1,25 @@ +export interface RoundedDonutDatum { + id: 'api' | 'jobs' | 'browser' | 'edge' | 'other' + label: string + value: number + fill: string +} + +const initialData: readonly RoundedDonutDatum[] = [ + { id: 'api', label: 'API', value: 34, fill: '#0284c7' }, + { id: 'jobs', label: 'Jobs', value: 25, fill: '#4f46e5' }, + { id: 'browser', label: 'Browser', value: 19, fill: '#9333ea' }, + { id: 'edge', label: 'Edge', value: 14, fill: '#db2777' }, + { id: 'other', label: 'Other', value: 8, fill: '#ea580c' }, +] + +export function roundedDonutData(revision = 0): readonly RoundedDonutDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'api') return { ...row, value: 29 } + if (row.id === 'jobs') return { ...row, value: 28 } + if (row.id === 'browser') return { ...row, value: 22 } + return row + }) +} diff --git a/benchmarks/conformance/cases/95-rounded-donut/recharts.ts b/benchmarks/conformance/cases/95-rounded-donut/recharts.ts new file mode 100644 index 0000000..be720fb --- /dev/null +++ b/benchmarks/conformance/cases/95-rounded-donut/recharts.ts @@ -0,0 +1,47 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { roundedDonutData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = roundedDonutData(input.revision) + const outerRadius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: outerRadius * 0.58, + outerRadius, + startAngle: 90, + endAngle: -270, + paddingAngle: 3, + cornerRadius: 8, + stroke: 'none', + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Rounded donut with gaps') diff --git a/benchmarks/conformance/cases/95-rounded-donut/tanstack.ts b/benchmarks/conformance/cases/95-rounded-donut/tanstack.ts new file mode 100644 index 0000000..a2bc3dc --- /dev/null +++ b/benchmarks/conformance/cases/95-rounded-donut/tanstack.ts @@ -0,0 +1,43 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' +import { roundedDonutData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { RoundedDonutDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const paddingAngle = (Math.PI / 180) * 3 +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + .padAngle(paddingAngle) + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...roundedDonutData(input.revision)]) + + return { + marks: [ + polar({ + radiusRatio: 0.8, + marks: [ + radialArc(arcs, { + startAngle: 'startAngle', + endAngle: (slice) => slice.endAngle - slice.padAngle, + padAngle: () => 0, + innerRadius: ({ radius }) => radius * 0.58, + cornerRadius: 8, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Rounded donut with gaps') diff --git a/benchmarks/conformance/cases/96-nested-donut/case.json b/benchmarks/conformance/cases/96-nested-donut/case.json new file mode 100644 index 0000000..c516b05 --- /dev/null +++ b/benchmarks/conformance/cases/96-nested-donut/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 960, + "id": "96-nested-donut", + "title": "Nested donut rings", + "family": "polar", + "intent": "Compare a high-level split and its detailed composition in two concentric rings.", + "support": "native", + "features": [ + "polar coordinates", + "two D3 pie layouts", + "concentric arc layers", + "independent ring radii", + "stable hierarchical keys" + ], + "geometry": [{ "role": "arc", "count": 6, "maxCount": 6 }], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Recharts Two Level Pie Chart", + "url": "https://recharts.github.io/en-US/examples/TwoLevelPieChart/" + }, + "ai": { + "create": "Create an inner two-slice summary ring and an outer four-slice detail ring with independent keyed D3 pie layouts.", + "maintain": "Update both levels together while preserving ring order, the gap between rings, stable identities, and responsive inner and outer radii." + } +} diff --git a/benchmarks/conformance/cases/96-nested-donut/data.ts b/benchmarks/conformance/cases/96-nested-donut/data.ts new file mode 100644 index 0000000..f7c734e --- /dev/null +++ b/benchmarks/conformance/cases/96-nested-donut/data.ts @@ -0,0 +1,40 @@ +export interface NestedDonutDatum { + id: 'client' | 'server' | 'browser' | 'mobile' | 'api' | 'worker' + label: string + value: number + fill: string +} + +export interface NestedDonutData { + inner: readonly NestedDonutDatum[] + outer: readonly NestedDonutDatum[] +} + +const initialData: NestedDonutData = { + inner: [ + { id: 'client', label: 'Client', value: 62, fill: '#38bdf8' }, + { id: 'server', label: 'Server', value: 38, fill: '#8b5cf6' }, + ], + outer: [ + { id: 'browser', label: 'Browser', value: 35, fill: '#0284c7' }, + { id: 'mobile', label: 'Mobile', value: 27, fill: '#0ea5e9' }, + { id: 'api', label: 'API', value: 23, fill: '#7c3aed' }, + { id: 'worker', label: 'Worker', value: 15, fill: '#a855f7' }, + ], +} + +export function nestedDonutData(revision = 0): NestedDonutData { + if (revision % 2 === 0) return initialData + + return { + inner: initialData.inner.map((row) => + row.id === 'client' ? { ...row, value: 55 } : { ...row, value: 45 }, + ), + outer: initialData.outer.map((row) => { + if (row.id === 'browser') return { ...row, value: 30 } + if (row.id === 'mobile') return { ...row, value: 25 } + if (row.id === 'api') return { ...row, value: 27 } + return { ...row, value: 18 } + }), + } +} diff --git a/benchmarks/conformance/cases/96-nested-donut/recharts.ts b/benchmarks/conformance/cases/96-nested-donut/recharts.ts new file mode 100644 index 0000000..12de313 --- /dev/null +++ b/benchmarks/conformance/cases/96-nested-donut/recharts.ts @@ -0,0 +1,72 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { nestedDonutData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const data = nestedDonutData(input.revision) + const radius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + [ + createElement( + Pie, + { + key: 'inner', + data: data.inner, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: radius * 0.12, + outerRadius: radius * 0.46, + startAngle: 90, + endAngle: -270, + stroke: 'none', + isAnimationActive: false, + }, + data.inner.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + createElement( + Pie, + { + key: 'outer', + data: data.outer, + dataKey: 'value', + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: radius * 0.56, + outerRadius: radius, + startAngle: 90, + endAngle: -270, + stroke: 'none', + isAnimationActive: false, + }, + data.outer.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: 'none', + }), + ), + ), + ], + ) +} + +export const mount = rechartsMount(chart, 'Nested donut rings') diff --git a/benchmarks/conformance/cases/96-nested-donut/tanstack.ts b/benchmarks/conformance/cases/96-nested-donut/tanstack.ts new file mode 100644 index 0000000..fab1082 --- /dev/null +++ b/benchmarks/conformance/cases/96-nested-donut/tanstack.ts @@ -0,0 +1,48 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' +import { nestedDonutData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { NestedDonutDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const innerLayout = pie() + .sort(null) + .value(({ value }) => value) +const outerLayout = pie() + .sort(null) + .value(({ value }) => value) + +const definition = defineChart()(({ input }) => { + const data = nestedDonutData(input.revision) + const innerArcs = innerLayout([...data.inner]) + const outerArcs = outerLayout([...data.outer]) + + return { + marks: [ + polar({ + radiusRatio: 0.8, + marks: [ + radialArc(innerArcs, { + innerRadius: ({ radius }) => radius * 0.12, + outerRadius: ({ radius }) => radius * 0.46, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + radialArc(outerArcs, { + innerRadius: ({ radius }) => radius * 0.56, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Nested donut rings') diff --git a/benchmarks/conformance/cases/97-rose/case.json b/benchmarks/conformance/cases/97-rose/case.json new file mode 100644 index 0000000..c61a68d --- /dev/null +++ b/benchmarks/conformance/cases/97-rose/case.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 970, + "id": "97-rose", + "title": "Nightingale rose chart", + "family": "polar", + "intent": "Compare six values through equal-angle sectors whose radii vary on one fixed scale.", + "support": "native", + "features": [ + "polar coordinates", + "equal-angle D3 pie layout", + "data-driven outer radii", + "authored D3 arc generator", + "fixed radial domain" + ], + "geometry": [{ "role": "arc", "count": 6, "maxCount": 6 }], + "minimumGeometrySimilarity": 0.995, + "source": { + "title": "Recharts Pie API", + "url": "https://recharts.github.io/en-US/api/Pie/" + }, + "ai": { + "create": "Create six equal-angle sectors whose outer radii encode values against a fixed zero-to-one-hundred domain.", + "maintain": "Update values without changing angle order or the radial domain; keep D3 arc accessors responsive and retain stable category keys." + } +} diff --git a/benchmarks/conformance/cases/97-rose/data.ts b/benchmarks/conformance/cases/97-rose/data.ts new file mode 100644 index 0000000..88faa0e --- /dev/null +++ b/benchmarks/conformance/cases/97-rose/data.ts @@ -0,0 +1,27 @@ +export interface RoseDatum { + id: 'api' | 'worker' | 'browser' | 'edge' | 'mobile' | 'other' + label: string + value: number + fill: string +} + +const initialData: readonly RoseDatum[] = [ + { id: 'api', label: 'API', value: 92, fill: '#0369a1' }, + { id: 'worker', label: 'Worker', value: 74, fill: '#2563eb' }, + { id: 'browser', label: 'Browser', value: 61, fill: '#4f46e5' }, + { id: 'edge', label: 'Edge', value: 83, fill: '#7c3aed' }, + { id: 'mobile', label: 'Mobile', value: 48, fill: '#c026d3' }, + { id: 'other', label: 'Other', value: 67, fill: '#db2777' }, +] + +export function roseData(revision = 0): readonly RoseDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => { + if (row.id === 'api') return { ...row, value: 78 } + if (row.id === 'worker') return { ...row, value: 88 } + if (row.id === 'browser') return { ...row, value: 70 } + if (row.id === 'mobile') return { ...row, value: 56 } + return row + }) +} diff --git a/benchmarks/conformance/cases/97-rose/recharts.ts b/benchmarks/conformance/cases/97-rose/recharts.ts new file mode 100644 index 0000000..6439925 --- /dev/null +++ b/benchmarks/conformance/cases/97-rose/recharts.ts @@ -0,0 +1,54 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { roseData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { RoseDatum } from './data' +import type { ConformanceInput } from '../../types' + +const maximumValue = 100 + +function outerRadius(value: number, radius: number): number { + return radius * (0.3 + (0.7 * value) / maximumValue) +} + +function chart(input: ConformanceInput) { + const data = roseData(input.revision) + const radius = Math.min(input.width, input.height) * 0.4 + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + createElement( + Pie, + { + data, + dataKey: () => 1, + nameKey: 'label', + cx: input.width / 2, + cy: input.height / 2, + innerRadius: 0, + outerRadius: (row: RoseDatum) => outerRadius(row.value, radius), + startAngle: 90, + endAngle: -270, + stroke: '#ffffff', + strokeWidth: 1, + isAnimationActive: false, + }, + data.map((row) => + createElement(Cell, { + key: row.id, + fill: row.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ), + ), + ) +} + +export const mount = rechartsMount(chart, 'Nightingale rose chart') diff --git a/benchmarks/conformance/cases/97-rose/tanstack.ts b/benchmarks/conformance/cases/97-rose/tanstack.ts new file mode 100644 index 0000000..244b120 --- /dev/null +++ b/benchmarks/conformance/cases/97-rose/tanstack.ts @@ -0,0 +1,49 @@ +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { arc, pie } from 'd3-shape' +import { roseData } from './data' +import { tanstackMount } from '../../shared/mount' +import type { RoseDatum } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const maximumValue = 100 +const pieLayout = pie() + .sort(null) + .value(() => 1) + +function outerRadius(value: number, radius: number): number { + return radius * (0.3 + (0.7 * value) / maximumValue) +} + +const definition = defineChart()(({ input }) => { + const arcs = pieLayout([...roseData(input.revision)]) + + return { + marks: [ + polar({ + radiusRatio: 0.8, + marks: [ + radialArc(arcs, { + key: ({ data }: PieArcDatum) => data.id, + generator: ({ radius }) => + arc>() + .startAngle((slice) => slice.startAngle) + .endAngle((slice) => slice.endAngle) + .innerRadius(0) + .outerRadius((slice) => outerRadius(slice.data.value, radius)), + fill: ({ data }: PieArcDatum) => data.fill, + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Nightingale rose chart') diff --git a/benchmarks/conformance/cases/98-needle-gauge/case.json b/benchmarks/conformance/cases/98-needle-gauge/case.json new file mode 100644 index 0000000..66ea1c3 --- /dev/null +++ b/benchmarks/conformance/cases/98-needle-gauge/case.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 980, + "id": "98-needle-gauge", + "title": "Threshold gauge with needle", + "family": "polar", + "intent": "Show one bounded reading against three threshold bands with a centered needle and value.", + "support": "native", + "features": [ + "polar coordinates", + "D3 pie layout", + "threshold bands", + "native radial rule ticks and needle", + "native radial dot hub", + "native radial text" + ], + "geometry": [ + { "role": "arc", "count": 3, "maxCount": 3 }, + { "role": "rule", "count": 12, "maxCount": 12 }, + { "role": "dot", "count": 1, "maxCount": 1 }, + { "role": "text", "count": 1, "maxCount": 1 } + ], + "minimumGeometrySimilarity": 0.999, + "source": { + "title": "Recharts Pie Chart With Needle", + "url": "https://recharts.github.io/en-US/examples/PieChartWithNeedle/" + }, + "ai": { + "create": "Create a semicircular threshold gauge with three D3 pie sectors, eleven radial tick rules, a scale-driven radial rule needle, a radial dot hub, and a centered radial value label.", + "maintain": "Update the reading without changing threshold or tick boundaries; preserve stable keys, the zero-to-one-hundred angle domain, the responsive radius, and native polar mark ownership." + } +} diff --git a/benchmarks/conformance/cases/98-needle-gauge/data.ts b/benchmarks/conformance/cases/98-needle-gauge/data.ts new file mode 100644 index 0000000..a3c932b --- /dev/null +++ b/benchmarks/conformance/cases/98-needle-gauge/data.ts @@ -0,0 +1,41 @@ +export interface GaugeBand { + id: string + label: string + value: number + fill: string +} + +export interface GaugeReading { + id: string + value: number + label: string +} + +export interface GaugeTick { + id: string + value: number +} + +export const gaugeBands: readonly GaugeBand[] = [ + { id: 'safe', label: 'Safe', value: 55, fill: '#22c55e' }, + { id: 'watch', label: 'Watch', value: 25, fill: '#f59e0b' }, + { id: 'critical', label: 'Critical', value: 20, fill: '#ef4444' }, +] + +export const gaugeTicks: readonly GaugeTick[] = Array.from( + { length: 11 }, + (_, index) => ({ + id: `tick-${index}`, + value: index * 10, + }), +) + +export function gaugeReading(revision = 0): GaugeReading { + const value = revision % 2 === 0 ? 68 : 86 + + return { + id: 'reading', + value, + label: `${value}%`, + } +} diff --git a/benchmarks/conformance/cases/98-needle-gauge/recharts.ts b/benchmarks/conformance/cases/98-needle-gauge/recharts.ts new file mode 100644 index 0000000..68683c5 --- /dev/null +++ b/benchmarks/conformance/cases/98-needle-gauge/recharts.ts @@ -0,0 +1,102 @@ +import { createElement } from 'react' +import { Cell, Pie, PieChart } from 'recharts' +import { gaugeBands, gaugeReading, gaugeTicks } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + const reading = gaugeReading(input.revision) + const radius = Math.min(input.width, input.height) * 0.41 + const cx = input.width / 2 + const cy = input.height / 2 + const angle = -Math.PI / 2 + (reading.value / 100) * Math.PI + const needleRadius = radius * 0.64 + const x2 = cx + Math.sin(angle) * needleRadius + const y2 = cy - Math.cos(angle) * needleRadius + + return createElement( + PieChart, + { + width: input.width, + height: input.height, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: true, + }, + [ + createElement( + Pie, + { + key: 'bands', + data: gaugeBands, + dataKey: 'value', + nameKey: 'label', + cx, + cy, + innerRadius: radius * 0.72, + outerRadius: radius, + startAngle: 180, + endAngle: 0, + stroke: 'none', + isAnimationActive: false, + }, + gaugeBands.map((band) => + createElement(Cell, { + key: band.id, + fill: band.fill, + stroke: 'none', + }), + ), + ), + ...gaugeTicks.map((tick) => { + const tickAngle = -Math.PI / 2 + (tick.value / 100) * Math.PI + + return createElement('line', { + key: tick.id, + className: 'recharts-reference-line-line', + x1: cx + Math.sin(tickAngle) * radius * 0.76, + y1: cy - Math.cos(tickAngle) * radius * 0.76, + x2: cx + Math.sin(tickAngle) * radius * 0.94, + y2: cy - Math.cos(tickAngle) * radius * 0.94, + stroke: '#ffffff', + strokeOpacity: 0.85, + strokeWidth: 2, + }) + }), + createElement('line', { + key: 'needle', + className: 'recharts-reference-line-line', + x1: cx, + y1: cy, + x2, + y2, + stroke: 'currentColor', + strokeWidth: 4, + }), + createElement('circle', { + key: 'hub', + className: 'recharts-dot', + cx, + cy, + r: 8, + fill: 'currentColor', + }), + createElement( + 'text', + { + key: 'value', + className: 'recharts-text', + x: cx, + y: cy + 34, + fill: 'currentColor', + fontSize: 18, + fontWeight: 700, + textAnchor: 'middle', + dominantBaseline: 'middle', + }, + reading.label, + ), + ], + ) +} + +export const mount = rechartsMount(chart, 'Threshold gauge with needle') diff --git a/benchmarks/conformance/cases/98-needle-gauge/tanstack.ts b/benchmarks/conformance/cases/98-needle-gauge/tanstack.ts new file mode 100644 index 0000000..f8de2e3 --- /dev/null +++ b/benchmarks/conformance/cases/98-needle-gauge/tanstack.ts @@ -0,0 +1,102 @@ +import { defineChart } from '@tanstack/charts' +import { + polar, + radialArc, + radialDot, + radialRule, + radialText, +} from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' +import { pie } from 'd3-shape' +import { gaugeBands, gaugeReading, gaugeTicks } from './data' +import { tanstackMount } from '../../shared/mount' +import type { GaugeBand } from './data' +import type { ConformanceInput } from '../../types' +import type { PieArcDatum } from 'd3-shape' + +const startAngle = -Math.PI / 2 +const endAngle = Math.PI / 2 +const angleScale = scaleLinear().domain([0, 100]) +const radiusScale = scaleLinear().domain([0, 1]) +const pieLayout = pie() + .sort(null) + .value(({ value }) => value) + .startAngle(startAngle) + .endAngle(endAngle) + +const definition = defineChart()(({ input }) => { + const reading = gaugeReading(input.revision) + const arcs = pieLayout([...gaugeBands]) + + return { + marks: [ + polar({ + angle: { scale: angleScale }, + radius: { scale: radiusScale }, + startAngle, + endAngle, + inset: 0, + radiusRatio: 0.82, + marks: [ + radialArc(arcs, { + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + innerRadius: ({ radius }) => radius * 0.72, + key: ({ data }: PieArcDatum) => data.id, + fill: ({ data }: PieArcDatum) => data.fill, + }), + radialRule(gaugeTicks, { + angle: 'value', + radius1: 0.76, + radius2: 0.94, + key: 'id', + stroke: '#ffffff', + strokeOpacity: 0.85, + strokeWidth: 2, + }), + radialRule([reading], { + angle: 'value', + radius1: 0, + radius2: 0.64, + key: 'id', + stroke: 'currentColor', + strokeWidth: 4, + }), + radialDot([reading], { + angle: 'value', + radius: 0, + r: 8, + key: 'id', + fill: 'currentColor', + }), + radialText([reading], { + angle: 'value', + radius: 0, + text: 'label', + key: 'id', + dy: 34, + anchor: 'middle', + baseline: 'middle', + fill: 'currentColor', + fontSize: 18, + fontWeight: 700, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 0, + } +}) + +export const mount = tanstackMount(definition, 'Threshold gauge with needle', { + format: ({ datum }) => { + if ('data' in datum) { + return `${datum.data.label} · ${datum.data.value}% band` + } + return `Reading · ${datum.label}` + }, +}) diff --git a/benchmarks/conformance/cases/99-comparative-radar/case.json b/benchmarks/conformance/cases/99-comparative-radar/case.json new file mode 100644 index 0000000..09ea38f --- /dev/null +++ b/benchmarks/conformance/cases/99-comparative-radar/case.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "referenceRenderer": "recharts", + "order": 990, + "id": "99-comparative-radar", + "title": "Comparative radar chart", + "family": "polar", + "intent": "Compare current and target profiles across six qualitative dimensions on one fixed radial scale.", + "support": "native", + "features": [ + "polar coordinates", + "two grouped radial areas", + "polygon grid", + "radial tick labels", + "closed D3 curves", + "series visual channels" + ], + "geometry": [ + { "role": "radar", "count": 2, "maxCount": 2 }, + { "role": "text", "count": 11, "maxCount": 11 } + ], + "minimumGeometrySimilarity": 0.9999, + "source": { + "title": "Recharts Simple Radar Chart", + "url": "https://recharts.github.io/en-US/examples/SimpleRadarChart/" + }, + "ai": { + "create": "Create a comparative radar chart with two keyed six-point profiles, a fixed zero-to-one-hundred radial scale, polygon grid rings, and sparse angle labels.", + "maintain": "Update either profile while preserving metric order, series identity, the fixed radial domain, closed D3 curves, and shared guide geometry." + } +} diff --git a/benchmarks/conformance/cases/99-comparative-radar/data.ts b/benchmarks/conformance/cases/99-comparative-radar/data.ts new file mode 100644 index 0000000..24d8237 --- /dev/null +++ b/benchmarks/conformance/cases/99-comparative-radar/data.ts @@ -0,0 +1,55 @@ +export const radarMetrics = [ + 'Reliability', + 'Speed', + 'Coverage', + 'Quality', + 'Efficiency', + 'Growth', +] as const + +export type RadarMetric = (typeof radarMetrics)[number] +export type RadarSeries = 'Current' | 'Target' + +export interface ComparativeRadarDatum { + metric: RadarMetric + current: number + target: number +} + +export interface ComparativeRadarPoint { + metric: RadarMetric + value: number + series: RadarSeries +} + +const initialData: readonly ComparativeRadarDatum[] = [ + { metric: 'Reliability', current: 82, target: 94 }, + { metric: 'Speed', current: 76, target: 88 }, + { metric: 'Coverage', current: 68, target: 85 }, + { metric: 'Quality', current: 88, target: 92 }, + { metric: 'Efficiency', current: 72, target: 86 }, + { metric: 'Growth', current: 79, target: 90 }, +] + +export function comparativeRadarData( + revision = 0, +): readonly ComparativeRadarDatum[] { + if (revision % 2 === 0) return initialData + + return initialData.map((row) => + row.metric === 'Coverage' + ? { ...row, current: 77 } + : row.metric === 'Efficiency' + ? { ...row, current: 81 } + : row, + ) +} + +export function comparativeRadarPoints( + revision = 0, +): readonly ComparativeRadarPoint[] { + return comparativeRadarData(revision).flatMap((row) => [ + { metric: row.metric, value: row.current, series: 'Current' }, + { metric: row.metric, value: row.target, series: 'Target' }, + ]) +} diff --git a/benchmarks/conformance/cases/99-comparative-radar/recharts.ts b/benchmarks/conformance/cases/99-comparative-radar/recharts.ts new file mode 100644 index 0000000..43678df --- /dev/null +++ b/benchmarks/conformance/cases/99-comparative-radar/recharts.ts @@ -0,0 +1,64 @@ +import { createElement } from 'react' +import { + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, +} from 'recharts' +import { comparativeRadarData } from './data' +import { rechartsMount } from '../../shared/recharts-mount' +import type { ConformanceInput } from '../../types' + +function chart(input: ConformanceInput) { + return createElement( + RadarChart, + { + width: input.width, + height: input.height, + data: comparativeRadarData(input.revision), + outerRadius: '78%', + margin: { top: 20, right: 20, bottom: 20, left: 20 }, + accessibilityLayer: true, + }, + [ + createElement(PolarGrid, { + key: 'grid', + gridType: 'polygon', + stroke: '#cbd5e1', + }), + createElement(PolarAngleAxis, { + key: 'angle', + dataKey: 'metric', + }), + createElement(PolarRadiusAxis, { + key: 'radius', + domain: [0, 100], + ticks: [20, 40, 60, 80, 100], + angle: 30, + }), + createElement(Radar, { + key: 'current', + name: 'Current', + dataKey: 'current', + stroke: '#7c3aed', + strokeWidth: 2, + fill: '#7c3aed', + fillOpacity: 0.18, + isAnimationActive: false, + }), + createElement(Radar, { + key: 'target', + name: 'Target', + dataKey: 'target', + stroke: '#0ea5e9', + strokeWidth: 2, + fill: '#0ea5e9', + fillOpacity: 0.18, + isAnimationActive: false, + }), + ], + ) +} + +export const mount = rechartsMount(chart, 'Comparative radar chart') diff --git a/benchmarks/conformance/cases/99-comparative-radar/tanstack.ts b/benchmarks/conformance/cases/99-comparative-radar/tanstack.ts new file mode 100644 index 0000000..d70a6b2 --- /dev/null +++ b/benchmarks/conformance/cases/99-comparative-radar/tanstack.ts @@ -0,0 +1,95 @@ +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialArea, + radialGrid, +} from '@tanstack/charts/polar' +import { scaleLinear, scalePoint } from 'd3-scale' +import { curveLinearClosed } from 'd3-shape' +import { comparativeRadarPoints, radarMetrics } from './data' +import { tanstackMount } from '../../shared/mount' +import type { ComparativeRadarPoint, RadarSeries } from './data' +import type { ConformanceInput } from '../../types' +import type { PolarGuideLabelContext } from '@tanstack/charts/polar' + +const ringValues = [20, 40, 60, 80, 100] as const +const angleScale = scalePoint().domain(radarMetrics) +const radiusScale = scaleLinear().domain([0, 100]) +const seriesColors: Record = { + Current: '#7c3aed', + Target: '#0ea5e9', +} + +function angleLabelIsTopOrBottom(angle: number): boolean { + return Math.abs(Math.sin(angle)) <= Math.SQRT1_2 +} + +function angleLabelBaseline({ + angle, + y, +}: PolarGuideLabelContext): 'auto' | 'middle' | 'hanging' { + if (!angleLabelIsTopOrBottom(angle)) return 'middle' + return y > 0 ? 'hanging' : 'auto' +} + +function angleLabelDy({ angle, y }: PolarGuideLabelContext): number { + if (!angleLabelIsTopOrBottom(angle)) return 1.1 + return y > 0 ? -1.1 : 0 +} + +const definition = defineChart()(({ input }) => { + const points = comparativeRadarPoints(input.revision) + + return { + marks: [ + polar({ + angle: { scale: angleScale, wrap: true }, + radius: { scale: radiusScale }, + inset: 0, + radiusRatio: 0.78, + guides: [ + radialGrid({ + values: ringValues, + shape: 'polygon', + labels: true, + labelAngle: Math.PI / 3, + labelRotate: 60, + labelBaseline: 'auto', + labelFill: '#cccccc', + stroke: '#cbd5e1', + }), + angleGrid({ + values: radarMetrics, + labels: true, + labelOffset: 8, + labelBaseline: angleLabelBaseline, + labelDy: angleLabelDy, + labelFill: '#808080', + stroke: '#cbd5e1', + }), + ], + marks: [ + radialArea(points, { + angle: 'metric', + radius: 'value', + z: 'series', + key: 'metric', + className: 'ts-chart__radar', + curve: curveLinearClosed, + fill: (row: ComparativeRadarPoint) => seriesColors[row.series], + fillOpacity: 0.18, + stroke: (row: ComparativeRadarPoint) => seriesColors[row.series], + strokeWidth: 2, + }), + ], + }), + ], + x: null, + y: null, + guides: false, + margin: 20, + } +}) + +export const mount = tanstackMount(definition, 'Comparative radar chart') diff --git a/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts b/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts index 9ad5a80..7b92b29 100644 --- a/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts +++ b/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts @@ -37,7 +37,10 @@ const definition = defineChart()(({ input, width }) => { } }) -export const mount = tanstackMount(definition, 'Sorted vertical bars') +export const mount = tanstackMount(definition, 'Sorted vertical bars', { + format: ({ datum }) => + `${datum.category} · ${datum.value.toLocaleString('en-US')} total`, +}) function summarizeCategories(rows: readonly CategoryPoint[]) { return rollups( diff --git a/benchmarks/conformance/cases/histogram/tanstack.ts b/benchmarks/conformance/cases/histogram/tanstack.ts index 4d588ff..75510a8 100644 --- a/benchmarks/conformance/cases/histogram/tanstack.ts +++ b/benchmarks/conformance/cases/histogram/tanstack.ts @@ -60,4 +60,9 @@ const definition = defineChart()(({ input }) => { } }) -export const mount = tanstackMount(definition, 'Histogram of values') +export const mount = tanstackMount(definition, 'Histogram of values', { + format: ({ datum }) => + `${datum.x0.toLocaleString('en-US')}–${datum.x1.toLocaleString( + 'en-US', + )} · ${datum.count.toLocaleString('en-US')} observations`, +}) diff --git a/benchmarks/conformance/metadata.test.ts b/benchmarks/conformance/metadata.test.ts index 838655e..06d7b36 100644 --- a/benchmarks/conformance/metadata.test.ts +++ b/benchmarks/conformance/metadata.test.ts @@ -19,6 +19,18 @@ const baseMetadata = { } as const describe('conformance metadata', () => { + it('accepts arc geometry for polar sector comparisons', () => { + expect( + parseConformanceCaseMeta( + { + ...baseMetadata, + geometry: [{ role: 'arc', count: 4, maxCount: 4 }], + }, + 'case.json', + ).geometry, + ).toEqual([{ role: 'arc', count: 4, maxCount: 4 }]) + }) + it('accepts ECharts and ordered semantic interaction scenarios', () => { expect( parseConformanceCaseMeta( diff --git a/benchmarks/conformance/metadata.ts b/benchmarks/conformance/metadata.ts index 8c633a6..83b7e36 100644 --- a/benchmarks/conformance/metadata.ts +++ b/benchmarks/conformance/metadata.ts @@ -558,6 +558,7 @@ function isGeometryRole( value: unknown, ): value is ConformanceCaseMeta['geometry'][number]['role'] { return ( + value === 'arc' || value === 'area' || value === 'arrow' || value === 'bar' || diff --git a/benchmarks/conformance/shared/mount.test.ts b/benchmarks/conformance/shared/mount.test.ts new file mode 100644 index 0000000..3fcc49d --- /dev/null +++ b/benchmarks/conformance/shared/mount.test.ts @@ -0,0 +1,63 @@ +import { defineChart, dot } from '@tanstack/charts' +import { scaleLinear } from 'd3-scale' +import { describe, expect, it } from 'vitest' +import type { ConformanceInput } from '../types' +import { tanstackMount } from './mount' + +type Point = { + id: string + x: number + y: number +} + +const rows: Point[] = [{ id: 'a', x: 1, y: 2 }] +const definition = defineChart()(() => ({ + marks: [ + dot(rows, { + x: 'x', + y: 'y', + key: 'id', + }), + ], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, +})) + +describe('tanstackMount', () => { + it('keeps benchmark comparisons passive', () => { + const container = document.createElement('div') + const handle = tanstackMount(definition, 'Passive chart')(container, { + width: 320, + height: 180, + revision: 0, + }) + const svg = container.querySelector('svg') + + expect(svg?.getAttribute('tabindex')).toBe('-1') + svg?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(container.querySelector('.ts-chart-tooltip')).toBeNull() + + handle.destroy() + }) + + it('enables keyboard focus and tooltips for embeds', () => { + const container = document.createElement('div') + const handle = tanstackMount(definition, 'Interactive chart', { + format: (point) => `Point ${point.datum.id}`, + })(container, { + width: 320, + height: 180, + revision: 0, + interactive: true, + }) + const svg = container.querySelector('svg') + + expect(svg?.getAttribute('tabindex')).toBe('0') + svg?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(container.querySelector('.ts-chart-tooltip')?.textContent).toBe( + 'Point a', + ) + + handle.destroy() + }) +}) diff --git a/benchmarks/conformance/shared/mount.ts b/benchmarks/conformance/shared/mount.ts index f580030..45aa505 100644 --- a/benchmarks/conformance/shared/mount.ts +++ b/benchmarks/conformance/shared/mount.ts @@ -1,5 +1,8 @@ import { mountChart } from '@tanstack/charts' -import type { DynamicChartDefinition } from '@tanstack/charts' +import type { + ChartTooltipOptions, + DynamicChartDefinition, +} from '@tanstack/charts' import type { ConformanceHandle, ConformanceInput, @@ -33,6 +36,7 @@ export function tanstackMount( TDatum >, ariaLabel: string, + interactiveTooltip: true | ChartTooltipOptions = true, ): ConformanceMount { return (container, input) => { const options = { @@ -42,7 +46,8 @@ export function tanstackMount( height: input.height, ariaLabel, animate: false, - keyboard: false, + keyboard: input.interactive === true, + tooltip: input.interactive === true ? interactiveTooltip : false, } as const const host = mountChart(container, options) @@ -53,6 +58,8 @@ export function tanstackMount( input: nextInput, width: nextInput.width, height: nextInput.height, + keyboard: nextInput.interactive === true, + tooltip: nextInput.interactive === true ? interactiveTooltip : false, }) }, destroy() { diff --git a/benchmarks/conformance/types.ts b/benchmarks/conformance/types.ts index 5fa00ec..c128c48 100644 --- a/benchmarks/conformance/types.ts +++ b/benchmarks/conformance/types.ts @@ -6,6 +6,7 @@ export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack' export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred' export type ConformanceGeometryRole = + | 'arc' | 'area' | 'arrow' | 'bar' @@ -33,6 +34,7 @@ export interface ConformanceInput { width: number height: number revision: number + interactive?: boolean } export interface ConformanceHandle { diff --git a/benchmarks/entries/charts-canvas.ts b/benchmarks/entries/charts-canvas.ts new file mode 100644 index 0000000..17800ec --- /dev/null +++ b/benchmarks/entries/charts-canvas.ts @@ -0,0 +1 @@ +export { mountCanvasChart } from '@tanstack/charts/canvas' diff --git a/benchmarks/entries/charts-geo-svg.ts b/benchmarks/entries/charts-geo-svg.ts new file mode 100644 index 0000000..53e9ba0 --- /dev/null +++ b/benchmarks/entries/charts-geo-svg.ts @@ -0,0 +1,73 @@ +import { geoShape } from '@tanstack/charts/geo' +import { createChartScene, defineChart } from '@tanstack/charts/scene' +import { renderChartSvg } from '@tanstack/charts/svg' +import { geoIdentity } from 'd3-geo' + +const regions = [ + { + type: 'Feature' as const, + properties: { id: 'west' }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [0, 0], + [40, 0], + [40, 40], + [0, 40], + [0, 0], + ], + ], + }, + }, + { + type: 'Feature' as const, + properties: { id: 'east' }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [50, 0], + [100, 0], + [100, 40], + [50, 40], + [50, 0], + ], + ], + }, + }, +] + +const collection = { + type: 'FeatureCollection' as const, + features: regions, +} + +const definition = defineChart({ + marks: [ + geoShape(regions, { + key: (region) => region.properties.id, + projection: ({ chart }) => + geoIdentity().fitExtent( + [ + [chart.x, chart.y], + [chart.x + chart.width, chart.y + chart.height], + ], + collection, + ), + fill: '#2563eb', + stroke: '#ffffff', + strokeWidth: 1, + }), + ], + guides: false, + margin: 8, + x: null, + y: null, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Projected regions', + }) +} diff --git a/benchmarks/entries/charts-polar-arc-svg.ts b/benchmarks/entries/charts-polar-arc-svg.ts new file mode 100644 index 0000000..9f3a0b3 --- /dev/null +++ b/benchmarks/entries/charts-polar-arc-svg.ts @@ -0,0 +1,42 @@ +import { polar, radialArc } from '@tanstack/charts/polar' +import { createChartScene, defineChart } from '@tanstack/charts/scene' +import { renderChartSvg } from '@tanstack/charts/svg' + +const arcs = [ + { + id: 'ingest', + startAngle: 0, + endAngle: Math.PI * 0.82, + fill: '#2563eb', + }, + { + id: 'query', + startAngle: Math.PI * 0.82, + endAngle: Math.PI * 2, + fill: '#7c3aed', + }, +] + +const definition = defineChart({ + marks: [ + polar({ + marks: [ + radialArc(arcs, { + key: 'id', + startAngle: 'startAngle', + endAngle: 'endAngle', + fill: (row) => row.fill, + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Polar arcs', + }) +} diff --git a/benchmarks/entries/charts-polar-gauge-svg.ts b/benchmarks/entries/charts-polar-gauge-svg.ts new file mode 100644 index 0000000..fd815da --- /dev/null +++ b/benchmarks/entries/charts-polar-gauge-svg.ts @@ -0,0 +1,82 @@ +import { + polar, + radialArc, + radialDot, + radialRule, + radialText, +} from '@tanstack/charts/polar' +import { createChartScene, defineChart } from '@tanstack/charts/scene' +import { renderChartSvg } from '@tanstack/charts/svg' +import { scaleLinear } from 'd3-scale' + +const startAngle = -Math.PI * 0.75 +const endAngle = Math.PI * 0.75 +const value = 72 +const bands = [ + { + id: 'healthy', + startAngle, + endAngle: startAngle + (endAngle - startAngle) * 0.6, + fill: '#22c55e', + }, + { + id: 'warning', + startAngle: startAngle + (endAngle - startAngle) * 0.6, + endAngle: startAngle + (endAngle - startAngle) * 0.85, + fill: '#f59e0b', + }, + { + id: 'critical', + startAngle: startAngle + (endAngle - startAngle) * 0.85, + endAngle, + fill: '#ef4444', + }, +] + +const definition = defineChart({ + marks: [ + polar({ + startAngle, + endAngle, + angle: { scale: scaleLinear().domain([0, 100]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + marks: [ + radialArc(bands, { + key: 'id', + startAngle: 'startAngle', + endAngle: 'endAngle', + innerRadius: ({ radius }) => radius * 0.72, + cornerRadius: 3, + fill: (band) => band.fill, + }), + radialRule([{ value }], { + angle: 'value', + radius1: 0, + radius2: 0.68, + strokeWidth: 3, + }), + radialDot([{ value, radius: 0 }], { + angle: 'value', + radius: 'radius', + r: 5, + }), + radialText([{ value: 50, radius: 0.34, text: `${value}%` }], { + angle: 'value', + radius: 'radius', + text: 'text', + fontSize: 18, + fontWeight: 700, + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Threshold gauge', + }) +} diff --git a/benchmarks/entries/charts-polar-line-scatter-svg.ts b/benchmarks/entries/charts-polar-line-scatter-svg.ts new file mode 100644 index 0000000..20e8e40 --- /dev/null +++ b/benchmarks/entries/charts-polar-line-scatter-svg.ts @@ -0,0 +1,57 @@ +import { + angleGrid, + polar, + radialDot, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { createChartScene, defineChart } from '@tanstack/charts/scene' +import { renderChartSvg } from '@tanstack/charts/svg' +import { scaleLinear, scalePoint } from 'd3-scale' +import { curveLinearClosed } from 'd3-shape' + +const metrics = ['latency', 'errors', 'traffic', 'saturation'] as const +const samples = [ + { metric: 'latency', value: 72, observation: 68 }, + { metric: 'errors', value: 44, observation: 51 }, + { metric: 'traffic', value: 86, observation: 79 }, + { metric: 'saturation', value: 63, observation: 70 }, +] as const + +const definition = defineChart({ + marks: [ + polar({ + angle: { scale: scalePoint().domain(metrics), wrap: true }, + radius: { scale: scaleLinear().domain([0, 100]) }, + guides: [ + radialGrid({ values: [25, 50, 75, 100] }), + angleGrid({ values: metrics }), + ], + marks: [ + radialLine(samples, { + angle: 'metric', + radius: 'value', + key: 'metric', + curve: curveLinearClosed, + stroke: '#2563eb', + }), + radialDot(samples, { + angle: 'metric', + radius: 'observation', + key: 'metric', + r: 4, + fill: '#f97316', + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Polar line and scatter composition', + }) +} diff --git a/benchmarks/entries/charts-polar-pie-svg.ts b/benchmarks/entries/charts-polar-pie-svg.ts new file mode 100644 index 0000000..0e6b124 --- /dev/null +++ b/benchmarks/entries/charts-polar-pie-svg.ts @@ -0,0 +1,40 @@ +import { polar, radialArc } from '@tanstack/charts/polar' +import { createChartScene, defineChart } from '@tanstack/charts/scene' +import { renderChartSvg } from '@tanstack/charts/svg' +import { pie } from 'd3-shape' + +const rows = [ + { id: 'ingest', value: 42, fill: '#2563eb' }, + { id: 'query', value: 28, fill: '#7c3aed' }, + { id: 'alerts', value: 18, fill: '#db2777' }, + { id: 'other', value: 12, fill: '#f59e0b' }, +] + +const slices = pie<(typeof rows)[number]>() + .sort(null) + .value((row) => row.value)(rows) + +const definition = defineChart({ + marks: [ + polar({ + marks: [ + radialArc(slices, { + key: (slice) => slice.data.id, + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + fill: (slice) => slice.data.fill, + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Pie chart', + }) +} diff --git a/benchmarks/entries/charts-react-canvas.ts b/benchmarks/entries/charts-react-canvas.ts new file mode 100644 index 0000000..b15bcdf --- /dev/null +++ b/benchmarks/entries/charts-react-canvas.ts @@ -0,0 +1 @@ +export { Chart } from '@tanstack/react-charts/canvas' diff --git a/benchmarks/entries/charts-react-core.ts b/benchmarks/entries/charts-react-core.ts new file mode 100644 index 0000000..0f9ed29 --- /dev/null +++ b/benchmarks/entries/charts-react-core.ts @@ -0,0 +1 @@ +export { Chart } from '@tanstack/react-charts/core' diff --git a/benchmarks/entries/charts-renderer.ts b/benchmarks/entries/charts-renderer.ts new file mode 100644 index 0000000..d58b50a --- /dev/null +++ b/benchmarks/entries/charts-renderer.ts @@ -0,0 +1 @@ +export { mountChartRenderer } from '@tanstack/charts/renderer' diff --git a/deploy/catalog/_headers b/deploy/catalog/_headers new file mode 100644 index 0000000..1f35fe9 --- /dev/null +++ b/deploy/catalog/_headers @@ -0,0 +1,16 @@ +/charts/catalog/* + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + X-XSS-Protection: 1; mode=block + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=() + Strict-Transport-Security: max-age=31536000; includeSubDomains + +/charts/catalog/embed/* + ! X-Frame-Options + +/charts/catalog/assets/* + Cache-Control: public, max-age=31536000, immutable + +https://:version.:subdomain.workers.dev/* + X-Robots-Tag: noindex diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 296664c..1979bee 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -186,6 +186,31 @@ For a normal cartesian chart: See [Scales and D3](./scales-and-d3.md) for the complete ownership boundary and pixel-to-value inversion. +## Non-cartesian coordinates + +Polar and geographic marks resolve geometry from the same final +`scene.chart` bounds without materializing Cartesian x/y channels: + +```ts +import { polar, radialArc } from '@tanstack/charts/polar' +import { geoShape } from '@tanstack/charts/geo' +``` + +`polar` copies configured angle and radius scales, assigns responsive angular +and radial ranges, and renders guide backgrounds, child marks, then guide +foregrounds around one resolved center. `geoShape` calls an +application-supplied D3 projection factory with the final plot bounds. + +Both paths emit the same keyed scene nodes and interaction points as ordinary +marks. SVG rendering, DOM reconciliation, focus, export, and adapters do not +need a coordinate-system branch. Their outer chart uses `x: null`, `y: null`, +and `guides: false`. + +These capabilities stay behind separate package subpaths so their D3 geometry +does not enter a Cartesian consumer. See +[Polar and Radar Charts](../examples/polar-and-radar.md) and +[Maps and Spatial Charts](../examples/maps-and-spatial.md). + ## Band alignment A D3 band scale returns the start of a band. TanStack Charts centers the resolved positional value: diff --git a/docs/concepts/marks-and-layering.md b/docs/concepts/marks-and-layering.md index 6002085..790560b 100644 --- a/docs/concepts/marks-and-layering.md +++ b/docs/concepts/marks-and-layering.md @@ -20,8 +20,13 @@ A mark turns data and channel values into renderer-neutral scene nodes. Marks ar | Compact distribution glyph | `tickX`, `tickY` | | Plot frame | `frame` | | Small-multiple composition | `facet`, `facetChart` | +| Pie, donut, gauge, or cyclic profile | `polar` and radial marks | +| Projected GeoJSON | `geoShape` | -Start from the analytical question in [Choosing a Chart](../guides/choosing-a-chart.md). The [Mark Reference](../reference/marks/line-and-area.md) lists every channel and style option. +Start from the analytical question in +[Choosing a Chart](../guides/choosing-a-chart.md). The +[Mark Reference](../reference/index.md#mark-reference) lists every channel and +style option. Polar and geographic marks use explicit capability subpaths. ## Layer order is declaration order diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index 55684e8..11b28c7 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -28,23 +28,23 @@ This rule applies when definitions live in React or Octane source as well. The f Use the official D3 pages as the API reference for each algorithm. TanStack Charts documentation only describes how its output crosses the chart boundary. -| Need | D3 module | How it enters TanStack Charts | -| ------------------------------------------------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| Quantitative, temporal, categorical, log, radial, and color scales | [`d3-scale`](https://d3js.org/d3-scale) | Pass the configured scale to an axis, `color.scale`, `rScale`, or a custom mark | -| Sequential, diverging, and categorical color schemes | [`d3-scale-chromatic`](https://d3js.org/d3-scale-chromatic) | Pass an interpolator or scheme to a configured D3 color scale | -| Extents, grouping, aggregation, bins, sorting, and statistics | [`d3-array`](https://d3js.org/d3-array) | Convert source data into rows, domains, or thresholds before creating marks | -| Stacks, pies, arcs, curves, and shape generators | [`d3-shape`](https://d3js.org/d3-shape) | Feed generated interval rows to marks, use output in a custom mark, or bridge a curve with `d3Curve` | -| Calendar intervals | [`d3-time`](https://d3js.org/d3-time) | Build bins, ticks, rounded selections, and date windows in application code | -| Numeric formatting | [`d3-format`](https://d3js.org/d3-format) | Pass a formatter to an axis or tooltip option | -| Time formatting | [`d3-time-format`](https://d3js.org/d3-time-format) | Pass a formatter to an axis or tooltip option | -| Quadtrees | [`d3-quadtree`](https://d3js.org/d3-quadtree) | Implement an optional `ChartSpatialIndexFactory` | -| Delaunay and Voronoi geometry | [`d3-delaunay`](https://d3js.org/d3-delaunay) | Implement a spatial index, overlay, or custom mark | -| DOM selection for optional D3 gesture controllers | [`d3-selection`](https://d3js.org/d3-selection) | Attach an application-owned brush or zoom behavior to an overlay | -| Brushes | [`d3-brush`](https://d3js.org/d3-brush) | Own the gesture in application code and map pixels through a copied chart scale | -| Pan and zoom | [`d3-zoom`](https://d3js.org/d3-zoom) | Own the gesture and update chart input or a configured scale domain | -| Hierarchies and layouts | [`d3-hierarchy`](https://d3js.org/d3-hierarchy) | Convert layout output into ordinary rows or custom scene nodes | -| Force simulation | [`d3-force`](https://d3js.org/d3-force) | Prepare positioned nodes and links before rendering | -| Geographic projections and paths | [`d3-geo`](https://d3js.org/d3-geo) | Generate projected geometry for custom marks or renderers | +| Need | D3 module | How it enters TanStack Charts | +| ------------------------------------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Quantitative, temporal, categorical, log, radial, and color scales | [`d3-scale`](https://d3js.org/d3-scale) | Pass the configured scale to an axis, polar coordinate, `color.scale`, `rScale`, or custom mark | +| Sequential, diverging, and categorical color schemes | [`d3-scale-chromatic`](https://d3js.org/d3-scale-chromatic) | Pass an interpolator or scheme to a configured D3 color scale | +| Extents, grouping, aggregation, bins, sorting, and statistics | [`d3-array`](https://d3js.org/d3-array) | Convert source data into rows, domains, or thresholds before creating marks | +| Stacks, pies, arcs, curves, and shape generators | [`d3-shape`](https://d3js.org/d3-shape) | Feed pie intervals and curve factories to polar marks, or bridge a Cartesian curve with `d3Curve` | +| Calendar intervals | [`d3-time`](https://d3js.org/d3-time) | Build bins, ticks, rounded selections, and date windows in application code | +| Numeric formatting | [`d3-format`](https://d3js.org/d3-format) | Pass a formatter to an axis or tooltip option | +| Time formatting | [`d3-time-format`](https://d3js.org/d3-time-format) | Pass a formatter to an axis or tooltip option | +| Quadtrees | [`d3-quadtree`](https://d3js.org/d3-quadtree) | Implement an optional `ChartSpatialIndexFactory` | +| Delaunay and Voronoi geometry | [`d3-delaunay`](https://d3js.org/d3-delaunay) | Implement a spatial index, overlay, or custom mark | +| DOM selection for optional D3 gesture controllers | [`d3-selection`](https://d3js.org/d3-selection) | Attach an application-owned brush or zoom behavior to an overlay | +| Brushes | [`d3-brush`](https://d3js.org/d3-brush) | Own the gesture in application code and map pixels through a copied chart scale | +| Pan and zoom | [`d3-zoom`](https://d3js.org/d3-zoom) | Own the gesture and update chart input or a configured scale domain | +| Hierarchies and layouts | [`d3-hierarchy`](https://d3js.org/d3-hierarchy) | Convert layout output into ordinary rows or custom scene nodes | +| Force simulation | [`d3-force`](https://d3js.org/d3-force) | Prepare positioned nodes and links before rendering | +| Geographic projections and paths | [`d3-geo`](https://d3js.org/d3-geo) | Pass a responsive projection factory to `geoShape` | ## Positional scales are required @@ -246,7 +246,11 @@ const histogram = bin() Pass `histogram` to `rect`, `barY`, `lineY`, `dot`, or a custom mark according to the desired geometry. In a dynamic definition, put substantial synchronous transformation in `prepare` and define `prepareEqual` around the inputs that affect it. -The same rule applies to stacks, pies, hierarchies, force layouts, geographic projections, and server-prepared intervals: preserve the useful output as typed rows, then map it through ordinary channels. +The same rule applies to stacks, pies, hierarchies, force layouts, and +server-prepared intervals: preserve the useful output as typed rows, then map +it through mark channels. A responsive geographic projection instead belongs +in `geoShape`'s projection factory because its pixel range depends on the final +plot bounds. ## Pixel-to-value inversion diff --git a/docs/config.json b/docs/config.json index 2950645..638e89b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -307,6 +307,14 @@ { "label": "Text, Frame, and Facet", "to": "reference/marks/text-frame-and-facet" + }, + { + "label": "Geo Shape Mark", + "to": "reference/marks/geo" + }, + { + "label": "Polar Marks", + "to": "reference/marks/polar" } ] } diff --git a/docs/examples/maps-and-spatial.md b/docs/examples/maps-and-spatial.md index cd1d10c..fbd02b6 100644 --- a/docs/examples/maps-and-spatial.md +++ b/docs/examples/maps-and-spatial.md @@ -1,6 +1,6 @@ --- title: Maps and Spatial Charts -description: Choose choropleths, point layers, and vector fields for geographic or projected spatial questions. +description: Build choropleths, bubble maps, globes, routes, and vector fields for geographic or projected spatial questions. --- Spatial charts encode values in a coordinate system whose geometry already has @@ -18,9 +18,9 @@ ordinary categorical chart with decorative shapes. | How does a path move through space? | Projected line with directional context | | Must small regions be compared precisely by value? | Sorted bars or a table beside the map | -Projection, geographic path generation, containment, and spatial sampling are -application-owned algorithms. Their output enters TanStack Charts through -typed custom marks or ordinary projected rows. See +The application chooses the projection and spatial sampling policy. The +opt-in `@tanstack/charts/geo` entry uses `d3-geo` to turn GeoJSON into shared +scene paths and interaction points. See [Scales and D3](../concepts/scales-and-d3.md). ## Compare regional aggregates @@ -37,6 +37,33 @@ value through a color scale. style="width:100%;height:460px;border:0;" > + + + + + + The join is part of data preparation. Match features through stable IDs, report unmatched records, and distinguish missing values from zero. Keep the color domain and legend explicit so one filtered region cannot silently rescale the @@ -49,6 +76,172 @@ part of the task. [Legends and Color](../guides/legends-and-color.md) covers sequential, diverging, and threshold choices. +## Prepare atlas boundaries + +TanStack accepts GeoJSON and does not bundle a political boundary dataset. +Convert an application-owned TopoJSON atlas once, validate its feature count +and IDs, then keep that geometry stable while values change. + + + +```ts +import { feature } from 'topojson-client' +import worldAtlas from 'world-atlas/countries-110m.json' +import type { + GeometryCollection, + Objects, + Topology, +} from 'topojson-specification' + +interface AtlasProperties { + name: string +} + +type WorldObjects = Objects & { + countries: GeometryCollection +} + +const topology = worldAtlas as unknown as Topology +const countries = feature(topology, topology.objects.countries) +``` + +`world-atlas` redistributes Natural Earth boundaries; `us-atlas` provides +Census-derived state geometry. `topojson-client.feature` performs the standard +conversion to GeoJSON consumed by `geoShape`. These remain application +dependencies, so neither their data nor the converter enters ordinary +Cartesian or geo-only consumer bundles. + +## Project GeoJSON responsively + +Create the D3 projection inside `projection`. Its context contains the final +plot bounds, so `fitExtent` reruns correctly when the chart resizes. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { geoEqualEarth } from 'd3-geo' + +const regions = [ + { + type: 'Feature' as const, + properties: { id: 'west', errorRate: 3.2 }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [-120, 30], + [-100, 30], + [-100, 46], + [-120, 46], + [-120, 30], + ], + ], + }, + }, + { + type: 'Feature' as const, + properties: { id: 'east', errorRate: 8.7 }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [-98, 30], + [-72, 30], + [-72, 46], + [-98, 46], + [-98, 30], + ], + ], + }, + }, +] + +const collection = { + type: 'FeatureCollection' as const, + features: regions, +} + +const map = defineChart({ + marks: [ + geoShape(regions, { + key: (region) => region.properties.id, + projection: ({ chart }) => + geoEqualEarth().fitExtent( + [ + [chart.x, chart.y], + [chart.x + chart.width, chart.y + chart.height], + ], + collection, + ), + fill: (region) => + region.properties.errorRate >= 5 ? '#ef4444' : '#fbbf24', + stroke: '#ffffff', + strokeWidth: 0.75, + }), + ], + guides: false, + x: null, + y: null, +}) +``` + +`geoShape` uses `geoPath` for each feature and D3 centroids for focus. Supply +`anchor` only when a feature needs a different semantic longitude/latitude +than its spherical centroid. The complete option contract is in +[Geo Shape Mark](../reference/marks/geo.md). + +## Project points by magnitude + +Point and MultiPoint features use D3 `geoPath().pointRadius()`. The `r` +channel can carry pixels directly; `rScale` maps a quantitative value first. + + + +## Change the projection + +The same GeoJSON can use any D3 projection factory. Sphere and graticule +geometry are ordinary `geoShape` layers. + + + + + +## Layer routes over geography + +Polygon, LineString, and Point features can share one responsive projection. + + + ## Show direction and magnitude A vector field places an arrow at each sampled position. Direction uses angle; @@ -78,7 +271,7 @@ resolved plot bounds, not the viewport, and recompute when the chart container changes. Preserve source longitude and latitude alongside projected coordinates for tooltips and selection. -When one custom spatial shape must render inside the shared scene: +When a custom spatial shape must render beside `geoShape`: - Keep path generation deterministic and DOM-free. - Emit stable scene keys for each feature. diff --git a/docs/examples/polar-and-radar.md b/docs/examples/polar-and-radar.md index 7b3c295..5b25070 100644 --- a/docs/examples/polar-and-radar.md +++ b/docs/examples/polar-and-radar.md @@ -1,113 +1,450 @@ --- title: Polar and Radar Charts -description: Decide when cyclic, radial, radar, or parallel-coordinate layouts clarify multivariate data. +description: Build D3-backed pie, donut, gauge, radar, line, scatter, radial bar, rose, and sunburst charts through the opt-in polar coordinate entry. --- -Polar layouts place angle and radius where cartesian charts use x and y. They -can clarify genuinely cyclic domains and compact multivariate profiles, but -they also make length, angle, and area harder to compare. +Polar geometry is available only from `@tanstack/charts/polar`. The container +owns responsive center, angle, and radius ranges; granular D3 modules still +own pie layout, configured scales, and curve factories. -Use them deliberately. A radial shape is not automatically more informative -than aligned bars, dots, or small multiples. +```ts +import { + angleGrid, + polar, + radialArc, + radialArea, + radialDot, + radialGrid, + radialLine, + radialRule, + radialText, +} from '@tanstack/charts/polar' +``` -## Choose the profile view +The package root stays Cartesian-sized when this subpath is not imported. -| Reader question | Start with | -| ----------------------------------------------------------------- | -------------------------------------- | -| How does one profile vary across a small set of fixed dimensions? | Radar chart | -| How do several entities compare across many ordered dimensions? | Parallel coordinates | -| Does a measure repeat around a natural cycle? | Polar line, area, or radial bars | -| Must values be compared precisely across dimensions? | Aligned bars, dots, or small multiples | -| Does angle itself have no semantic meaning? | Stay cartesian | +## Pie and donut -Keep every dimension's domain and direction explicit. A shape can change -dramatically when one axis is reversed or rescaled. +Use `d3-shape`'s `pie` transform to turn totals into angular intervals. +`radialArc` renders the intervals. A zero inner radius is a pie; a responsive +nonzero inner radius is a donut. -## Compare one compact profile + -A radar chart maps each qualitative dimension to an equally spaced angle and -its value to radius. The filled polygon makes the overall profile visible. +```ts +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' + +interface Part { + id: string + value: number + fill: string +} + +const parts: Part[] = [ + { id: 'ingest', value: 42, fill: '#2563eb' }, + { id: 'query', value: 28, fill: '#7c3aed' }, + { id: 'alerts', value: 18, fill: '#db2777' }, + { id: 'other', value: 12, fill: '#f59e0b' }, +] + +const slices = pie() + .sort(null) + .value((part) => part.value)(parts) + +function ring(innerRatio: number) { + return polar({ + inset: 8, + radiusRatio: 0.82, + marks: [ + radialArc(slices, { + key: (slice) => slice.data.id, + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + innerRadius: ({ radius }) => radius * innerRatio, + cornerRadius: 4, + fill: (slice) => slice.data.fill, + }), + ], + }) +} + +const pieChart = defineChart({ + marks: [ring(0)], + guides: false, + x: null, + y: null, +}) + +const donutChart = defineChart({ + marks: [ring(0.58)], + guides: false, + x: null, + y: null, +}) +``` + + + +The same primitives cover labels, center content, padding, rounded corners, +and concentric rings: + + + + + + + + + +Keep `pie().sort(null)` when source order is semantic. Stable arc keys must +come from the original row, not the generated slice index. + +## Partial-circle gauge + +A gauge is the same composition over a restricted D3 pie interval. It is not +a separate geometry implementation. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' + +const value = 72 +const gaugeParts = [ + { id: 'value', value, fill: '#ef4444' }, + { id: 'remainder', value: 100 - value, fill: '#e2e8f0' }, +] +const gaugeSlices = pie<(typeof gaugeParts)[number]>() + .sort(null) + .value((part) => part.value) + .startAngle(-Math.PI * 0.75) + .endAngle(Math.PI * 0.75)(gaugeParts) + +const gauge = defineChart({ + marks: [ + polar({ + radiusRatio: 0.84, + marks: [ + radialArc(gaugeSlices, { + key: (slice) => slice.data.id, + startAngle: 'startAngle', + endAngle: 'endAngle', + innerRadius: ({ radius }) => radius * 0.72, + cornerRadius: 999, + fill: (slice) => slice.data.fill, + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` + + + + -Use radar for a small, fixed dimension set with compatible scales. Start each -radial axis at a truthful baseline, show reference rings, and label every -dimension directly. +Bound the input before layout and expose the exact value outside the arc. Arc +length is useful for a compact status summary, not fine comparison. -Polygon area is visually prominent but not a simple sum of the values. Do not -rank profiles by apparent filled area. If two or more profiles overlap, keep -fills translucent and provide a direct or textual comparison. +## Radar profile -## Compare many multivariate rows +Radar combines configured angle and radius scales with polar guides and radial +marks. TanStack copies both scales and supplies responsive ranges; the source +scales keep their semantic domains. -Parallel coordinates keep each dimension as a straight axis and draw one path -per entity. They are not polar, but they are often the clearer alternative -when several multivariate profiles must be compared. + + +```ts +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialArea, + radialDot, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { scaleBand, scaleLinear } from 'd3-scale' +import { curveLinearClosed } from 'd3-shape' + +const metrics = [ + 'Latency', + 'Errors', + 'Traffic', + 'Saturation', + 'Availability', +] as const +const profile = metrics.map((metric, index) => ({ + metric, + score: [74, 46, 88, 61, 93][index] ?? 0, +})) + +const radar = defineChart({ + marks: [ + polar({ + radiusRatio: 0.72, + angle: { scale: scaleBand().domain(metrics) }, + radius: { scale: scaleLinear().domain([0, 100]).nice(4) }, + guides: [ + radialGrid({ ticks: 4, shape: 'polygon', labels: false }), + angleGrid({ + labels: true, + labelDx: ({ x }) => (x < -1 ? -3 : x > 1 ? 3 : 0), + labelDy: ({ y }) => (y < -1 ? -2 : y > 1 ? 2 : 0), + }), + ], + marks: [ + radialArea(profile, { + angle: 'metric', + radius: 'score', + curve: curveLinearClosed, + fill: '#7c3aed', + fillOpacity: 0.22, + }), + radialLine(profile, { + angle: 'metric', + radius: 'score', + curve: curveLinearClosed, + stroke: '#8b5cf6', + strokeWidth: 2, + }), + radialDot(profile, { + angle: 'metric', + radius: 'score', + key: 'metric', + r: 3, + fill: '#8b5cf6', + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` -Parallel coordinates preserve a separate readable axis for every dimension, -but path crossings depend on axis order. Keep order intentional and stable. -Use selection or filtering before rendering a high-cardinality dataset; an -opaque bundle of lines does not support precise comparison. + + +Use radar for a small, fixed set of compatible dimensions. Keep every domain +and direction explicit, and do not rank profiles by apparent filled area. + +## Numeric polar line and scatter + +Continuous D3 scales map numeric angle and radius values without changing the +mark API. Repeat the first row at the angular endpoint when a line should close; +independent scatter observations need no seam row. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialDot, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' + +const trajectory = [ + { id: '0', angle: 0, radius: 52 }, + { id: '90', angle: 90, radius: 76 }, + { id: '180', angle: 180, radius: 43 }, + { id: '270', angle: 270, radius: 68 }, + { id: '360', angle: 360, radius: 52 }, +] +const observations = [ + { id: 'a', angle: 34, radius: 61 }, + { id: 'b', angle: 142, radius: 82 }, + { id: 'c', angle: 248, radius: 47 }, +] + +const numericPolar = defineChart({ + marks: [ + polar({ + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([0, 100]) }, + guides: [ + radialGrid({ values: [25, 50, 75, 100] }), + angleGrid({ values: [0, 90, 180, 270], labels: false }), + ], + marks: [ + radialLine(trajectory, { + angle: 'angle', + radius: 'radius', + key: 'id', + stroke: '#0f766e', + }), + radialDot(observations, { + angle: 'angle', + radius: 'radius', + key: 'id', + r: 4.5, + fill: '#e11d48', + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` -## Scale dimensions deliberately + -Dimensions with different units need separate normalization policies. Store -the original value and unit alongside any normalized radius or axis position. -The chart should expose original values in tooltips, tables, and accessible -descriptions. + -For each dimension decide: +## Radial magnitude and hierarchy -- Domain and baseline -- Whether larger always means better -- Tick or ring values -- Formatter and unit -- Missing-value treatment -- Stable order around the circle or across axes +`radialArc.generator` accepts responsive D3 arc accessors, so variable-radius +sectors, concentric radial bars, and hierarchy rings remain one primitive. -[Scales and D3](../concepts/scales-and-d3.md) owns scale construction. The -application should prepare any normalized dimension rows before marks render. + -## Geometry and labels + + + -Radar and other polar geometry usually need a custom mark or a composed scene: +## Coordinate and bundle boundary -- Convert each dimension index to an angle. -- Map each value through its radial scale. -- Close the profile polygon intentionally. -- Keep grid rings and spokes decorative rather than focusable data. -- Emit interaction points at semantic dimension values. -- Reserve enough responsive space for labels around every edge. +`polar()` is a positionless container mark. It resolves one center and radius, +copies configured angle/radius scales, paints guide backgrounds, child marks, +then guide foreground labels, and emits ordinary scene nodes and focus points. +The outer chart therefore uses `x: null`, `y: null`, and `guides: false`. -Use [Custom Marks and Renderers](../guides/custom-marks-and-renderers.md) for -the extension boundary and -[Layout, Axes, and Coordinates](../concepts/layout-axes-and-coordinates.md) for -responsive bounds. +The polar entry uses D3 arc and radial path generators internally. Application +source imports `pie`, configured scales, and curve factories directly from +`d3-shape` or `d3-scale`. See +[Polar Marks](../reference/marks/polar.md) for the complete API and +[Bundle Size and Performance](../guides/bundle-size-and-performance.md) for +the isolated consumer budgets. ## Production checks -- Use angle only for cyclic order or a small fixed dimension sequence. -- Keep every dimension domain, direction, and unit explicit. -- Preserve original values when rendering normalized positions. -- Avoid misleading polygon-area comparisons. -- Test long labels around the complete circumference. -- Provide exact values through a table or textual summary. -- Limit overlapping profiles and high-cardinality paths. -- Verify light and dark contrast, keyboard access, and reduced-motion behavior - with [Accessibility](../guides/accessibility.md). +- Keep angle for cyclic order or part-to-whole intervals. +- Use D3 pie output rather than reimplementing angle accumulation. +- Give every mutable arc and point a stable source key. +- Preserve original values for tooltips and accessible summaries. +- Keep radar dimension domains, directions, and units explicit. +- Verify labels around the full circumference at narrow widths. +- Prefer aligned bars or dots when precise comparison is the primary task. diff --git a/docs/framework/octane/adapter.md b/docs/framework/octane/adapter.md index 9e28d5b..df71d87 100644 --- a/docs/framework/octane/adapter.md +++ b/docs/framework/octane/adapter.md @@ -4,7 +4,7 @@ description: Understand the native TSRX lifecycle, conditional SSR target, hydra --- `@tanstack/octane-charts` is the native TSRX lifecycle and SSR adapter around -`@tanstack/charts`. Definitions, scenes, responsive layout, SVG rendering, +`@tanstack/charts`. Definitions, scenes, responsive layout, rendering, interaction, and animation remain framework-neutral. ## Public exports @@ -22,13 +22,24 @@ export type { } from '@tanstack/octane-charts' ``` +Choose Canvas or an application-supplied renderer through an explicit +subpath: + +```tsx +import { Chart as CanvasChart } from '@tanstack/octane-charts/canvas' +import { Chart as RendererChart } from '@tanstack/octane-charts/core' +``` + +The default `Chart` remains SVG-based. `CanvasChart` selects the optional +built-in renderer; `RendererChart` requires a `renderer` prop. + The package export map supplies a browser build for browser bundlers and a separate Node build for the `node` condition. ## Render lifecycle -The adapter creates one `ChartRuntime` for each component instance and renders -the initial complete SVG in TSRX. After layout: +The adapter creates one `ChartRuntime` for each component instance and asks the +selected renderer for initial markup in TSRX. After layout: 1. `useLayoutEffect` mounts the shared DOM host into the existing chart surface 2. the initial runtime is passed through, preserving prepared data @@ -37,14 +48,19 @@ the initial complete SVG in TSRX. After layout: 5. cleanup destroys the host and all browser-owned behavior The inner chart surface is memoized after its first output. The shared host -reconciles later scenes directly in the SVG. +paints later scenes directly into the selected surface. ## SSR and hydration -The Node target renders the complete `.ts-chart-host`, +The default Node target renders the complete `.ts-chart-host`, `.ts-chart-surface`, and accessible SVG at `initialWidth`. The browser target hydrates the same structure before mounting the host. +The Canvas entry renders a deterministic named root and two `aria-hidden` +canvases on the server. It paints no server pixels. The browser adopts the +elements, paints after mount, and attaches the same focus, keyboard, tooltip, +and selection host. + Keep data, definitions, scale domains, custom renderers, and dimensions deterministic between server and browser. The adapter generates a sanitized resource prefix from Octane's `useId()` when `idPrefix` is absent. @@ -62,7 +78,7 @@ The rendered structure is: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart + svg.ts-chart | div.ts-chart-canvas ``` The outer host uses `position: relative`. @@ -104,8 +120,8 @@ Octane-specific presentation props apply to the outer host: Do not conflict a fixed `width` prop with `style.width`. The style changes the outer CSS box while the prop continues to lock scene measurement. -The core renderer's `className` option applies to direct SVG rendering, not the -Octane outer host. +The core renderer's `className` option applies to a directly rendered surface, +not the Octane outer host. ## Definition and option identity diff --git a/docs/framework/octane/reference/chart.md b/docs/framework/octane/reference/chart.md index 8eb7e63..0a040e0 100644 --- a/docs/framework/octane/reference/chart.md +++ b/docs/framework/octane/reference/chart.md @@ -24,6 +24,33 @@ function Chart< >(props: DynamicChartProps): unknown ``` +## Renderer entry points + +The default entry uses SVG. The optional entries keep other renderer code +explicit: + +```tsx +import { Chart as CanvasChart } from '@tanstack/octane-charts/canvas' +import { Chart as RendererChart } from '@tanstack/octane-charts/core' + +const canvasChart = ( + +) +const rendererChart = ( + +) +``` + +The Canvas `Chart` accepts the common interaction and sizing props except +`renderSvg`. Its `onRender` receives `ChartRendererRenderContext`. The `/core` +`Chart` also requires `renderer: ChartRenderer`. Both entries export +`ChartCommonProps`, `ChartProps`, `StaticChartProps`, `DynamicChartProps`, +`ChartDefinition`, and `ChartPoint`. + ## Definition props | Prop | Static | Dynamic | Meaning | @@ -37,9 +64,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). | Prop | Type | Default | Meaning | | ----------------- | ----------------------------------------------- | -------------------------- | ---------------------------------------------------- | -| `ariaLabel` | `string` | Required | Accessible SVG name | -| `ariaDescription` | `string` | None | Optional SVG description | -| `tabIndex` | `number` | `0` | SVG tab index while keyboard behavior is enabled | +| `ariaLabel` | `string` | Required | Accessible surface name | +| `ariaDescription` | `string` | None | Optional surface description | +| `tabIndex` | `number` | `0` | Surface tab index while keyboard behavior is enabled | | `height` | `number` | `320` without aspect ratio | Fixed CSS and scene height | | `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | | `width` | `number` | Responsive | Fixed CSS and scene width | diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 5e5ef34..42609e1 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -5,8 +5,7 @@ description: Understand the thin React lifecycle, SSR, hydration, update, sizing `@tanstack/react-charts` is a thin lifecycle and SSR adapter around `@tanstack/charts`. Chart definitions, scale resolution, guide layout, scenes, -SVG rendering, reconciliation, animation, and interaction remain in the -framework-neutral core. +rendering, animation, and interaction remain in the framework-neutral core. ## Public exports @@ -23,13 +22,25 @@ export type { } from '@tanstack/react-charts' ``` +Choose Canvas or an application-supplied renderer through an explicit +subpath: + +```tsx +import { Chart as CanvasChart } from '@tanstack/react-charts/canvas' +import { Chart as RendererChart } from '@tanstack/react-charts/core' +``` + +`CanvasChart` selects the optional built-in renderer. `RendererChart` requires +a `renderer` prop. The default `Chart` remains SVG-based, so importing the +default adapter does not pull Canvas into its module graph. + Use the re-exported definition and point types only when an application API needs an explicit annotation. Ordinary component use is inferred. ## Render lifecycle -The adapter creates one `ChartRuntime` per mounted component and renders the -initial complete SVG during React render. After commit: +The adapter creates one `ChartRuntime` per mounted component and asks the +selected renderer for its initial markup during React render. After commit: 1. a layout effect mounts the shared DOM host into the existing chart surface 2. the same runtime is passed to the host, preserving prepared data @@ -38,15 +49,14 @@ initial complete SVG during React render. After commit: 5. effect cleanup destroys the host and runtime-owned browser behavior The chart surface is memoized after its first render. Dynamic changes are -reconciled by the shared host rather than by rebuilding the SVG element tree -through React. +painted by the shared host rather than by rebuilding the surface through React. React batching may omit intermediate application states. Every committed prop set forwarded to the host remains declarative and complete. ## SSR and hydration -Server rendering emits: +The default SVG entry emits: - the outer `.ts-chart-host` div - a `.ts-chart-surface` div @@ -55,6 +65,11 @@ Server rendering emits: The client renders the same initial structure, then the layout effect adopts and reconciles that SVG. There is no placeholder-only server mode. +The Canvas entry emits the same outer structure with a named Canvas root and +two `aria-hidden` canvases. It does not paint pixels on the server. The client +adopts those elements, paints after mount, and attaches the same focus, +keyboard, tooltip, and selection host. + Use deterministic data, scale domains, definitions, dimensions, and custom renderers on server and client. The adapter generates a sanitized `idPrefix` from `React.useId()` when one is not supplied, keeping document resources @@ -74,7 +89,7 @@ The adapter renders two nested containers: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart + svg.ts-chart | div.ts-chart-canvas ``` The outer host has `position: relative`. @@ -117,9 +132,8 @@ Do not give `style.width` a value that conflicts with a fixed `width` prop. The style controls the outer CSS box, while the prop continues to lock the scene width. -Core `RenderChartSvgOptions.className` applies to an SVG when calling the -renderer directly. The React adapter's `className` intentionally owns the -outer element instead. +Core renderer `className` options apply to a surface when calling it directly. +The React adapter's `className` intentionally owns the outer element instead. ## Definition and input identity diff --git a/docs/framework/react/reference/chart.md b/docs/framework/react/reference/chart.md index 19175e5..149afca 100644 --- a/docs/framework/react/reference/chart.md +++ b/docs/framework/react/reference/chart.md @@ -25,6 +25,33 @@ function Chart< >(props: DynamicChartProps): React.JSX.Element ``` +## Renderer entry points + +The default entry uses SVG. The optional entries keep other renderer code +explicit: + +```tsx +import { Chart as CanvasChart } from '@tanstack/react-charts/canvas' +import { Chart as RendererChart } from '@tanstack/react-charts/core' + +const canvasChart = ( + +) +const rendererChart = ( + +) +``` + +The Canvas `Chart` accepts the same common interaction and sizing props except +`renderSvg`. Its `onRender` receives `ChartRendererRenderContext`. The `/core` +`Chart` also requires `renderer: ChartRenderer`; use it for application-owned +surfaces. Both entries export `ChartCommonProps`, `ChartProps`, +`StaticChartProps`, `DynamicChartProps`, `ChartDefinition`, and `ChartPoint`. + ## Definition props | Prop | Static | Dynamic | Meaning | @@ -38,9 +65,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). | Prop | Type | Default | Meaning | | ----------------- | --------------------- | -------------------------- | ------------------------------------------------------ | -| `ariaLabel` | `string` | Required | Accessible SVG name | -| `ariaDescription` | `string` | None | Optional SVG description | -| `tabIndex` | `number` | `0` | SVG tab index while keyboard behavior is enabled | +| `ariaLabel` | `string` | Required | Accessible surface name | +| `ariaDescription` | `string` | None | Optional surface description | +| `tabIndex` | `number` | `0` | Surface tab index while keyboard behavior is enabled | | `height` | `number` | `320` without aspect ratio | Fixed CSS and scene height | | `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | | `width` | `number` | Responsive | Fixed CSS and scene width | diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md index f6b4405..5294358 100644 --- a/docs/guides/accessibility.md +++ b/docs/guides/accessibility.md @@ -4,8 +4,9 @@ description: Give charts useful names, keyboard-equivalent interaction, reduced --- Accessibility is part of the chart contract, not a final annotation pass. -TanStack Charts provides accessible SVG and focus primitives; the application -still owns the surrounding explanation, controls, and exact-value alternative. +TanStack Charts provides named SVG and Canvas surfaces plus shared focus +primitives; the application still owns the surrounding explanation, controls, +and exact-value alternative. ## Name the chart @@ -30,10 +31,12 @@ visible nearby: ``` The SVG renderer emits an image role, a chart roledescription, and a `` -when a description is supplied. Do not put instructions, conclusions, and all +when a description is supplied. The Canvas renderer places the same image role, +name, roledescription, description, and tab index on its root while keeping its +two paint canvases `aria-hidden`. Do not put instructions, conclusions, and all underlying data into one enormous accessible name. -## Preserve semantic context outside SVG +## Preserve semantic context outside the surface A chart should usually be accompanied by: @@ -120,7 +123,7 @@ semantically, rather than inferred from DOM nodes. ## Testing checklist -Test the finished application, not only the SVG: +Test the finished application, not only the SVG or Canvas element: 1. Navigate the page and chart with a keyboard. 2. Confirm visible focus and a logical navigation order. diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 5853bb3..1617b02 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -19,11 +19,33 @@ Capability subpaths make optional boundaries explicit: ```ts import { mountChart } from '@tanstack/charts/dom' +import { mountCanvasChart } from '@tanstack/charts/canvas' +import { mountChartRenderer } from '@tanstack/charts/renderer' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' import { d3Curve } from '@tanstack/charts/d3/shape' ``` +Canvas is opt-in. The default core, React, and Octane entries remain SVG-based; +Canvas enters the module graph only through `@tanstack/charts/canvas`, +`@tanstack/react-charts/canvas`, or `@tanstack/octane-charts/canvas`. The +framework `/core` entries accept an application-supplied renderer without +importing Canvas. + +Non-cartesian geometry is subpath-only: + +```ts +import { polar, radialArc } from '@tanstack/charts/polar' +import { geoShape } from '@tanstack/charts/geo' +``` + +The root entry does not re-export those capabilities. Polar brings in its +`d3-shape` geometry only when the polar subpath is imported; geography does +the same for `d3-geo`. Import `pie`, configured scales, projections, and curve +factories from their granular D3 modules as the chart requires them. Political +boundary data and `topojson-client` remain application dependencies; importing +`geoShape` does not bundle an atlas. + Your bundler must honor ESM exports and tree shaking. Avoid namespace imports when a named or subpath import communicates the real dependency. @@ -53,7 +75,10 @@ bundler, minifier, target, and entry source. A root package tarball size or an unminified source count is not a user bundle measurement. The repository's bundle gates use isolated entries so adding a complex mark -cannot silently increase the smallest chart. +cannot silently increase the smallest chart. Polar has separate arc-only and +D3-pie consumer ceilings plus a complete scale-backed line/scatter ceiling; +geography has its own projected-shape ceiling. The ordinary line, +representative-mark, DOM, and framework entries remain exact byte locks. ## Separate preparation, scene, and paint @@ -61,7 +86,8 @@ Measure three layers independently: 1. Data preparation: sorting, grouping, binning, stacking, or layout. 2. Scene build: channels, scales, guides, marks, and focus points. -3. DOM paint: SVG serialization, keyed reconciliation, and optional animation. +3. Surface paint: SVG serialization and keyed reconciliation, or Canvas draw + calls, plus optional animation. This separation reveals whether an expensive chart needs a better encoding, a cached transform, fewer scene nodes, or a different renderer. @@ -81,8 +107,10 @@ The fastest way to render too much data is to avoid rendering it: - use facets only when each panel remains interpretable; - virtualize application chrome and lanes when only a subset is visible. -Every visible SVG node carries paint, memory, hit-testing, and accessibility -cost. More nodes are justified only when they communicate more information. +Every visible SVG node carries DOM and paint cost. Canvas removes the +per-element DOM cost, but not scene construction, draw work, interaction-point +memory, or visual overplotting. More marks are justified only when they +communicate more information. See [Large Data](./large-data.md) for representation thresholds and interaction policies. diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index 22ba36f..6a60259 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -1,13 +1,14 @@ --- title: Custom Marks and Renderers -description: Extend the grammar with typed mark channels and keyed scene nodes, or replace SVG serialization without bypassing chart semantics. +description: Extend the grammar with typed mark channels and keyed scene nodes, or replace the mounted renderer without bypassing chart semantics. --- -Use a custom mark when a visualization fits the chart's cartesian scale and -scene model but is not expressible as a useful composition of built-in marks. +Use a custom mark when a visualization fits the shared scene model but is not +expressible as a useful composition of built-in Cartesian, polar, or +geographic marks. -Use a custom renderer when the same chart scene needs different SVG -serialization or resources. +Use a custom renderer when the same chart scene needs a different mounted +surface. Use a custom SVG serializer when only SVG markup or resources differ. Neither extension should reach into private scene compiler state. @@ -21,6 +22,8 @@ Before creating a mark, check whether the result is a combination of: - rules, links, ticks, arrows, or vectors; - text or frames; - facets. +- polar arcs, radial paths, dots, or guides; +- projected GeoJSON. Composition retains built-in type inference, focus metadata, animation, and subpath bundle boundaries. The [chart examples](../examples/index.md) show @@ -144,7 +147,30 @@ bundle must not pay for a custom scale registered elsewhere. See [Scales and D3](../concepts/scales-and-d3.md) and [Legends and Color](./legends-and-color.md). -## Custom SVG renderer +## Custom renderer + +A full renderer implements `ChartRenderer` and returns a `ChartSurface`: + +```ts +import { mountChartRenderer } from '@tanstack/charts/renderer' + +const host = mountChartRenderer(container, { + definition, + renderer: myRenderer, + ariaLabel: 'Threshold history', +}) +``` + +The renderer owns server shell markup, its mounted element, scene painting, +coordinate conversion, focus painting, and cleanup. The host retains sizing, +runtime, keyboard, tooltip, selection, and focus-strategy behavior. Keep +`prerender` deterministic and make `mount` adopt compatible server markup. + +Use `ChartRendererRenderContext.surface` instead of assuming `onRender` exposes +an SVG element. Framework consumers pass `renderer` through +`@tanstack/react-charts/core` or `@tanstack/octane-charts/core`. + +## Custom SVG serializer A `ChartSvgRenderer` accepts the complete `ChartScene` and accessible SVG options: diff --git a/docs/guides/exporting.md b/docs/guides/exporting.md index 709e579..bf0ac8e 100644 --- a/docs/guides/exporting.md +++ b/docs/guides/exporting.md @@ -3,10 +3,11 @@ title: Exporting description: Export accessible chart SVG or browser-rendered raster images while preserving dimensions, styling, and resource identity. --- -TanStack Charts has two export paths: +TanStack Charts' built-in export helpers have three paths: - render a `ChartScene` directly to an SVG string; -- serialize or rasterize an SVG that is already mounted in a browser. +- serialize an SVG that is already mounted in a browser; +- rasterize a mounted SVG or Canvas chart. Choose based on whether export needs computed browser styles. @@ -85,6 +86,20 @@ await downloadChartImage(chartContainer, 'quarterly-revenue.png', { 2 produces a 2400 × 1350 canvas while retaining the 1200 × 675 visual coordinate system. +## Export a Canvas chart + +Pass the Canvas root or an ancestor containing it to the same +`renderChartImage` or `downloadChartImage` functions. The exporter draws the +base scene layer at the requested dimensions and scale. Set +`includeFocus: true` to composite the focus overlay; it is excluded by +default. + +Canvas focus is painted on a separate overlay so pointer movement does not +repaint the base scene. Applications that need only the raw base bitmap may +also call `toBlob()` or `toDataURL()` on +`CanvasChartSurface.canvas`. Unlike SVG serialization, Canvas export does not +retain vector geometry, accessible markup, or independently styleable nodes. + ## Theme and resource policy Export the theme intended for the artifact. A chart following application dark @@ -114,6 +129,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. +- A Canvas export intentionally includes or excludes the focus overlay. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/docs/guides/large-data.md b/docs/guides/large-data.md index 611fd4e..2c35d0e 100644 --- a/docs/guides/large-data.md +++ b/docs/guides/large-data.md @@ -23,7 +23,8 @@ Track four quantities: - **source**: rows received by the application; - **represented**: source rows accounted for by the encoding; - **prepared**: rows or vertices passed to marks; -- **rendered**: SVG nodes and path vertices. +- **rendered**: scene nodes and path vertices, plus the selected renderer's DOM + or draw work. Do not describe a million-row chart as a million rendered points when the visible result is a few thousand aggregate cells. The bounded output is a @@ -64,6 +65,24 @@ the same pixels. Switch representations before the raw update cost exceeds the product's frame budget or the output stops being readable. +## When Canvas helps + +The optional Canvas renderer removes per-node SVG DOM and reconciliation cost. +It can be useful when a measured bottleneck is SVG element creation or paint. +It does not remove channel materialization, scale and guide work, scene +compilation, path construction, or `ChartPoint` creation. + +A dot mark still produces one scene node and one interaction point per +observation. Default nearest-point focus still scans those points linearly. +Supply a measured `spatialIndex` when individual points are still the correct +pointer target, and use a focus strategy with a bounded `navigation` order +when every observation is not a useful keyboard stop. + +Do not treat Canvas as permission to promise a million independently +interactive marks. Compare source, represented, prepared, and rendered counts; +measure compilation, paint, interaction, and memory; then aggregate, sample, +or bound the window when the representation exceeds the product budget. + ## Preparation ownership Put width-independent aggregation in a dynamic definition's `prepare` phase. @@ -108,10 +127,12 @@ target. - Provide drill-down or a linked table when readers need original rows. - Use a spatial index only when individual points remain the correct representation. +- Keep keyboard navigation bounded to useful targets instead of stepping + through every source observation. - Keep exact values outside hover-only UI. -An index can accelerate nearest-point lookup. It does not reduce DOM count, -path serialization, paint cost, or visual overplotting. +An index can accelerate nearest-point lookup. It does not reduce scene size, +DOM or draw count, path construction, paint cost, or visual overplotting. ## Streaming windows @@ -136,6 +157,7 @@ Separate: - scene compilation; - SVG serialization; - DOM reconciliation; +- Canvas painting; - pointer activation and sustained movement; - memory after repeated updates and destroy. diff --git a/docs/guides/ssr-and-hydration.md b/docs/guides/ssr-and-hydration.md index 10ee32b..13dd9ad 100644 --- a/docs/guides/ssr-and-hydration.md +++ b/docs/guides/ssr-and-hydration.md @@ -3,10 +3,10 @@ title: SSR and Hydration description: Render deterministic chart markup on the server, preserve runtime work through hydration, and handle responsive dimensions without mismatches. --- -TanStack Charts builds a platform-neutral scene before it renders SVG. React -and Octane use the same runtime implementation and SVG path on the server and -in the browser, so a chart does not need a browser-only substitute during -server rendering. +TanStack Charts builds a platform-neutral scene before the selected renderer +produces output. React and Octane use the same runtime and renderer on the +server and in the browser, so a chart does not need a browser-only substitute +during server rendering. ## Give the server a real size @@ -62,6 +62,18 @@ Do not conditionally replace a chart with a different component only because the code is executing on the server. That creates a different tree and gives up the shared render path. +## Canvas server shell + +`@tanstack/react-charts/canvas` and `@tanstack/octane-charts/canvas` render a +deterministic accessible shell on the server: a named chart root and two +`aria-hidden` canvas elements with the initial scene dimensions. No server +Canvas API or pixel painting is required. + +The client renders the same shell, adopts its existing root and canvases, sizes +their backing stores for the device-pixel ratio, paints the scene, and attaches +the shared interaction host. The first image appears after client mount; use +the default SVG adapter when visible server-rendered geometry is required. + ## Fonts and text measurement Automatic guide margins depend on text metrics. The server uses deterministic @@ -96,9 +108,10 @@ const svg = renderChartSvg(scene, { runtime.destroy() ``` -`renderChartSvg` returns a string and does not require a DOM. Browser-only -focus, tooltip, reconciliation, animation, and image export begin at -`mountChart`. +`renderChartSvg` returns a string and does not require a DOM. A custom +`ChartRenderer.prerender` may produce another deterministic shell. Browser-only +focus, tooltip, reconciliation or paint, animation, and export begin when its +surface mounts. ## Hydration checklist diff --git a/docs/guides/testing-and-debugging.md b/docs/guides/testing-and-debugging.md index d1dad11..611a04e 100644 --- a/docs/guides/testing-and-debugging.md +++ b/docs/guides/testing-and-debugging.md @@ -3,7 +3,7 @@ title: Testing and Debugging description: Test chart semantics, geometry, interaction, updates, accessibility, and performance without relying on brittle screenshots alone. --- -A useful chart test answers more than “did an SVG appear?” Test at the +A useful chart test answers more than “did a surface appear?” Test at the narrowest layer that owns the behavior. ## Test the scene first @@ -65,6 +65,17 @@ Mount into a real or sufficiently complete DOM when behavior depends on: Always call `destroy()` and verify observers, frames, tooltip nodes, and event listeners do not survive. +Run the same focus, keyboard, tooltip, selection, responsive-update, and +destroy sequences against SVG and Canvas when renderer parity matters. Those +behaviors belong to the shared host; renderer tests should concentrate on +surface adoption, coordinate conversion, paint, and cleanup. + +For Canvas, test deterministic server-shell markup without installing Canvas +APIs. Use a Canvas 2D mock for draw-call and device-pixel-ratio assertions, then +use browser screenshots for representative paths, clipping, gradients, text, +themes, and focus-overlay composition. Do not use a raw pixel or data-URL +snapshot as the only semantic assertion. + ## Test interaction as a sequence Describe the user path and its semantic assertion: @@ -98,7 +109,7 @@ semantically important error cannot hide in the diff threshold. At minimum verify: -- the SVG has a meaningful accessible name; +- the SVG or Canvas root has a meaningful accessible name; - descriptions summarize the current chart, not every point; - keyboard focus can enter and traverse the chart when enabled; - focus and selection callbacks expose the same data as pointer interaction; @@ -116,12 +127,12 @@ When output is wrong, inspect in this order: 3. Resolved scale domains, ranges, bandwidths, and ticks. 4. `scene.chart` bounds and margins. 5. Scene nodes and interaction points. -6. Serialized SVG. -7. Mounted DOM and application CSS. +6. Renderer input and static output. +7. Mounted surface and application styling. -If the scene is correct but the DOM is wrong, the defect belongs to rendering, -reconciliation, or CSS. If the channel values are wrong, changing the SVG -renderer will not fix the cause. +If the scene is correct but the surface is wrong, the defect belongs to +rendering, reconciliation or paint, or styling. If the channel values are +wrong, changing the renderer will not fix the cause. ## Performance tests need correctness gates diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index 9a085bb..f15b6c3 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -1,6 +1,6 @@ --- title: Themes and Styling -description: Apply automatic light and dark color behavior, CSS palette tokens, mark styles, and scoped SVG resources. +description: Apply automatic light and dark color behavior, CSS palette tokens, mark styles, and renderer-aware resources. --- TanStack Charts inherits the surrounding application instead of installing a @@ -76,7 +76,7 @@ time. ## Mark styling -Built-in marks expose the SVG styles relevant to their geometry: fill, stroke, +Built-in marks expose the paint styles relevant to their geometry: fill, stroke, opacity, widths, line caps, dashes, corner radius, and font properties. A style can be fixed or data-driven where the mark's option accepts a visual channel. @@ -90,6 +90,18 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Canvas styling + +The Canvas renderer resolves scene paints such as `currentColor` and CSS +custom properties against the chart's computed environment. It inherits the +root font and repaints after relevant ancestor class, style, `data-theme`, +color-scheme, forced-colors, or viewport changes. + +Rasterized scene nodes are not DOM descendants. A node's `className` therefore +cannot be targeted by a CSS selector after paint. Put data-dependent fill, +stroke, opacity, and font choices in mark options or the chart theme; use +container CSS for palette variables, inherited color, and typography. + ## Gradients and clipping Gradients are opt-in SVG resources. Declare them on the chart and render with @@ -125,6 +137,10 @@ scopes resource and clip IDs when several charts share a document. Set `clip: true` when marks should be clipped to the resolved plot rectangle. Clipping is a geometry policy, not a substitute for correct scale domains. +Canvas consumes the same declared gradients and group clips without the +resource-aware SVG serializer. A Canvas gradient needs measurable node bounds; +path-only geometry with no point bounds should use an explicit paint instead. + + + + + + + The join is part of data preparation. Match features through stable IDs, report unmatched records, and distinguish missing values from zero. Keep the color domain and legend explicit so one filtered region cannot silently rescale the @@ -49,6 +76,172 @@ part of the task. [Legends and Color](../guides/legends-and-color.md) covers sequential, diverging, and threshold choices. +## Prepare atlas boundaries + +TanStack accepts GeoJSON and does not bundle a political boundary dataset. +Convert an application-owned TopoJSON atlas once, validate its feature count +and IDs, then keep that geometry stable while values change. + + + +```ts +import { feature } from 'topojson-client' +import worldAtlas from 'world-atlas/countries-110m.json' +import type { + GeometryCollection, + Objects, + Topology, +} from 'topojson-specification' + +interface AtlasProperties { + name: string +} + +type WorldObjects = Objects & { + countries: GeometryCollection +} + +const topology = worldAtlas as unknown as Topology +const countries = feature(topology, topology.objects.countries) +``` + +`world-atlas` redistributes Natural Earth boundaries; `us-atlas` provides +Census-derived state geometry. `topojson-client.feature` performs the standard +conversion to GeoJSON consumed by `geoShape`. These remain application +dependencies, so neither their data nor the converter enters ordinary +Cartesian or geo-only consumer bundles. + +## Project GeoJSON responsively + +Create the D3 projection inside `projection`. Its context contains the final +plot bounds, so `fitExtent` reruns correctly when the chart resizes. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { geoShape } from '@tanstack/charts/geo' +import { geoEqualEarth } from 'd3-geo' + +const regions = [ + { + type: 'Feature' as const, + properties: { id: 'west', errorRate: 3.2 }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [-120, 30], + [-100, 30], + [-100, 46], + [-120, 46], + [-120, 30], + ], + ], + }, + }, + { + type: 'Feature' as const, + properties: { id: 'east', errorRate: 8.7 }, + geometry: { + type: 'Polygon' as const, + coordinates: [ + [ + [-98, 30], + [-72, 30], + [-72, 46], + [-98, 46], + [-98, 30], + ], + ], + }, + }, +] + +const collection = { + type: 'FeatureCollection' as const, + features: regions, +} + +const map = defineChart({ + marks: [ + geoShape(regions, { + key: (region) => region.properties.id, + projection: ({ chart }) => + geoEqualEarth().fitExtent( + [ + [chart.x, chart.y], + [chart.x + chart.width, chart.y + chart.height], + ], + collection, + ), + fill: (region) => + region.properties.errorRate >= 5 ? '#ef4444' : '#fbbf24', + stroke: '#ffffff', + strokeWidth: 0.75, + }), + ], + guides: false, + x: null, + y: null, +}) +``` + +`geoShape` uses `geoPath` for each feature and D3 centroids for focus. Supply +`anchor` only when a feature needs a different semantic longitude/latitude +than its spherical centroid. The complete option contract is in +[Geo Shape Mark](../reference/marks/geo.md). + +## Project points by magnitude + +Point and MultiPoint features use D3 `geoPath().pointRadius()`. The `r` +channel can carry pixels directly; `rScale` maps a quantitative value first. + + + +## Change the projection + +The same GeoJSON can use any D3 projection factory. Sphere and graticule +geometry are ordinary `geoShape` layers. + + + + + +## Layer routes over geography + +Polygon, LineString, and Point features can share one responsive projection. + + + ## Show direction and magnitude A vector field places an arrow at each sampled position. Direction uses angle; @@ -78,7 +271,7 @@ resolved plot bounds, not the viewport, and recompute when the chart container changes. Preserve source longitude and latitude alongside projected coordinates for tooltips and selection. -When one custom spatial shape must render inside the shared scene: +When a custom spatial shape must render beside `geoShape`: - Keep path generation deterministic and DOM-free. - Emit stable scene keys for each feature. diff --git a/packages/charts-core/docs/examples/polar-and-radar.md b/packages/charts-core/docs/examples/polar-and-radar.md index 7b3c295..5b25070 100644 --- a/packages/charts-core/docs/examples/polar-and-radar.md +++ b/packages/charts-core/docs/examples/polar-and-radar.md @@ -1,113 +1,450 @@ --- title: Polar and Radar Charts -description: Decide when cyclic, radial, radar, or parallel-coordinate layouts clarify multivariate data. +description: Build D3-backed pie, donut, gauge, radar, line, scatter, radial bar, rose, and sunburst charts through the opt-in polar coordinate entry. --- -Polar layouts place angle and radius where cartesian charts use x and y. They -can clarify genuinely cyclic domains and compact multivariate profiles, but -they also make length, angle, and area harder to compare. +Polar geometry is available only from `@tanstack/charts/polar`. The container +owns responsive center, angle, and radius ranges; granular D3 modules still +own pie layout, configured scales, and curve factories. -Use them deliberately. A radial shape is not automatically more informative -than aligned bars, dots, or small multiples. +```ts +import { + angleGrid, + polar, + radialArc, + radialArea, + radialDot, + radialGrid, + radialLine, + radialRule, + radialText, +} from '@tanstack/charts/polar' +``` -## Choose the profile view +The package root stays Cartesian-sized when this subpath is not imported. -| Reader question | Start with | -| ----------------------------------------------------------------- | -------------------------------------- | -| How does one profile vary across a small set of fixed dimensions? | Radar chart | -| How do several entities compare across many ordered dimensions? | Parallel coordinates | -| Does a measure repeat around a natural cycle? | Polar line, area, or radial bars | -| Must values be compared precisely across dimensions? | Aligned bars, dots, or small multiples | -| Does angle itself have no semantic meaning? | Stay cartesian | +## Pie and donut -Keep every dimension's domain and direction explicit. A shape can change -dramatically when one axis is reversed or rescaled. +Use `d3-shape`'s `pie` transform to turn totals into angular intervals. +`radialArc` renders the intervals. A zero inner radius is a pie; a responsive +nonzero inner radius is a donut. -## Compare one compact profile + -A radar chart maps each qualitative dimension to an equally spaced angle and -its value to radius. The filled polygon makes the overall profile visible. +```ts +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' + +interface Part { + id: string + value: number + fill: string +} + +const parts: Part[] = [ + { id: 'ingest', value: 42, fill: '#2563eb' }, + { id: 'query', value: 28, fill: '#7c3aed' }, + { id: 'alerts', value: 18, fill: '#db2777' }, + { id: 'other', value: 12, fill: '#f59e0b' }, +] + +const slices = pie() + .sort(null) + .value((part) => part.value)(parts) + +function ring(innerRatio: number) { + return polar({ + inset: 8, + radiusRatio: 0.82, + marks: [ + radialArc(slices, { + key: (slice) => slice.data.id, + startAngle: 'startAngle', + endAngle: 'endAngle', + padAngle: 'padAngle', + innerRadius: ({ radius }) => radius * innerRatio, + cornerRadius: 4, + fill: (slice) => slice.data.fill, + }), + ], + }) +} + +const pieChart = defineChart({ + marks: [ring(0)], + guides: false, + x: null, + y: null, +}) + +const donutChart = defineChart({ + marks: [ring(0.58)], + guides: false, + x: null, + y: null, +}) +``` + + + +The same primitives cover labels, center content, padding, rounded corners, +and concentric rings: + + + + + + + + + +Keep `pie().sort(null)` when source order is semantic. Stable arc keys must +come from the original row, not the generated slice index. + +## Partial-circle gauge + +A gauge is the same composition over a restricted D3 pie interval. It is not +a separate geometry implementation. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { polar, radialArc } from '@tanstack/charts/polar' +import { pie } from 'd3-shape' + +const value = 72 +const gaugeParts = [ + { id: 'value', value, fill: '#ef4444' }, + { id: 'remainder', value: 100 - value, fill: '#e2e8f0' }, +] +const gaugeSlices = pie<(typeof gaugeParts)[number]>() + .sort(null) + .value((part) => part.value) + .startAngle(-Math.PI * 0.75) + .endAngle(Math.PI * 0.75)(gaugeParts) + +const gauge = defineChart({ + marks: [ + polar({ + radiusRatio: 0.84, + marks: [ + radialArc(gaugeSlices, { + key: (slice) => slice.data.id, + startAngle: 'startAngle', + endAngle: 'endAngle', + innerRadius: ({ radius }) => radius * 0.72, + cornerRadius: 999, + fill: (slice) => slice.data.fill, + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` + + + + -Use radar for a small, fixed dimension set with compatible scales. Start each -radial axis at a truthful baseline, show reference rings, and label every -dimension directly. +Bound the input before layout and expose the exact value outside the arc. Arc +length is useful for a compact status summary, not fine comparison. -Polygon area is visually prominent but not a simple sum of the values. Do not -rank profiles by apparent filled area. If two or more profiles overlap, keep -fills translucent and provide a direct or textual comparison. +## Radar profile -## Compare many multivariate rows +Radar combines configured angle and radius scales with polar guides and radial +marks. TanStack copies both scales and supplies responsive ranges; the source +scales keep their semantic domains. -Parallel coordinates keep each dimension as a straight axis and draw one path -per entity. They are not polar, but they are often the clearer alternative -when several multivariate profiles must be compared. + + +```ts +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialArea, + radialDot, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { scaleBand, scaleLinear } from 'd3-scale' +import { curveLinearClosed } from 'd3-shape' + +const metrics = [ + 'Latency', + 'Errors', + 'Traffic', + 'Saturation', + 'Availability', +] as const +const profile = metrics.map((metric, index) => ({ + metric, + score: [74, 46, 88, 61, 93][index] ?? 0, +})) + +const radar = defineChart({ + marks: [ + polar({ + radiusRatio: 0.72, + angle: { scale: scaleBand().domain(metrics) }, + radius: { scale: scaleLinear().domain([0, 100]).nice(4) }, + guides: [ + radialGrid({ ticks: 4, shape: 'polygon', labels: false }), + angleGrid({ + labels: true, + labelDx: ({ x }) => (x < -1 ? -3 : x > 1 ? 3 : 0), + labelDy: ({ y }) => (y < -1 ? -2 : y > 1 ? 2 : 0), + }), + ], + marks: [ + radialArea(profile, { + angle: 'metric', + radius: 'score', + curve: curveLinearClosed, + fill: '#7c3aed', + fillOpacity: 0.22, + }), + radialLine(profile, { + angle: 'metric', + radius: 'score', + curve: curveLinearClosed, + stroke: '#8b5cf6', + strokeWidth: 2, + }), + radialDot(profile, { + angle: 'metric', + radius: 'score', + key: 'metric', + r: 3, + fill: '#8b5cf6', + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` -Parallel coordinates preserve a separate readable axis for every dimension, -but path crossings depend on axis order. Keep order intentional and stable. -Use selection or filtering before rendering a high-cardinality dataset; an -opaque bundle of lines does not support precise comparison. + + +Use radar for a small, fixed set of compatible dimensions. Keep every domain +and direction explicit, and do not rank profiles by apparent filled area. + +## Numeric polar line and scatter + +Continuous D3 scales map numeric angle and radius values without changing the +mark API. Repeat the first row at the angular endpoint when a line should close; +independent scatter observations need no seam row. + + + +```ts +import { defineChart } from '@tanstack/charts' +import { + angleGrid, + polar, + radialDot, + radialGrid, + radialLine, +} from '@tanstack/charts/polar' +import { scaleLinear } from 'd3-scale' + +const trajectory = [ + { id: '0', angle: 0, radius: 52 }, + { id: '90', angle: 90, radius: 76 }, + { id: '180', angle: 180, radius: 43 }, + { id: '270', angle: 270, radius: 68 }, + { id: '360', angle: 360, radius: 52 }, +] +const observations = [ + { id: 'a', angle: 34, radius: 61 }, + { id: 'b', angle: 142, radius: 82 }, + { id: 'c', angle: 248, radius: 47 }, +] + +const numericPolar = defineChart({ + marks: [ + polar({ + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([0, 100]) }, + guides: [ + radialGrid({ values: [25, 50, 75, 100] }), + angleGrid({ values: [0, 90, 180, 270], labels: false }), + ], + marks: [ + radialLine(trajectory, { + angle: 'angle', + radius: 'radius', + key: 'id', + stroke: '#0f766e', + }), + radialDot(observations, { + angle: 'angle', + radius: 'radius', + key: 'id', + r: 4.5, + fill: '#e11d48', + }), + ], + }), + ], + guides: false, + x: null, + y: null, +}) +``` -## Scale dimensions deliberately + -Dimensions with different units need separate normalization policies. Store -the original value and unit alongside any normalized radius or axis position. -The chart should expose original values in tooltips, tables, and accessible -descriptions. + -For each dimension decide: +## Radial magnitude and hierarchy -- Domain and baseline -- Whether larger always means better -- Tick or ring values -- Formatter and unit -- Missing-value treatment -- Stable order around the circle or across axes +`radialArc.generator` accepts responsive D3 arc accessors, so variable-radius +sectors, concentric radial bars, and hierarchy rings remain one primitive. -[Scales and D3](../concepts/scales-and-d3.md) owns scale construction. The -application should prepare any normalized dimension rows before marks render. + -## Geometry and labels + + + -Radar and other polar geometry usually need a custom mark or a composed scene: +## Coordinate and bundle boundary -- Convert each dimension index to an angle. -- Map each value through its radial scale. -- Close the profile polygon intentionally. -- Keep grid rings and spokes decorative rather than focusable data. -- Emit interaction points at semantic dimension values. -- Reserve enough responsive space for labels around every edge. +`polar()` is a positionless container mark. It resolves one center and radius, +copies configured angle/radius scales, paints guide backgrounds, child marks, +then guide foreground labels, and emits ordinary scene nodes and focus points. +The outer chart therefore uses `x: null`, `y: null`, and `guides: false`. -Use [Custom Marks and Renderers](../guides/custom-marks-and-renderers.md) for -the extension boundary and -[Layout, Axes, and Coordinates](../concepts/layout-axes-and-coordinates.md) for -responsive bounds. +The polar entry uses D3 arc and radial path generators internally. Application +source imports `pie`, configured scales, and curve factories directly from +`d3-shape` or `d3-scale`. See +[Polar Marks](../reference/marks/polar.md) for the complete API and +[Bundle Size and Performance](../guides/bundle-size-and-performance.md) for +the isolated consumer budgets. ## Production checks -- Use angle only for cyclic order or a small fixed dimension sequence. -- Keep every dimension domain, direction, and unit explicit. -- Preserve original values when rendering normalized positions. -- Avoid misleading polygon-area comparisons. -- Test long labels around the complete circumference. -- Provide exact values through a table or textual summary. -- Limit overlapping profiles and high-cardinality paths. -- Verify light and dark contrast, keyboard access, and reduced-motion behavior - with [Accessibility](../guides/accessibility.md). +- Keep angle for cyclic order or part-to-whole intervals. +- Use D3 pie output rather than reimplementing angle accumulation. +- Give every mutable arc and point a stable source key. +- Preserve original values for tooltips and accessible summaries. +- Keep radar dimension domains, directions, and units explicit. +- Verify labels around the full circumference at narrow widths. +- Prefer aligned bars or dots when precise comparison is the primary task. diff --git a/packages/charts-core/docs/framework/octane/adapter.md b/packages/charts-core/docs/framework/octane/adapter.md index 9e28d5b..df71d87 100644 --- a/packages/charts-core/docs/framework/octane/adapter.md +++ b/packages/charts-core/docs/framework/octane/adapter.md @@ -4,7 +4,7 @@ description: Understand the native TSRX lifecycle, conditional SSR target, hydra --- `@tanstack/octane-charts` is the native TSRX lifecycle and SSR adapter around -`@tanstack/charts`. Definitions, scenes, responsive layout, SVG rendering, +`@tanstack/charts`. Definitions, scenes, responsive layout, rendering, interaction, and animation remain framework-neutral. ## Public exports @@ -22,13 +22,24 @@ export type { } from '@tanstack/octane-charts' ``` +Choose Canvas or an application-supplied renderer through an explicit +subpath: + +```tsx +import { Chart as CanvasChart } from '@tanstack/octane-charts/canvas' +import { Chart as RendererChart } from '@tanstack/octane-charts/core' +``` + +The default `Chart` remains SVG-based. `CanvasChart` selects the optional +built-in renderer; `RendererChart` requires a `renderer` prop. + The package export map supplies a browser build for browser bundlers and a separate Node build for the `node` condition. ## Render lifecycle -The adapter creates one `ChartRuntime` for each component instance and renders -the initial complete SVG in TSRX. After layout: +The adapter creates one `ChartRuntime` for each component instance and asks the +selected renderer for initial markup in TSRX. After layout: 1. `useLayoutEffect` mounts the shared DOM host into the existing chart surface 2. the initial runtime is passed through, preserving prepared data @@ -37,14 +48,19 @@ the initial complete SVG in TSRX. After layout: 5. cleanup destroys the host and all browser-owned behavior The inner chart surface is memoized after its first output. The shared host -reconciles later scenes directly in the SVG. +paints later scenes directly into the selected surface. ## SSR and hydration -The Node target renders the complete `.ts-chart-host`, +The default Node target renders the complete `.ts-chart-host`, `.ts-chart-surface`, and accessible SVG at `initialWidth`. The browser target hydrates the same structure before mounting the host. +The Canvas entry renders a deterministic named root and two `aria-hidden` +canvases on the server. It paints no server pixels. The browser adopts the +elements, paints after mount, and attaches the same focus, keyboard, tooltip, +and selection host. + Keep data, definitions, scale domains, custom renderers, and dimensions deterministic between server and browser. The adapter generates a sanitized resource prefix from Octane's `useId()` when `idPrefix` is absent. @@ -62,7 +78,7 @@ The rendered structure is: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart + svg.ts-chart | div.ts-chart-canvas ``` The outer host uses `position: relative`. @@ -104,8 +120,8 @@ Octane-specific presentation props apply to the outer host: Do not conflict a fixed `width` prop with `style.width`. The style changes the outer CSS box while the prop continues to lock scene measurement. -The core renderer's `className` option applies to direct SVG rendering, not the -Octane outer host. +The core renderer's `className` option applies to a directly rendered surface, +not the Octane outer host. ## Definition and option identity diff --git a/packages/charts-core/docs/framework/octane/reference/chart.md b/packages/charts-core/docs/framework/octane/reference/chart.md index 8eb7e63..0a040e0 100644 --- a/packages/charts-core/docs/framework/octane/reference/chart.md +++ b/packages/charts-core/docs/framework/octane/reference/chart.md @@ -24,6 +24,33 @@ function Chart< >(props: DynamicChartProps): unknown ``` +## Renderer entry points + +The default entry uses SVG. The optional entries keep other renderer code +explicit: + +```tsx +import { Chart as CanvasChart } from '@tanstack/octane-charts/canvas' +import { Chart as RendererChart } from '@tanstack/octane-charts/core' + +const canvasChart = ( + +) +const rendererChart = ( + +) +``` + +The Canvas `Chart` accepts the common interaction and sizing props except +`renderSvg`. Its `onRender` receives `ChartRendererRenderContext`. The `/core` +`Chart` also requires `renderer: ChartRenderer`. Both entries export +`ChartCommonProps`, `ChartProps`, `StaticChartProps`, `DynamicChartProps`, +`ChartDefinition`, and `ChartPoint`. + ## Definition props | Prop | Static | Dynamic | Meaning | @@ -37,9 +64,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). | Prop | Type | Default | Meaning | | ----------------- | ----------------------------------------------- | -------------------------- | ---------------------------------------------------- | -| `ariaLabel` | `string` | Required | Accessible SVG name | -| `ariaDescription` | `string` | None | Optional SVG description | -| `tabIndex` | `number` | `0` | SVG tab index while keyboard behavior is enabled | +| `ariaLabel` | `string` | Required | Accessible surface name | +| `ariaDescription` | `string` | None | Optional surface description | +| `tabIndex` | `number` | `0` | Surface tab index while keyboard behavior is enabled | | `height` | `number` | `320` without aspect ratio | Fixed CSS and scene height | | `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | | `width` | `number` | Responsive | Fixed CSS and scene width | diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 5e5ef34..42609e1 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -5,8 +5,7 @@ description: Understand the thin React lifecycle, SSR, hydration, update, sizing `@tanstack/react-charts` is a thin lifecycle and SSR adapter around `@tanstack/charts`. Chart definitions, scale resolution, guide layout, scenes, -SVG rendering, reconciliation, animation, and interaction remain in the -framework-neutral core. +rendering, animation, and interaction remain in the framework-neutral core. ## Public exports @@ -23,13 +22,25 @@ export type { } from '@tanstack/react-charts' ``` +Choose Canvas or an application-supplied renderer through an explicit +subpath: + +```tsx +import { Chart as CanvasChart } from '@tanstack/react-charts/canvas' +import { Chart as RendererChart } from '@tanstack/react-charts/core' +``` + +`CanvasChart` selects the optional built-in renderer. `RendererChart` requires +a `renderer` prop. The default `Chart` remains SVG-based, so importing the +default adapter does not pull Canvas into its module graph. + Use the re-exported definition and point types only when an application API needs an explicit annotation. Ordinary component use is inferred. ## Render lifecycle -The adapter creates one `ChartRuntime` per mounted component and renders the -initial complete SVG during React render. After commit: +The adapter creates one `ChartRuntime` per mounted component and asks the +selected renderer for its initial markup during React render. After commit: 1. a layout effect mounts the shared DOM host into the existing chart surface 2. the same runtime is passed to the host, preserving prepared data @@ -38,15 +49,14 @@ initial complete SVG during React render. After commit: 5. effect cleanup destroys the host and runtime-owned browser behavior The chart surface is memoized after its first render. Dynamic changes are -reconciled by the shared host rather than by rebuilding the SVG element tree -through React. +painted by the shared host rather than by rebuilding the surface through React. React batching may omit intermediate application states. Every committed prop set forwarded to the host remains declarative and complete. ## SSR and hydration -Server rendering emits: +The default SVG entry emits: - the outer `.ts-chart-host` div - a `.ts-chart-surface` div @@ -55,6 +65,11 @@ Server rendering emits: The client renders the same initial structure, then the layout effect adopts and reconciles that SVG. There is no placeholder-only server mode. +The Canvas entry emits the same outer structure with a named Canvas root and +two `aria-hidden` canvases. It does not paint pixels on the server. The client +adopts those elements, paints after mount, and attaches the same focus, +keyboard, tooltip, and selection host. + Use deterministic data, scale domains, definitions, dimensions, and custom renderers on server and client. The adapter generates a sanitized `idPrefix` from `React.useId()` when one is not supplied, keeping document resources @@ -74,7 +89,7 @@ The adapter renders two nested containers: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart + svg.ts-chart | div.ts-chart-canvas ``` The outer host has `position: relative`. @@ -117,9 +132,8 @@ Do not give `style.width` a value that conflicts with a fixed `width` prop. The style controls the outer CSS box, while the prop continues to lock the scene width. -Core `RenderChartSvgOptions.className` applies to an SVG when calling the -renderer directly. The React adapter's `className` intentionally owns the -outer element instead. +Core renderer `className` options apply to a surface when calling it directly. +The React adapter's `className` intentionally owns the outer element instead. ## Definition and input identity diff --git a/packages/charts-core/docs/framework/react/reference/chart.md b/packages/charts-core/docs/framework/react/reference/chart.md index 19175e5..149afca 100644 --- a/packages/charts-core/docs/framework/react/reference/chart.md +++ b/packages/charts-core/docs/framework/react/reference/chart.md @@ -25,6 +25,33 @@ function Chart< >(props: DynamicChartProps): React.JSX.Element ``` +## Renderer entry points + +The default entry uses SVG. The optional entries keep other renderer code +explicit: + +```tsx +import { Chart as CanvasChart } from '@tanstack/react-charts/canvas' +import { Chart as RendererChart } from '@tanstack/react-charts/core' + +const canvasChart = ( + +) +const rendererChart = ( + +) +``` + +The Canvas `Chart` accepts the same common interaction and sizing props except +`renderSvg`. Its `onRender` receives `ChartRendererRenderContext`. The `/core` +`Chart` also requires `renderer: ChartRenderer`; use it for application-owned +surfaces. Both entries export `ChartCommonProps`, `ChartProps`, +`StaticChartProps`, `DynamicChartProps`, `ChartDefinition`, and `ChartPoint`. + ## Definition props | Prop | Static | Dynamic | Meaning | @@ -38,9 +65,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). | Prop | Type | Default | Meaning | | ----------------- | --------------------- | -------------------------- | ------------------------------------------------------ | -| `ariaLabel` | `string` | Required | Accessible SVG name | -| `ariaDescription` | `string` | None | Optional SVG description | -| `tabIndex` | `number` | `0` | SVG tab index while keyboard behavior is enabled | +| `ariaLabel` | `string` | Required | Accessible surface name | +| `ariaDescription` | `string` | None | Optional surface description | +| `tabIndex` | `number` | `0` | Surface tab index while keyboard behavior is enabled | | `height` | `number` | `320` without aspect ratio | Fixed CSS and scene height | | `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | | `width` | `number` | Responsive | Fixed CSS and scene width | diff --git a/packages/charts-core/docs/guides/accessibility.md b/packages/charts-core/docs/guides/accessibility.md index f6b4405..5294358 100644 --- a/packages/charts-core/docs/guides/accessibility.md +++ b/packages/charts-core/docs/guides/accessibility.md @@ -4,8 +4,9 @@ description: Give charts useful names, keyboard-equivalent interaction, reduced --- Accessibility is part of the chart contract, not a final annotation pass. -TanStack Charts provides accessible SVG and focus primitives; the application -still owns the surrounding explanation, controls, and exact-value alternative. +TanStack Charts provides named SVG and Canvas surfaces plus shared focus +primitives; the application still owns the surrounding explanation, controls, +and exact-value alternative. ## Name the chart @@ -30,10 +31,12 @@ visible nearby: ``` The SVG renderer emits an image role, a chart roledescription, and a `` -when a description is supplied. Do not put instructions, conclusions, and all +when a description is supplied. The Canvas renderer places the same image role, +name, roledescription, description, and tab index on its root while keeping its +two paint canvases `aria-hidden`. Do not put instructions, conclusions, and all underlying data into one enormous accessible name. -## Preserve semantic context outside SVG +## Preserve semantic context outside the surface A chart should usually be accompanied by: @@ -120,7 +123,7 @@ semantically, rather than inferred from DOM nodes. ## Testing checklist -Test the finished application, not only the SVG: +Test the finished application, not only the SVG or Canvas element: 1. Navigate the page and chart with a keyboard. 2. Confirm visible focus and a logical navigation order. 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 5853bb3..1617b02 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -19,11 +19,33 @@ Capability subpaths make optional boundaries explicit: ```ts import { mountChart } from '@tanstack/charts/dom' +import { mountCanvasChart } from '@tanstack/charts/canvas' +import { mountChartRenderer } from '@tanstack/charts/renderer' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' import { d3Curve } from '@tanstack/charts/d3/shape' ``` +Canvas is opt-in. The default core, React, and Octane entries remain SVG-based; +Canvas enters the module graph only through `@tanstack/charts/canvas`, +`@tanstack/react-charts/canvas`, or `@tanstack/octane-charts/canvas`. The +framework `/core` entries accept an application-supplied renderer without +importing Canvas. + +Non-cartesian geometry is subpath-only: + +```ts +import { polar, radialArc } from '@tanstack/charts/polar' +import { geoShape } from '@tanstack/charts/geo' +``` + +The root entry does not re-export those capabilities. Polar brings in its +`d3-shape` geometry only when the polar subpath is imported; geography does +the same for `d3-geo`. Import `pie`, configured scales, projections, and curve +factories from their granular D3 modules as the chart requires them. Political +boundary data and `topojson-client` remain application dependencies; importing +`geoShape` does not bundle an atlas. + Your bundler must honor ESM exports and tree shaking. Avoid namespace imports when a named or subpath import communicates the real dependency. @@ -53,7 +75,10 @@ bundler, minifier, target, and entry source. A root package tarball size or an unminified source count is not a user bundle measurement. The repository's bundle gates use isolated entries so adding a complex mark -cannot silently increase the smallest chart. +cannot silently increase the smallest chart. Polar has separate arc-only and +D3-pie consumer ceilings plus a complete scale-backed line/scatter ceiling; +geography has its own projected-shape ceiling. The ordinary line, +representative-mark, DOM, and framework entries remain exact byte locks. ## Separate preparation, scene, and paint @@ -61,7 +86,8 @@ Measure three layers independently: 1. Data preparation: sorting, grouping, binning, stacking, or layout. 2. Scene build: channels, scales, guides, marks, and focus points. -3. DOM paint: SVG serialization, keyed reconciliation, and optional animation. +3. Surface paint: SVG serialization and keyed reconciliation, or Canvas draw + calls, plus optional animation. This separation reveals whether an expensive chart needs a better encoding, a cached transform, fewer scene nodes, or a different renderer. @@ -81,8 +107,10 @@ The fastest way to render too much data is to avoid rendering it: - use facets only when each panel remains interpretable; - virtualize application chrome and lanes when only a subset is visible. -Every visible SVG node carries paint, memory, hit-testing, and accessibility -cost. More nodes are justified only when they communicate more information. +Every visible SVG node carries DOM and paint cost. Canvas removes the +per-element DOM cost, but not scene construction, draw work, interaction-point +memory, or visual overplotting. More marks are justified only when they +communicate more information. See [Large Data](./large-data.md) for representation thresholds and interaction policies. 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 22ba36f..6a60259 100644 --- a/packages/charts-core/docs/guides/custom-marks-and-renderers.md +++ b/packages/charts-core/docs/guides/custom-marks-and-renderers.md @@ -1,13 +1,14 @@ --- title: Custom Marks and Renderers -description: Extend the grammar with typed mark channels and keyed scene nodes, or replace SVG serialization without bypassing chart semantics. +description: Extend the grammar with typed mark channels and keyed scene nodes, or replace the mounted renderer without bypassing chart semantics. --- -Use a custom mark when a visualization fits the chart's cartesian scale and -scene model but is not expressible as a useful composition of built-in marks. +Use a custom mark when a visualization fits the shared scene model but is not +expressible as a useful composition of built-in Cartesian, polar, or +geographic marks. -Use a custom renderer when the same chart scene needs different SVG -serialization or resources. +Use a custom renderer when the same chart scene needs a different mounted +surface. Use a custom SVG serializer when only SVG markup or resources differ. Neither extension should reach into private scene compiler state. @@ -21,6 +22,8 @@ Before creating a mark, check whether the result is a combination of: - rules, links, ticks, arrows, or vectors; - text or frames; - facets. +- polar arcs, radial paths, dots, or guides; +- projected GeoJSON. Composition retains built-in type inference, focus metadata, animation, and subpath bundle boundaries. The [chart examples](../examples/index.md) show @@ -144,7 +147,30 @@ bundle must not pay for a custom scale registered elsewhere. See [Scales and D3](../concepts/scales-and-d3.md) and [Legends and Color](./legends-and-color.md). -## Custom SVG renderer +## Custom renderer + +A full renderer implements `ChartRenderer` and returns a `ChartSurface`: + +```ts +import { mountChartRenderer } from '@tanstack/charts/renderer' + +const host = mountChartRenderer(container, { + definition, + renderer: myRenderer, + ariaLabel: 'Threshold history', +}) +``` + +The renderer owns server shell markup, its mounted element, scene painting, +coordinate conversion, focus painting, and cleanup. The host retains sizing, +runtime, keyboard, tooltip, selection, and focus-strategy behavior. Keep +`prerender` deterministic and make `mount` adopt compatible server markup. + +Use `ChartRendererRenderContext.surface` instead of assuming `onRender` exposes +an SVG element. Framework consumers pass `renderer` through +`@tanstack/react-charts/core` or `@tanstack/octane-charts/core`. + +## Custom SVG serializer A `ChartSvgRenderer` accepts the complete `ChartScene` and accessible SVG options: diff --git a/packages/charts-core/docs/guides/exporting.md b/packages/charts-core/docs/guides/exporting.md index 709e579..bf0ac8e 100644 --- a/packages/charts-core/docs/guides/exporting.md +++ b/packages/charts-core/docs/guides/exporting.md @@ -3,10 +3,11 @@ title: Exporting description: Export accessible chart SVG or browser-rendered raster images while preserving dimensions, styling, and resource identity. --- -TanStack Charts has two export paths: +TanStack Charts' built-in export helpers have three paths: - render a `ChartScene` directly to an SVG string; -- serialize or rasterize an SVG that is already mounted in a browser. +- serialize an SVG that is already mounted in a browser; +- rasterize a mounted SVG or Canvas chart. Choose based on whether export needs computed browser styles. @@ -85,6 +86,20 @@ await downloadChartImage(chartContainer, 'quarterly-revenue.png', { 2 produces a 2400 × 1350 canvas while retaining the 1200 × 675 visual coordinate system. +## Export a Canvas chart + +Pass the Canvas root or an ancestor containing it to the same +`renderChartImage` or `downloadChartImage` functions. The exporter draws the +base scene layer at the requested dimensions and scale. Set +`includeFocus: true` to composite the focus overlay; it is excluded by +default. + +Canvas focus is painted on a separate overlay so pointer movement does not +repaint the base scene. Applications that need only the raw base bitmap may +also call `toBlob()` or `toDataURL()` on +`CanvasChartSurface.canvas`. Unlike SVG serialization, Canvas export does not +retain vector geometry, accessible markup, or independently styleable nodes. + ## Theme and resource policy Export the theme intended for the artifact. A chart following application dark @@ -114,6 +129,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. +- A Canvas export intentionally includes or excludes the focus overlay. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/packages/charts-core/docs/guides/large-data.md b/packages/charts-core/docs/guides/large-data.md index 611fd4e..2c35d0e 100644 --- a/packages/charts-core/docs/guides/large-data.md +++ b/packages/charts-core/docs/guides/large-data.md @@ -23,7 +23,8 @@ Track four quantities: - **source**: rows received by the application; - **represented**: source rows accounted for by the encoding; - **prepared**: rows or vertices passed to marks; -- **rendered**: SVG nodes and path vertices. +- **rendered**: scene nodes and path vertices, plus the selected renderer's DOM + or draw work. Do not describe a million-row chart as a million rendered points when the visible result is a few thousand aggregate cells. The bounded output is a @@ -64,6 +65,24 @@ the same pixels. Switch representations before the raw update cost exceeds the product's frame budget or the output stops being readable. +## When Canvas helps + +The optional Canvas renderer removes per-node SVG DOM and reconciliation cost. +It can be useful when a measured bottleneck is SVG element creation or paint. +It does not remove channel materialization, scale and guide work, scene +compilation, path construction, or `ChartPoint` creation. + +A dot mark still produces one scene node and one interaction point per +observation. Default nearest-point focus still scans those points linearly. +Supply a measured `spatialIndex` when individual points are still the correct +pointer target, and use a focus strategy with a bounded `navigation` order +when every observation is not a useful keyboard stop. + +Do not treat Canvas as permission to promise a million independently +interactive marks. Compare source, represented, prepared, and rendered counts; +measure compilation, paint, interaction, and memory; then aggregate, sample, +or bound the window when the representation exceeds the product budget. + ## Preparation ownership Put width-independent aggregation in a dynamic definition's `prepare` phase. @@ -108,10 +127,12 @@ target. - Provide drill-down or a linked table when readers need original rows. - Use a spatial index only when individual points remain the correct representation. +- Keep keyboard navigation bounded to useful targets instead of stepping + through every source observation. - Keep exact values outside hover-only UI. -An index can accelerate nearest-point lookup. It does not reduce DOM count, -path serialization, paint cost, or visual overplotting. +An index can accelerate nearest-point lookup. It does not reduce scene size, +DOM or draw count, path construction, paint cost, or visual overplotting. ## Streaming windows @@ -136,6 +157,7 @@ Separate: - scene compilation; - SVG serialization; - DOM reconciliation; +- Canvas painting; - pointer activation and sustained movement; - memory after repeated updates and destroy. diff --git a/packages/charts-core/docs/guides/ssr-and-hydration.md b/packages/charts-core/docs/guides/ssr-and-hydration.md index 10ee32b..13dd9ad 100644 --- a/packages/charts-core/docs/guides/ssr-and-hydration.md +++ b/packages/charts-core/docs/guides/ssr-and-hydration.md @@ -3,10 +3,10 @@ title: SSR and Hydration description: Render deterministic chart markup on the server, preserve runtime work through hydration, and handle responsive dimensions without mismatches. --- -TanStack Charts builds a platform-neutral scene before it renders SVG. React -and Octane use the same runtime implementation and SVG path on the server and -in the browser, so a chart does not need a browser-only substitute during -server rendering. +TanStack Charts builds a platform-neutral scene before the selected renderer +produces output. React and Octane use the same runtime and renderer on the +server and in the browser, so a chart does not need a browser-only substitute +during server rendering. ## Give the server a real size @@ -62,6 +62,18 @@ Do not conditionally replace a chart with a different component only because the code is executing on the server. That creates a different tree and gives up the shared render path. +## Canvas server shell + +`@tanstack/react-charts/canvas` and `@tanstack/octane-charts/canvas` render a +deterministic accessible shell on the server: a named chart root and two +`aria-hidden` canvas elements with the initial scene dimensions. No server +Canvas API or pixel painting is required. + +The client renders the same shell, adopts its existing root and canvases, sizes +their backing stores for the device-pixel ratio, paints the scene, and attaches +the shared interaction host. The first image appears after client mount; use +the default SVG adapter when visible server-rendered geometry is required. + ## Fonts and text measurement Automatic guide margins depend on text metrics. The server uses deterministic @@ -96,9 +108,10 @@ const svg = renderChartSvg(scene, { runtime.destroy() ``` -`renderChartSvg` returns a string and does not require a DOM. Browser-only -focus, tooltip, reconciliation, animation, and image export begin at -`mountChart`. +`renderChartSvg` returns a string and does not require a DOM. A custom +`ChartRenderer.prerender` may produce another deterministic shell. Browser-only +focus, tooltip, reconciliation or paint, animation, and export begin when its +surface mounts. ## Hydration checklist diff --git a/packages/charts-core/docs/guides/testing-and-debugging.md b/packages/charts-core/docs/guides/testing-and-debugging.md index d1dad11..611a04e 100644 --- a/packages/charts-core/docs/guides/testing-and-debugging.md +++ b/packages/charts-core/docs/guides/testing-and-debugging.md @@ -3,7 +3,7 @@ title: Testing and Debugging description: Test chart semantics, geometry, interaction, updates, accessibility, and performance without relying on brittle screenshots alone. --- -A useful chart test answers more than “did an SVG appear?” Test at the +A useful chart test answers more than “did a surface appear?” Test at the narrowest layer that owns the behavior. ## Test the scene first @@ -65,6 +65,17 @@ Mount into a real or sufficiently complete DOM when behavior depends on: Always call `destroy()` and verify observers, frames, tooltip nodes, and event listeners do not survive. +Run the same focus, keyboard, tooltip, selection, responsive-update, and +destroy sequences against SVG and Canvas when renderer parity matters. Those +behaviors belong to the shared host; renderer tests should concentrate on +surface adoption, coordinate conversion, paint, and cleanup. + +For Canvas, test deterministic server-shell markup without installing Canvas +APIs. Use a Canvas 2D mock for draw-call and device-pixel-ratio assertions, then +use browser screenshots for representative paths, clipping, gradients, text, +themes, and focus-overlay composition. Do not use a raw pixel or data-URL +snapshot as the only semantic assertion. + ## Test interaction as a sequence Describe the user path and its semantic assertion: @@ -98,7 +109,7 @@ semantically important error cannot hide in the diff threshold. At minimum verify: -- the SVG has a meaningful accessible name; +- the SVG or Canvas root has a meaningful accessible name; - descriptions summarize the current chart, not every point; - keyboard focus can enter and traverse the chart when enabled; - focus and selection callbacks expose the same data as pointer interaction; @@ -116,12 +127,12 @@ When output is wrong, inspect in this order: 3. Resolved scale domains, ranges, bandwidths, and ticks. 4. `scene.chart` bounds and margins. 5. Scene nodes and interaction points. -6. Serialized SVG. -7. Mounted DOM and application CSS. +6. Renderer input and static output. +7. Mounted surface and application styling. -If the scene is correct but the DOM is wrong, the defect belongs to rendering, -reconciliation, or CSS. If the channel values are wrong, changing the SVG -renderer will not fix the cause. +If the scene is correct but the surface is wrong, the defect belongs to +rendering, reconciliation or paint, or styling. If the channel values are +wrong, changing the renderer will not fix the cause. ## Performance tests need correctness gates diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index 9a085bb..f15b6c3 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -1,6 +1,6 @@ --- title: Themes and Styling -description: Apply automatic light and dark color behavior, CSS palette tokens, mark styles, and scoped SVG resources. +description: Apply automatic light and dark color behavior, CSS palette tokens, mark styles, and renderer-aware resources. --- TanStack Charts inherits the surrounding application instead of installing a @@ -76,7 +76,7 @@ time. ## Mark styling -Built-in marks expose the SVG styles relevant to their geometry: fill, stroke, +Built-in marks expose the paint styles relevant to their geometry: fill, stroke, opacity, widths, line caps, dashes, corner radius, and font properties. A style can be fixed or data-driven where the mark's option accepts a visual channel. @@ -90,6 +90,18 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Canvas styling + +The Canvas renderer resolves scene paints such as `currentColor` and CSS +custom properties against the chart's computed environment. It inherits the +root font and repaints after relevant ancestor class, style, `data-theme`, +color-scheme, forced-colors, or viewport changes. + +Rasterized scene nodes are not DOM descendants. A node's `className` therefore +cannot be targeted by a CSS selector after paint. Put data-dependent fill, +stroke, opacity, and font choices in mark options or the chart theme; use +container CSS for palette variables, inherited color, and typography. + ## Gradients and clipping Gradients are opt-in SVG resources. Declare them on the chart and render with @@ -125,6 +137,10 @@ scopes resource and clip IDs when several charts share a document. Set `clip: true` when marks should be clipped to the resolved plot rectangle. Clipping is a geometry policy, not a substitute for correct scale domains. +Canvas consumes the same declared gradients and group clips without the +resource-aware SVG serializer. A Canvas gradient needs measurable node bounds; +path-only geometry with no point bounds should use an explicit paint instead. +