diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index caa747ea..e1f5f808 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -30,6 +30,7 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: + fetch-depth: 0 persist-credentials: false - run: corepack enable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b1155a0a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +name: Publish release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: charts-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + registry-url: https://registry.npmjs.org + cache: '' + + - name: Verify tagged main revision + run: node scripts/verify-release-revision.mjs + + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + - run: pnpm docs:check + - run: pnpm typecheck + - run: pnpm test + - run: pnpm release:artifacts + - run: pnpm release:check + + - name: Upload checked package artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: charts-release-${{ github.ref_name }} + path: .release-artifacts + if-no-files-found: error + include-hidden-files: true + + publish: + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + registry-url: https://registry.npmjs.org + cache: '' + + - name: Download checked package artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: charts-release-${{ github.ref_name }} + path: .release-artifacts + + - name: Revalidate refs and publish with OIDC + run: | + node scripts/verify-release-revision.mjs + node scripts/publish-release.mjs + + verify: + needs: publish + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + registry-url: https://registry.npmjs.org + cache: '' + + - name: Download checked package artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: charts-release-${{ github.ref_name }} + path: .release-artifacts + + - name: Verify installed packages, signatures, and provenance + run: node scripts/verify-published-release.mjs + + release: + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.18.0 + cache: '' + + - name: Revalidate refs and create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + node scripts/verify-release-revision.mjs + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + exit 0 + fi + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "TanStack Charts $GITHUB_REF_NAME" \ + --notes-file CHANGELOG.md diff --git a/.gitignore b/.gitignore index 3b69faff..e16f3b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist .bundle-output .benchmark-output .catalog-artifact +.release-artifacts coverage *.log tanstack.com-parity/ diff --git a/API-FRICTION.md b/API-FRICTION.md index 01d02137..37862b29 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -170,6 +170,11 @@ Each entry records: | F-132 | Factory unions disrupt D3's generic inference | API | monitoring | | F-133 | Clipped ancestors trapped native tooltips | API | resolved | | F-134 | Demo fixtures modeled charts instead of source data | Docs/Tooling | resolved | +| F-135 | Published release lacked a repository baseline marker | Tooling/Release | monitoring | +| F-136 | Comparison conflated workspace and published source | Tooling/Docs | resolved | +| F-137 | Latest docs installed an incompatible published API | Docs/Release | monitoring | +| F-138 | Publisher pin predated explicit trust permissions | Tooling/Release | resolved | +| F-139 | Top-level entries bypassed tarball validation | Tooling/Release | resolved | ## Findings @@ -2599,17 +2604,17 @@ Each entry records: - Owner: API/Documentation - Observed in: authoring the runtime, SSR, and TypeScript reference pages - Friction: `createChartRuntime()` is called before `runtime.render()` receives - a definition, so TypeScript cannot infer the datum, input, x-value, and - y-value types from that later call. The low-level direct-runtime examples - required all four generic arguments even though normal hosts and adapters - infer them from `definition`. -- Decision: document the four explicit generics at the advanced direct-runtime + a definition, so TypeScript cannot infer the datum, x-value, and y-value types + from that later call. The low-level direct-runtime examples require all three + generic arguments even though normal hosts and adapters infer them from + `definition`. +- Decision: document the three explicit generics at the advanced direct-runtime boundary and keep the common host and adapter paths fully inferred. Do not add a definition token or second runtime-construction shape until repeated direct-runtime use shows that the extra API surface would pay for itself. -- Verification: the runtime, SSR, and TypeScript pages use the exact generic - order, while host, React, Octane, and packed declaration tests continue to - prove definition-driven inference without casts or adapter generics. +- Verification: the runtime and SSR pages use the exact generic order, while + host, React, Octane, and packed declaration tests continue to prove + definition-driven inference without casts or adapter generics. ### F-114 — Gradient stop tokens disappeared from standalone exports @@ -2640,19 +2645,21 @@ Each entry records: also accepted without proving the destination existed. Plausible, copyable snippets could pass with a wrong type import or a bare object-property fragment that was not valid TypeScript, while cross-page navigation could - silently land at the top. + silently land at the top. Primary README examples could still be + syntactically valid while passing an option to the wrong API boundary. - Decision: parse typed code fences in canonical docs and public READMEs, reject syntax diagnostics, resolve every TanStack specifier through its package manifest, and validate every named value and type import against that source entry. Resolve local Markdown fragments against generated heading anchors, including duplicate-heading suffixes. Designate primary - standalone examples for strict TypeScript checking; compile the Octane - quick start in both client and server modes. + standalone examples in canonical docs and public READMEs for strict + TypeScript checking; compile the Octane quick start in both client and server + modes. - Verification: the contract rejects invalid typed syntax, unknown subpaths, unknown symbols, and missing headings; helper tests cover syntax, - named-import extraction, heading slugs, and example discovery; 16 - executable examples, all 79 canonical pages, and the public READMEs pass - against the current package manifests. + named-import extraction, heading slugs, and example discovery; 17 executable + examples, all 81 canonical pages, and the public READMEs pass against the + current package manifests. ### F-116 — Build context was mistaken for resolved plot geometry @@ -2764,7 +2771,7 @@ Each entry records: preserved ownership but necessarily replaced the site's chrome, routing, headers, cache policy, and content delivery behavior. - Decision: treat the catalog as generated structured content. Charts CI builds - schema-v3 `catalog.json` plus only the recursively allowlisted implementation + schema-v4 `catalog.json` plus only the recursively allowlisted implementation modules, then replaces the generated `catalog-dist` branch after validation and the unfiltered conformance matrix. TanStack.com's existing content pipeline reads that branch, verifies hashes and limits, renders native routes @@ -2782,9 +2789,15 @@ Each entry records: Main-branch CI uploads the validated artifact and publishes only `catalog.json` and `assets/*.js` to `catalog-dist`. The publication workflow pins every third-party action to a full commit SHA, as required by the - repository's Actions policy. -- Follow-up: keep monitoring through the TanStack.com cutover, production route - verification, and retirement of the previously deployed catalog Worker. + repository's Actions policy. The TanStack.com consumer accepts the existing + schema-v2 publication and the schema-v4 replacement through separate strict + validators, applies their respective 5 MiB and 6 MiB limits, fetches complete + v4 source closures, excludes harness source, and renders dataset provenance + without raw rows. Its focused catalog tests pass 67 cases with site + typechecking and lint. +- Follow-up: land and deploy the TanStack.com consumer before publishing the + schema-v4 artifact, then verify the production routes before removing + schema-v2 compatibility. ### F-120 — Key-only focus collapsed duplicate observations @@ -3077,7 +3090,10 @@ Each entry records: typecheck, packed declaration/runtime consumers, all seven adapter packages, documentation contracts, formatting, and bundle policy pass. Catalog case 35 passes visual and interaction checks in Chromium at both quick-profile - widths. + widths. The cross-library fixtures now configure tooltip, keyboard, focus, + and animation on each definition through the typed `defineChart` overload; + a typed host-options boundary prevents behavior from drifting back to + adapter props. ### F-131 — Stable identity repeated inferable key channels @@ -3207,11 +3223,128 @@ Each entry records: have been audited: there are no case-local `data.ts` modules or `./data` imports, 25 explicit `selection.ts` modules, and only the two authored interaction-state exceptions named `scenario.ts`. The React, Octane, and - sandbox showcases import source-shaped demo rows. Demo-data sync, metadata, - schema, hash, exact-subpath, and compact-large-CSV tests pass. The catalog - source-view and schema-v4 artifact checks pass at 100 cases and 5.45 MiB. - The full 100-case Chromium matrix renders without gaps, all 16 interaction - cases pass, strict sources produce zero diagnostics or unsafe assertions, - and mean frame-relative geometry similarity is 96.7%. Root unit tests, - typecheck, docs sync, production builds, packed consumers, bundle budgets, - and all seven framework adapter package gates pass. + sandbox showcases import source-shaped demo rows. Public documentation and + READMEs instead use small typed inline data, and the documentation contract + rejects private workspace imports in public code fences. Demo-data sync, + metadata, schema, hash, exact-subpath, and compact-large-CSV tests pass. The + catalog source-view and schema-v4 artifact checks pass at 100 cases and 5.45 + MiB. The full 100-case Chromium matrix renders without gaps, all 16 + interaction cases pass, strict sources produce zero diagnostics or unsafe + assertions, and mean frame-relative geometry similarity is 96.7%. Root unit + tests, typecheck, docs sync, production builds, packed consumers, bundle + budgets, and all seven framework adapter package gates pass. + +### F-135 — The published release had no repository baseline marker + +- Status: monitoring +- Severity: high +- Owner: Tooling/Release +- Observed in: generating the post-`0.0.0` release changelog +- Friction: npm contains one published version of every product package, but + the repository has no `0.0.0` tag or GitHub release and the npm metadata has + no `gitHead`. An initial history audit therefore treated the repository's + first commit as the release baseline and incorrectly included 11 commits + that were already present in the published packages. +- Decision: use commit `58ee1e2` as the verified `0.0.0` source baseline. For + future releases, record the exact source revision in the package provenance + and create the matching repository tag before generating the next changelog. +- Verification: npm timestamps place the `@tanstack/charts` and + `@tanstack/react-charts` publication after `58ee1e2` and before the next + repository commit. The published core README, React README, and core + chart-definitions documentation are byte-identical to their `58ee1e2` + sources. The corrected changelog contains exactly the nine commits in + `58ee1e2..a91106c`. +- Follow-up: verify the `0.0.1` tag, GitHub release, and package provenance all + identify the exact release commit, then resolve this finding. + +### F-136 — Comparison conflated workspace and published source + +- Status: resolved +- Severity: high +- Owner: Tooling/Documentation +- Observed in: final release-note and bundle-provenance audit +- Friction: the comparison page and tracked bundle baseline labeled TanStack as + `@tanstack/charts@0.0.0`, but the benchmark imports current workspace source. + The published `0.0.0` artifact comes from `58ee1e2`; the measured source comes + from `a91106c` plus a release-preparation fixture correction. +- Decision: identify TanStack as workspace source and competitors as pinned npm + packages. Bundle-baseline schema 3 records package manifest versions + separately from source provenance. Derive the TanStack revision from the last + commit that changed core source or any transitive TanStack comparison input, + rather than the release branch head, so documentation-only commits do not + stale measured evidence. +- Verification: the public comparison names the measured TanStack revision, + the tracked baseline records the `0.0.1` manifest version separately from + exact `99c08eb` comparison-input revision, and the deterministic comparison + check rejects missing, malformed, or mismatched provenance. Its CI checkout + retains the history required to resolve that revision. + +### F-137 — Latest docs installed an incompatible published API + +- Status: monitoring +- Severity: high +- Owner: Documentation/Release +- Observed in: final public documentation audit before `0.0.1` +- Friction: canonical docs and public README examples used the post-`0.0.0` + definition, behavior, and scale contracts, while unqualified install commands + resolved to the earlier public `0.0.0` packages. Most framework adapters were + not published yet. +- Decision: keep the temporary unreleased-source distinction until `0.0.1`, + then make the README, installation, overview, quick-start, comparison, and + marketing copy describe the coordinated core and adapter release. +- Verification: the `0.0.1` release-candidate documentation no longer directs + people to wait for another release. Core and adapter installation commands + match the intended tarballs, the React commands include React DOM and its + types, executable examples pass the documentation contract, and generated + mirrors remain synchronized. +- Follow-up: after publication, install every `0.0.1` package from npm, verify + the documented entry points and peers, then resolve this finding. Keep future + public documentation deployments coupled to the npm release they describe. + +### F-138 — The publisher pin predated explicit trust permissions + +- Status: resolved +- Severity: high +- Owner: Tooling/Release +- Observed in: configuring npm trusted publishing for `0.0.1` +- Friction: the release workflow pinned npm `11.12.1`, whose `npm trust` + interface predated required per-action permissions. The initial publisher + request completed two-factor authentication but returned an opaque HTTP 400 + instead of identifying the missing `--allow-publish` permission. +- Decision: configure trust with npm `11.18.0`, give each public package an + explicit publish permission, and keep npm installation outside the + OIDC-enabled job. The release checks that Node's bundled npm meets the + trusted-publishing minimum instead of replacing the CLI while a job can mint + identity tokens. +- Verification: `npm trust list` reports the exact `TanStack/charts` + repository, `release.yml` workflow, and `createPackage` permission for core + and all nine public framework adapters. The workflow has one OIDC-enabled + job; it checks out the exact release SHA, downloads already-checked + artifacts, installs no dependencies, revalidates the protected remote tag + and main immediately before publishing, and delegates package installation + and signature verification to a post-publish job without OIDC. That job + requires npm to verify every release package's attestation bundle and then + matches the fetched bundles before checking their exact package, digest, + repository, workflow, tag, and commit claims. + +### F-139 — Top-level package entries bypassed tarball validation + +- Status: resolved +- Severity: high +- Owner: Tooling/Release +- Observed in: inspecting the `0.0.1` Svelte release-candidate tarball +- Friction: pnpm correctly replaced conditional exports with their + `publishConfig` targets, but retained the independent top-level `svelte` + field as `./src/index.ts`. The package excludes `src`, so Svelte tooling + using that field would resolve a file absent from the published tarball. + Release gates only checked conditional export targets and did not detect the + broken entry. +- Decision: point the Svelte package field at `./dist/index.js`. The release + artifact validator now reads the actual tar inventory and requires every + scalar top-level `main`, `module`, `browser`, `types`, `typings`, `svelte`, + and `style` entry to identify a packed file. +- Verification: a focused regression reproduces and rejects the missing + `./src/index.ts` entry, covers every validated field, and accepts entries + present in the archive. The rebuilt Svelte tarball contains its + `./dist/index.js` entry, and the focused package and release-artifact gates + pass. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..512f54d7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,577 @@ +# Changelog + +## 0.0.1 (2026-07-30) + +`0.0.1` is the first coordinated update after the public `0.0.0` release. The +verified `0.0.0` baseline is +`58ee1e28e469f8ab28f99877a6e0abc3958977a4`, not the initial repository +commit. + +- npm published `@tanstack/charts@0.0.0` at + `2026-07-29T18:42:40Z`, followed through `18:43:13Z` by the React, Octane, + Preact, Vue, Solid, Svelte, Angular, Lit, and Alpine adapters. +- The published core and React README hashes, and the published core + chart-definitions documentation hash, match commit `58ee1e2` exactly. +- No later commit existed before those packages were published. + +The audited product implementation range ends at +`a91106c34d654ea625a2f540f647222bd72bc0fc` and contains exactly nine +commits. Release-preparation corrections in this branch update the +documentation, comparison fixture and baseline, and this changelog after that +implementation range. + +[Compare the audited product range.](https://github.com/TanStack/charts/compare/58ee1e28e469f8ab28f99877a6e0abc3958977a4...a91106c34d654ea625a2f540f647222bd72bc0fc) + +### Release scope and gates + +- `0.0.1` publishes `@tanstack/charts` and the React, Octane, Preact, Vue, + Solid, Svelte, Angular, Lit, and Alpine adapters as one versioned set. Every + adapter depends exactly on `@tanstack/charts@0.0.1`. +- The comparison bundle review confirmed that basic TanStack line, bar, area, + and scatter consumers now measure 24.19–24.81 KiB gzip. The increase comes + from the reviewed definition-behavior, key-inference, scale-inference, and + portal-tooltip code rather than an accidental dependency. The comparison + fixture now configures behavior on the definition instead of the removed + host boundary, and the refreshed baseline passed the full pull-request + validation, comparison, conformance, and stress workflow. +- The tanstack.com schema-v4 catalog consumer must be deployed and verified + before the Charts release reaches `main`, because the successful main-branch + workflow publishes the schema-v4 artifact to `catalog-dist`. Keep schema-v2 + compatibility until the production catalog and embed routes are verified. +- The release commit must pass the same package, documentation, bundle, + comparison, catalog, stress, and conformance gates before its tag, GitHub + release, and npm provenance identify that exact revision. +- `@charts-poc/demo-data` remains private. It is a catalog, example, and + validation fixture package, not a production dependency or release target. +- `@tanstack/charts-d3` remains a private superseded experiment and is not a + release target. + +### Package impact + +All product packages in this table already existed at the published baseline. +This range updates them; it does not introduce new adapters. + +| Package | 0.0.1 update | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | +| `@tanstack/charts` | Definition-owned reactivity and behavior, inferred keys and scale domains, portal tooltips, typed data | +| `@tanstack/react-charts` | Updated definition contract, composed tooltip bodies, portal mounting, and a `react-dom` peer | +| `@tanstack/octane-charts` | Updated definition contract across SVG, Canvas, and renderer entries, plus composed tooltip bodies | +| `@tanstack/preact-charts` | Updated definition contract, SVG adoption behavior, and composed tooltip bodies | +| `@tanstack/vue-charts` | Updated definition contract, SSR forwarding fixes, and a scoped tooltip-body slot | +| `@tanstack/solid-charts` | Updated definition contract, stable SSR IDs, and composed tooltip bodies | +| `@tanstack/svelte-charts` | Updated definition contract, hydration behavior, and a tooltip-body snippet | +| `@tanstack/angular-charts` | Updated definition contract and a typed projected tooltip template directive | +| `@tanstack/lit-charts` | Updated definition contract and composed tooltip rendering through options | +| `@tanstack/alpine-charts` | Updated definition contract and DOM tooltip-body rendering | + +## Breaking changes and migrations + +### Definition identity is now the application reactivity boundary + +The runtime no longer accepts formal chart input, prepares data, or compares +input values. A definition captures the application values it uses. Recreate +or memoize the complete definition when those values change. A responsive +builder still reruns when its surface size or build-time theme changes. + +Before: + +```tsx +const definition = defineChart()({ + prepare: (rows) => summarize(rows), + prepareEqual: Object.is, + inputEqual: Object.is, + chart: ({ input, prepared, width, height, theme }) => ({ + marks: createMarks(input, prepared), + x: createXScale(width), + y: createYScale(height, theme), + }), +}) + + +``` + +After: + +```tsx +function createRevenueDefinition(rows: readonly Row[]) { + const prepared = summarize(rows) + + return defineChart({ + marks: createMarks(rows, prepared), + x: { scale: scaleUtc, nice: true }, + y: { scale: scaleLinear, nice: true }, + }) +} + +const definition = useMemo(() => createRevenueDefinition(rows), [rows]) + + +``` + +Migration details: + +- Remove `input` from DOM-host and framework-adapter options. +- Move transforms beside `defineChart`, or into the framework memo or computed + primitive that creates the definition. +- Replace `prepare`, `prepareEqual`, and `inputEqual`. Prepared-data caching + and the preparation `AbortSignal` no longer exist. +- Remove uses of `ChartPrepareContext`, `chartInputsEqual`, and + `shallowInputEqual`. +- Replace `StaticChartHostOptions`, `DynamicChartHostOptions`, + `StaticChartProps`, and `DynamicChartProps` with the unified host and adapter + contracts. +- Read only `width`, `height`, and `theme` from `ChartBuildContext`. +- Call `runtime.render(definition, size, layout?)`; the runtime no longer + accepts an input argument. +- Update low-level generic arguments. `ChartDefinition`, + `DynamicChartDefinition`, `ChartRuntime`, hosts, adapters, and points now + describe datum, x-value, and y-value types rather than input and + prepared-data types. +- For vanilla updates, create the next definition and pass it through + `host.update`. +- For framework updates, memoize the complete definition against every + application value it captures. + +`DynamicChartConfig` remains public. It combines a responsive +`chart(context)` builder with definition-owned behavior options. + +### Chart behavior moved from hosts into definitions + +Focus, tooltip, animation, keyboard, focus-distance, and spatial-index policy +are reusable chart behavior. Hosts and framework components no longer accept +`focus`, `maxFocusDistance`, `spatialIndex`, `animate`, `keyboard`, or +`tooltip`. + +Before: + +```tsx + +``` + +After: + +```tsx +const interactiveDefinition = defineChart(definition, { + focus: 'nearest-x', + tooltip: true, + animate: { duration: 180 }, + keyboard: true, +}) + + +``` + +Static definitions may declare behavior directly. Responsive definitions place +behavior beside `chart`. `defineChart(existingDefinition, options)` creates a +separately configured definition without moving surface policy back into the +adapter. + +The host still owns: + +- dimensions and aspect ratio; +- accessible labels and descriptions; +- surface class names and styles; +- focus, grouped-focus, selection, render, and tooltip-body callbacks; +- text measurement; +- renderer selection. + +The `focus` option now accepts `nearest`, `nearest-x`, `nearest-y`, `group-x`, +and `group-y`, as well as a custom focus strategy. + +Enabled tooltips are pinnable by default. Click, Enter, or Space pins the +current tooltip; Escape or a composed body's `dismiss` callback clears it. Set +`tooltip: { sticky: false }` for transient-only behavior. + +### Resize animation is opt-in + +`ChartAnimationOptions.resize` now defaults to `false`. + +- Definition changes may still animate. +- Responsive observation and explicit size changes commit immediately unless + `animate.resize` is `true`. +- Incompatible layout changes never interpolate. +- Interrupted animations still begin from currently painted geometry. +- Renderer completion is associated with the render reason so an older + animation cannot overwrite a newer immediate resize. + +Applications that deliberately animate size changes must opt in: + +```ts +const definition = defineChart(chart, { + animate: { + duration: 180, + resize: true, + }, +}) +``` + +### Built-in marks infer stable identity + +Built-in marks no longer fall back immediately to row index. Identity resolves +in this order: + +1. An explicit `key` +2. A unique primitive `datum.id` +3. A unique primitive `datum.data.id` +4. A unique mark-specific positional candidate +5. Row index + +Mark-specific candidates: + +- bars use their categorical channel; +- lines and areas use their independent axis; +- rects and cells use the complete x/y interval tuple; +- dots and text try x, then y, then the x/y tuple; +- polar lines, areas, and rules use angle; +- D3 pie-backed polar arcs can recover nested source identity. + +A candidate must be complete and unique inside its interaction group. +Development builds warn once per mark instance when a positional candidate is +missing or duplicated and identity falls back to row position. + +Remove redundant keys when an ID or semantic position already identifies the +datum. Keep an explicit key when position can change independently of entity +identity. Use `key: (_datum, index) => index` only when positional identity is +intentional. + +### D3 scale factories now request domain inference + +Positional, color, radius, and polar scale options distinguish a factory from +a configured instance. + +```ts +const inferred = { + x: { scale: scaleUtc, nice: true }, + y: { scale: scaleLinear, nice: true }, +} + +const configured = { + x: { scale: scaleUtc().domain(fixedDateDomain) }, + y: { scale: scaleLinear().domain([0, fixedMaximum]) }, +} +``` + +Migration rules: + +- Pass `scaleLinear`, `scaleUtc`, or another zero-argument D3 factory when the + domain should follow mark channels. +- Use a wrapper such as `() => scaleBand().padding(0.2)` when the + factory needs pre-domain configuration. +- Continue passing a configured D3 instance when the application owns a fixed + domain. +- Do not pass `scaleLinear()` and expect data inference. It is a configured + instance and retains D3's default domain until the application changes it. +- Move nicening for inferred axes to `nice?: boolean | number`. +- Keep every granular `d3-*` module imported by application source as a direct + application dependency. + +Inference behavior: + +- continuous and temporal axes use the finite extent of every materialized + channel on that axis; +- band and point axes retain distinct values in first-seen order; +- implicit bar and area baselines contribute zero; +- empty channels retain the scale factory's native domain; +- configured instances are copied and never mutated; +- TanStack Charts owns responsive ranges, y orientation, reversal, band + centering, guide layout, and final tick placement. + +Validation is stricter: + +- inferred quantitative and temporal scales reject incompatible semantic + values instead of coercing them; +- an inferred log domain rejects zero or a domain that crosses zero; +- a bar or area with an implicit zero baseline cannot use an inferred log + scale; +- required-axis checks follow each mark's materialized dimensions; +- configuring an unused phantom axis is rejected. + +Color factories now infer ordinal, continuous, quantize, quantile, or threshold +domains and expose semantic scale kind and thresholds to legends. Radius +factories infer `[0, maximum]` while preserving configured output ranges. +Polar angle and radius scales use the same factory-versus-instance contract. + +### Point and scale types are more precise + +- `ChartPoint.xValue` and `yValue` preserve inferred `Date`, numeric, or + categorical values. +- `ChartMarkPointX` and `ChartMarkPointY` describe interaction anchors. +- `ChartMarkScaleX` and `ChartMarkScaleY` describe values materialized into + scales. +- `ChartMarkX` and `ChartMarkY` remain deprecated compatibility aliases. +- Rectangle and cell interval scale values are independent from their + interaction anchors. +- Custom marks whose interaction and scale values differ use + `createMarkWithScaleValues` from `@tanstack/charts/mark/scale-values`. +- Positionless polar and geo definitions may omit Cartesian axes. +- A one-dimensional mark requires only the dimension it materializes. + +## Core updates + +### Channel, interval, and layout refinements + +- Added an independent semantic `color` channel across existing Cartesian, + polar, and geographic marks. +- `z` remains geometry and interaction grouping. It is the color fallback when + `color` is omitted. +- For lines and areas, `color` groups paths only when `z` is absent. When both + exist, `z` groups geometry and `color` supplies scale semantics. +- Grouped bars use `z`, or `color` when no `z` is present. +- Final `fill` and `stroke` accessors remain paint overrides. +- Interaction-point colors now match the fill or stroke actually painted. +- Interval points now expose `x1Value`, `x2Value`, `y1Value`, `y2Value`, and + `xInterval` or `yInterval` range and difference semantics. +- Channel field types reject datum fields incompatible with the mark channel. +- Inference carries datum and coordinate types through focus, tooltip, + spatial-index, selection, adapter, and renderer callbacks. +- Custom marks may provide `layoutLabels`, allowing data-bound labels, + including polar and geographic labels, to contribute to automatic margins. + +### Tooltip, focus, and interaction refinements + +The native tooltip model now provides structured content rather than only +plain text: + +- safe titles; +- ordered rows and swatches; +- channel and datum items; +- derived items; +- interval ranges and differences; +- `content`, `items`, `sort`, `anchor`, `placement`, and `offset` options; +- compatibility with existing `format` and `formatGroup` callbacks. + +Grouped tooltip rows can sort by color-domain order, focus order, or a custom +comparator. Anchors can follow the point, pointer, group center, or a custom +coordinate. Eight placements and ordered fallbacks support viewport collision +handling. + +`tooltip.portal` moves the native surface out of clipped stacking contexts. +The host uses the browser Popover top layer when available and a fixed +body-level fallback otherwise. Both paths: + +- map scene anchors to client coordinates; +- collide against the viewport; +- reposition on scroll, viewport resize, chart resize, and tooltip-content + resize; +- target the chart's owner document; +- clean up when the definition, renderer, or chart host changes. + +Framework adapters can compose native framework content into the core-owned +tooltip body. The body context contains `points`, structured `content`, +`defaultBody`, `pinned`, and `dismiss`. Transient custom bodies remain inert; +pinned bodies become nonmodal dialogs and may contain selectable or +interactive content. + +The renderer-neutral host exposes `onTooltipBodyChange` and +`ChartTooltipBodyTarget` for lower-level integrations. + +Additional interaction corrections: + +- duplicate public keys no longer collapse distinct observations during path + hover or responsive repaint; +- spatial-index option changes repaint active focus and tooltip state. + +## Framework adapter updates + +Every existing adapter moved to the definition-identity update contract and +removed formal input and host-owned behavior props. Callback types continue to +infer from the complete definition, and committed callbacks remain fresh +without forcing a new definition. + +- Shared adapter prerendering preserves `className`; Vue declares and forwards + it on both server and client paths. + +Adapter-specific tooltip composition: + +- React, Octane, Preact, and Solid expose `renderTooltipBody`. +- Vue exposes a scoped `#tooltipBody` slot. +- Svelte exposes a `tooltipBody` snippet. +- Angular exports `ChartTooltipBodyDirective`, with the definition as its + strict type witness. +- Lit accepts `options.renderTooltipBody`. +- Alpine accepts `renderTooltipBody` returning DOM content. + +React now declares `react-dom ^19.0.0` as a peer because composed tooltip bodies +use React portals. React and Octane preserve the existing default SVG, +`/canvas`, and renderer-neutral `/core` entry boundaries. + +## Catalog, demo data, and publication artifact + +The catalog already contained 100 cases at the published `0.0.0` baseline. +This range changes the data and publication model without claiming a catalog +count expansion. + +### Source-shaped demo data + +- Added the private `@charts-poc/demo-data` workspace package with 27 pinned + Observable datasets and a metadata subpath. +- Every dataset has an exact subpath export and records source URL, upstream + revision, record count, schema, byte size, license note, and SHA-256. +- Small snapshots are emitted as typed rows. +- Large CSV snapshots remain compact and parse only when their exact subpath is + imported. Sibling datasets and the CSV parser stay out of unrelated chunks. +- All 100 existing catalog cases were migrated away from case-local `data.ts` + fixtures and now import source-shaped data through exact subpaths. +- Case selection, sampling, joins, derived channels, normalization, layout, and + transforms remain visible in `selection.ts`, `transform.ts`, `layout.ts`, or + model modules. +- Only two authored interaction-state fixtures remain. They are explicitly + named `scenario.ts`, not observation data. +- React, Octane, and sandbox showcases now use pinned source-shaped datasets + instead of synthetic Stats-shaped fixtures. +- Demo data is externalized from renderer bundle measurement and is not a + production dependency of any Charts package. +- Deterministic sync, metadata, schema, hash, exact-subpath, and compact-CSV + tests cover the package. + +### Schema-v4 source and asset closures + +The existing generated-content publication pipeline now emits a schema-v4 +catalog artifact. + +- The artifact records the exact Charts revision, source repository, route and + embed contracts, renderer module contract, implementation counts, datasets, + authored-source metadata, and asset graph. +- Each case records TanStack and reference source closures by entry, support, + fixture, and harness role. +- Authored-source totals include transitive implementation and transform code + while excluding harness code and raw dataset rows. +- Published modules are recursively allowlisted with SHA-256, byte size, static + imports, and dynamic imports. +- Validation rejects unsafe paths, oversized or unreferenced assets, + inconsistent closures, and public comparison modules. +- The catalog source viewer exposes the same closure and dataset provenance + used by artifact validation. +- Loading checks preserve the existing contract: the normal catalog and embed + routes load only TanStack code; competitor code remains opt-in. + +The artifact cannot replace the production schema-v2 feed until tanstack.com +has deployed and verified its schema-v4 consumer. + +## Documentation, comparison, and lineage + +### Lineage + +- Acknowledgements now distinguish implementation lineage from conceptual + lineage. +- Public concepts, overview, and marketing material credit the + grammar-of-graphics tradition and its development through Leland Wilkinson, + ggplot2, Vega-Lite, and Observable Plot. +- Observable Plot remains identified as the closest API influence for + mark-local data, channels, and layered composition. + +### Public documentation gaps + +- Expanded the existing canonical documentation from 71 to 81 pages. +- Added the evidence-backed comparison page. +- Added missing framework adapter references and completed renderer, + controller, scale, color, tooltip, and public-type contracts. +- Documented definition identity, framework memoization, inferred stable keys, + D3 factory ownership, inferred domains, structured and portaled tooltips, + point-versus-scale types, SSR, hydration, and migration requirements. +- Corrected shared SSR `className` forwarding and documented the exact adapter + support boundary. + +Documentation validation now: + +- parses typed code fences; +- resolves TanStack imports through package manifests; +- verifies named value and type imports against the selected public entry; +- checks local heading fragments, including duplicate-heading suffixes; +- rejects name-only API inventories as reference coverage; +- typechecks designated standalone examples; +- compiles the Octane quick start in client and server modes; +- verifies generated package docs and both `llms.txt` indexes remain in sync. + +### Reproducible comparison evidence + +- The public comparison is generated from shared capability evidence rather + than a handwritten feature matrix. +- Bundle evidence covers Chart.js `4.5.1`, Apache ECharts `6.1.0`, Recharts + `3.10.1`, Observable Plot `0.6.17`, and TanStack Charts across line, bar, + area, and scatter at basic, interactive, and advanced tiers. +- Source accounting follows each implementation's transitive authored closure + and keeps dataset provenance separate from authored chart code. +- The current baseline records the reviewed 24.19–28.20 KiB complete-chart + range. Pull-request CI reproduced the bundle and comparison gates before the + release version was prepared. + +## Verification added or updated in the audited product range + +- Runtime, host, renderer, and framework tests cover definition identity, + behavior ownership, resize render reasons, animation interruption, key + inference, duplicate-key focus, tooltip pinning, portal placement, and + cleanup. +- Type-contract tests cover mark channels, point and scale value separation, + required axes, factory inference, configured instances, callback inference, + and adapter props. +- Scale tests cover quantitative, temporal, band, point, log, color, radius, + and polar inference, including empty and invalid domains. +- Packed-consumer tests cover the revised core, DOM, React, Octane, and adapter + declaration contracts without casts or private imports. +- Documentation checks cover 81 canonical pages, executable examples, public + package entries, exports, links, and generated mirrors. +- Catalog checks cover all 100 cases, source roles, exact dataset subpaths, + schema-v4 asset closures, source-view parity, and TanStack-only production + loading. +- The full catalog and interaction evidence completed during this range. + +## 0.0.1 release-preparation corrections + +- Updated the canonical docs, public READMEs, generated package mirrors, and + `llms.txt` indexes for the `0.0.1` API. +- Replaced temporary unreleased-source warnings with `0.0.1` installation + guidance, including the React DOM runtime and type peers required by the + React adapter. +- Replaced private demo-data imports in public examples with small, + self-contained typed datasets. +- Fixed the comparison fixture to configure behavior on definitions instead of + the removed host boundary. +- Refreshed the reviewed bundle baseline and recorded the TanStack workspace + revision separately from pinned competitor package versions. +- Updated the offline chart-authoring evaluation to target the `0.0.1` + definition, behavior, and scale contracts. +- Added this baseline-verified changelog and the required tanstack.com + consumer-before-catalog deployment order. + +## Audited product commit inventory + +1. `9d23a50` (2026-07-29), **Credit grammar-of-graphics lineage**: + clarified implementation and conceptual lineage across acknowledgements, + concepts, overview, and marketing. +2. `d2c4d44` (2026-07-29), **Close public documentation gaps**: + expanded the canonical reference, added evidence-backed comparison content, + tightened documentation contracts, and fixed public host, focus, export, + scale, and adapter gaps discovered while documenting the API. +3. `dc4bc70` (2026-07-29), **Make definition identity the chart reactivity + boundary**: removed formal input, preparation, equality, and cache APIs and + migrated runtimes, hosts, adapters, examples, and all catalog cases to + captured application values. +4. `235455f` (2026-07-29), **Disable chart animation during resize**: + classified render reasons, made responsive and explicit resize immediate by + default, and added `animate.resize` opt-in. +5. `b9e1886` (2026-07-29), **Move chart behavior into definitions**: + moved focus, tooltip, animation, keyboard, focus distance, and spatial + indexing out of hosts; added focus presets, pinnable tooltips, and richer + structured tooltip behavior. +6. `f07fdf2` (2026-07-29), **Infer stable chart keys**: + added ID and mark-specific positional identity inference, development + diagnostics, and reconciliation, animation, and focus coverage. +7. `de65652` (2026-07-29), **Infer scale domains from marks**: + added direct D3 factories, domain inference, axis nicening, strict value + validation, and corresponding positional, color, radius, and polar scale + behavior. +8. `4b940ed` (2026-07-30), **Add composable portal tooltips**: + added top-layer and fixed portal positioning, framework-native tooltip + bodies, pinned dialog behavior, and adapter-specific composition APIs. +9. `a91106c` (2026-07-30), **Improve chart inference and catalog data**: + completed point and scale typing, required-axis inference, independent color + channels, interval metadata, source-shaped demo data, visible transform + roles, source closures, and the schema-v4 catalog artifact. diff --git a/MARKETING.md b/MARKETING.md index b7c17d39..b6398a2c 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -1,14 +1,14 @@ # TanStack Charts Marketing Strategy -Last updated: 2026-07-28 +Last updated: 2026-07-30 ## Status -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. +TanStack Charts `0.0.1` is a public pre-alpha release. The docs, examples, and +catalog describe the same definition, behavior, scale, tooltip, and adapter +contracts available in `0.0.1`. Marketing must keep the pre-alpha status +visible and avoid production-readiness claims until the gates in +[`PLAN.md`](./PLAN.md) are complete. ## Executive summary @@ -44,15 +44,16 @@ D3 primitives. **Product category:** TypeScript visualization grammar; application charting library. -**Product type:** MIT-licensed open-source developer library, subject to final -distribution decisions. +**Product type:** MIT-licensed open-source developer library. The current +package line is pre-alpha. **Core model:** Marks consume application data directly. Channels describe visual encodings. D3 supplies algorithms. TanStack compiles the definition into a renderer-neutral keyed scene and owns the application runtime around it. -**Framework position:** The core is framework-independent. React and Octane are -thin adapters, not separate chart implementations. +**Framework position:** The core is framework-independent. `0.0.1` ships thin +adapters for React, Vue, Svelte, Solid, Angular, Preact, Lit, Alpine, and +Octane. React and Octane also have Canvas components over the same runtime. **Lineage:** TanStack Charts builds on the grammar-of-graphics tradition established by Leland Wilkinson and developed through ggplot2, Vega-Lite, and @@ -71,8 +72,8 @@ model as a TanStack invention. - Design-system and platform teams standardizing charts across applications. - Teams whose charts begin as common line, bar, area, or scatter plots but accumulate product-specific requirements. -- Teams already comfortable making explicit decisions about data domains, - scales, and visual encodings. +- Teams comfortable choosing scale types and visual encodings while letting + routine domains derive from mark data. - Teams that need responsive layout, dark mode, SSR, accessibility, live updates, interaction, or export as part of the application contract. @@ -152,9 +153,11 @@ become more specialized without migrating to a second API. ### Native D3 ownership -Authors supply native D3 scales, curves, bins, stacks, layouts, and other -algorithms. TanStack does not ask teams to relearn a parallel mathematical -system or wait for a fixed chart catalog to expose an upstream D3 capability. +Authors supply native D3 scales or scale factories, curves, bins, stacks, +layouts, and other algorithms. Scale factories infer routine domains from mark +channels; configured scale instances retain their fixed domains. TanStack does +not ask teams to relearn a parallel mathematical system or wait for a fixed +chart catalog to expose an upstream D3 capability. ### Application runtime included @@ -229,15 +232,12 @@ pretend to match. See the current responsive, accessible, server-rendered charts, built on the grammar-of-graphics tradition and most directly 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. +through vanilla TypeScript or adapters for React, Vue, Svelte, Solid, Angular, +Preact, Lit, Alpine, and Octane. -**Primary CTA today:** Explore the proof +**Primary CTA:** Build your first chart -**Secondary CTA today:** Follow development - -**Primary CTA after release:** Build your first chart - -**Secondary CTA after release:** Explore examples +**Secondary CTA:** Explore examples ### Problem section @@ -288,7 +288,8 @@ 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, Canvas, server SVG, and export. +5. Render the same definition through a framework adapter, the vanilla host, + Canvas, server SVG, and export. 6. Show the resulting bundle trace. This demonstration expresses the product thesis better than a gallery of @@ -296,28 +297,28 @@ unrelated chart thumbnails. ### Proof section -**Headline:** Complete charts around 19–23 KiB gzip. +**Headline:** Complete charts around 24–29 KiB gzip. The durable public claim is: -> Complete tested charts are approximately 19–23 KiB gzip. A static SVG line -> is 13.30 KiB gzip. Consumers pay for the capabilities they import. +> Complete tested charts are approximately 24–29 KiB gzip. A static SVG line +> is 14.88 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 +Do not lead with the 6.28 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 | 19.02–19.74 KiB | Checked four-chart comparison baseline | -| Mounted chart with legend and pointer tooltip | 19.33–20.05 KiB | Checked four-chart comparison baseline | -| Advanced two-series composition | 19.33–22.23 KiB | Checked four-chart comparison baseline | -| React line consumer, with React externalized | 19.63 KiB | Exact byte lock | -| TanStack Stats parity surface | 30.68 KiB | Isolated measurement under a 30.9 KiB capability budget | +| Consumer surface | Gzip size | Evidence status | +| --------------------------------------------- | --------------: | -------------------------------------------------------- | +| Custom-scale line scene, no renderer | 6.28 KiB | Exact byte lock; not a complete chart | +| D3-scale line with static SVG | 14.88 KiB | Exact byte lock | +| Mounted basic line, bar, area, or scatter | 24.19–24.81 KiB | Checked four-chart comparison baseline | +| Mounted chart with legend and pointer tooltip | 25.38–25.98 KiB | Checked four-chart comparison baseline | +| Advanced two-series composition | 25.39–28.20 KiB | Checked four-chart comparison baseline | +| React line consumer, with React externalized | 25.11 KiB | Exact byte lock | +| TanStack Stats parity surface | 35.35 KiB | Isolated measurement under a 35.45 KiB capability budget | The exact ordinary-consumer locks live in [`universal-baseline.json`](./benchmarks/bundle-size/universal-baseline.json). @@ -333,61 +334,36 @@ variable point size. | Gzip comparison | Basic | Interactive | Advanced | | -------------------------- | --------------: | --------------: | --------------: | -| TanStack Charts | 19.02–19.74 KiB | 19.33–20.05 KiB | 19.33–22.23 KiB | -| Chart.js | 2.35–2.63× | 2.71–2.98× | 2.56–2.71× | -| Observable Plot | 4.32–4.79× | 4.25–4.72× | 4.12–4.31× | -| Recharts, React external | 4.81–5.06× | 5.48–5.59× | 4.91–5.60× | -| Recharts, full cold bundle | 7.75–8.13× | 8.39–8.61× | 7.53–8.62× | -| Apache ECharts | 7.97–8.30× | 8.53–8.88× | 7.79–8.63× | +| TanStack Charts | 24.19–24.81 KiB | 25.38–25.98 KiB | 25.39–28.20 KiB | +| Chart.js | 1.85–2.08× | 2.06–2.29× | 2.01–2.07× | +| Observable Plot | 3.44–3.79× | 3.28–3.61× | 3.21–3.29× | +| Recharts, React external | 3.82–3.98× | 4.22–4.28× | 3.87–4.27× | +| Recharts, full cold bundle | 6.17–6.39× | 6.47–6.58× | 5.94–6.57× | +| Apache ECharts | 6.33–6.57× | 6.57–6.81× | 6.14–6.57× | 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 57–89% less gzipped JavaScript. +in this controlled suite, TanStack shipped 46–85% 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: +CI reproduces 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. +Before broad marketing, measure packed artifacts, add the small-library +fixtures, and publish release-linked JSON and Markdown results from CI. Every published number must link to the [`comparison protocol`](./benchmarks/comparison/README.md), @@ -398,7 +374,14 @@ imply that bundle size alone represents product quality. Additional proof: - Complete SVG SSR and client hydration adoption. -- React, vanilla, and Octane adapters over the same scene and runtime. +- Nine framework adapters and the vanilla host over the same scene and runtime. +- Definition-owned focus, tooltip, keyboard, and animation behavior, with + adapter-native tooltip body composition. +- Scale-domain and stable-key inference with explicit overrides. +- Faceting, declarative transforms, polar coordinates, and geographic + projections through the same mark-and-channel grammar. +- 100 catalog cases with complete authored-source accounting and pinned dataset + provenance. - Typed arbitrary-data channels and heterogeneous mark data. - Public custom-mark contract used by built-in marks. - Automatic guide margins based on formatted content and measured fonts. @@ -411,9 +394,7 @@ Additional proof: **Body:** Keep your data, bring the D3 primitives you trust, and let TanStack handle the application runtime around them. -**CTA today:** Explore TanStack Charts - -**CTA after release:** Build your first chart +**CTA:** Build your first chart ## Go-to-market @@ -471,14 +452,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 closest API 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. | +| Objection | Response | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Do I still need to choose scales? | Yes. Authors choose the D3 scale type and may supply a configured instance when the domain is part of the product contract. Scale factories let TanStack Charts infer routine domains from mark channels. | +| 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 closest API 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. `0.0.1` is a public pre-alpha release. Marketing must keep the documented release gates visible until they 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 @@ -490,7 +471,7 @@ TanStack Charts is not currently the best choice for teams that: 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. +- Want a library to choose every scale and chart decision. - Need commercial support, LTS, or contractual response times. - Have three permanently standard React charts and value the shortest possible initial implementation over customization headroom. @@ -512,9 +493,9 @@ capability-level imports. **Habit:** Existing charts already work; the team knows the incumbent API; standard chart catalogs have more examples and integrations. -**Anxiety:** TanStack Charts is new, has a smaller catalog, requires explicit -scale policy, and has not yet established compatibility, support, or production -history. +**Anxiety:** TanStack Charts is new, has a smaller catalog, still asks authors +to choose scale types when routine domains can be inferred, and has not yet +established compatibility, support, or production history. Marketing should answer anxiety with migration guides, real application case studies, stable API commitments, reproducible benchmarks, and honest release @@ -533,7 +514,7 @@ gates. - Framework-independent engine - Thin framework adapters - Lightweight and capability-scaled -- Complete chart consumers around 19–23 KiB gzip, with the benchmark link +- Complete chart consumers around 24–29 KiB gzip, with the benchmark link - Pay only for what you import - Built for applications, not screenshots - No customization cliff @@ -604,7 +585,8 @@ production case study. ### Current proof phase -- Conversion: Explore the proof, follow development, or join early access. +- Conversion: Install `0.0.1`, read the matching docs, explore the catalog, + and report friction. - Publish architecture, benchmarks, and working examples with limitations. - Recruit a small number of chart-heavy TanStack users. @@ -629,12 +611,10 @@ 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. +- Longitudinal, release-linked bundle-comparison results. - Remaining Plot-backed animated export migration. - Accessibility, locale, and RTL release gates. -- Public package, versioning, license packaging, and compatibility policy. +- Release and compatibility policy. ## Goals @@ -642,7 +622,7 @@ tracked in [`PLAN.md`](./PLAN.md): foundation for data-rich TanStack applications and a credible choice for frontend teams whose visualizations need to grow beyond standard chart types. -**Primary conversion today:** Explore the proof or join early access. +**Primary conversion today:** Install `0.0.1` and complete the first chart. **Primary conversion after stable release:** Install the package and complete the first chart. @@ -658,7 +638,6 @@ the first chart. ## Open decisions -- Public package names and release sequence. - Whether early access uses a waitlist, discussion thread, or prerelease npm channel. - Which TanStack product becomes the launch case study. diff --git a/README.md b/README.md index b50a556a..764830a8 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ # TanStack Charts -A tiny TypeScript visualization grammar for responsive, accessible, +A TypeScript visualization grammar for responsive, accessible, server-rendered application charts. > [!IMPORTANT] -> TanStack Charts is currently an unpublished `0.0.0` product proof. The packages -> are not published or ready for production use yet. +> TanStack Charts `0.0.1` is pre-alpha. Its API may change between releases, +> and it is not ready for production use. Most chart libraries are easy until the chart stops being standard. TanStack Charts gives you one typed grammar that can grow from a familiar line or bar @@ -64,6 +64,8 @@ or dropping down to a separate API. ## Quick look + + ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' @@ -92,16 +94,12 @@ const revenueChart = defineChart({ label: 'Revenue', grid: true, }, + tooltip: true, }) export function RevenueChart() { return ( - + ) } ``` @@ -117,7 +115,7 @@ through React, Preact, Vue, Solid, Svelte, Angular, Lit, Alpine, 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: +keep the definition and host callbacks: ```tsx import { Chart } from '@tanstack/react-charts/canvas' diff --git a/benchmarks/comparison/README.md b/benchmarks/comparison/README.md index 493adf43..f13236d7 100644 --- a/benchmarks/comparison/README.md +++ b/benchmarks/comparison/README.md @@ -68,10 +68,13 @@ pnpm benchmark:update-baseline Update the baseline only after reviewing the built files and confirming that a size change is intentional. The check permits 3% or 512 bytes, whichever is larger. It rejects package-version or chart/tier matrix drift before comparing -bytes. It also requires normal comparison artifacts to contain zero bytes from -the stress-probe modules; optional measurement machinery must disappear -through direct build-time feature gates. It does not gate browser timings -because those are hardware-sensitive. Every check writes +bytes. The TanStack source revision must exactly match the last commit that +changed core source or a transitive TanStack comparison input; +documentation-only commits do not stale the evidence. It also requires normal +comparison artifacts to contain zero bytes from the stress-probe modules; +optional measurement machinery must disappear through direct build-time +feature gates. It does not gate browser timings because those are +hardware-sensitive. Every check writes `.benchmark-output/results/bundle-baseline.candidate.json` with all measured cases. CI uploads that candidate when the baseline fails; a manual workflow run can request it explicitly. This keeps exact Ubuntu measurements available diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 04136bf9..a210b4d2 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,13 +1,39 @@ { - "schemaVersion": 2, - "generatedAt": "2026-07-29T05:42:29.634Z", - "versions": { - "tanstack": "0.0.0", + "schemaVersion": 3, + "generatedAt": "2026-07-30T21:26:09.124Z", + "packageVersions": { + "tanstack": "0.0.1", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", "observable-plot": "0.6.17" }, + "sources": { + "tanstack": { + "kind": "workspace", + "revision": "99c08ebd320a50a869796905e2f8f34d44bb1586" + }, + "chartjs": { + "kind": "package", + "packageName": "chart.js", + "version": "4.5.1" + }, + "echarts": { + "kind": "package", + "packageName": "echarts", + "version": "6.1.0" + }, + "recharts": { + "kind": "package", + "packageName": "recharts", + "version": "3.10.1" + }, + "observable-plot": { + "kind": "package", + "packageName": "@observablehq/plot", + "version": "0.6.17" + } + }, "matrix": { "chartTypes": ["line", "bar", "area", "scatter"], "tiers": ["basic", "interactive", "advanced"] @@ -18,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 50567, - "gzipBytes": 19672, - "brotliBytes": 17598, - "incrementalGzipBytes": 19672, - "incrementalBrotliBytes": 17598 + "minifiedBytes": 65857, + "gzipBytes": 24843, + "brotliBytes": 22148, + "incrementalGzipBytes": 24843, + "incrementalBrotliBytes": 22148 }, "tanstack-line-interactive": { - "minifiedBytes": 51483, - "gzipBytes": 19981, - "brotliBytes": 17910, - "incrementalGzipBytes": 19981, - "incrementalBrotliBytes": 17910 + "minifiedBytes": 69898, + "gzipBytes": 26059, + "brotliBytes": 23190, + "incrementalGzipBytes": 26059, + "incrementalBrotliBytes": 23190 }, "tanstack-line-advanced": { - "minifiedBytes": 58661, - "gzipBytes": 22260, - "brotliBytes": 19857, - "incrementalGzipBytes": 22260, - "incrementalBrotliBytes": 19857 + "minifiedBytes": 77078, + "gzipBytes": 28401, + "brotliBytes": 25173, + "incrementalGzipBytes": 28401, + "incrementalBrotliBytes": 25173 }, "tanstack-bar-basic": { - "minifiedBytes": 52164, - "gzipBytes": 20209, - "brotliBytes": 18092, - "incrementalGzipBytes": 20209, - "incrementalBrotliBytes": 18092 + "minifiedBytes": 67451, + "gzipBytes": 25404, + "brotliBytes": 22627, + "incrementalGzipBytes": 25404, + "incrementalBrotliBytes": 22627 }, "tanstack-bar-interactive": { - "minifiedBytes": 53076, - "gzipBytes": 20527, - "brotliBytes": 18334, - "incrementalGzipBytes": 20527, - "incrementalBrotliBytes": 18334 + "minifiedBytes": 71488, + "gzipBytes": 26608, + "brotliBytes": 23709, + "incrementalGzipBytes": 26608, + "incrementalBrotliBytes": 23709 }, "tanstack-bar-advanced": { - "minifiedBytes": 54466, - "gzipBytes": 21079, - "brotliBytes": 18832, - "incrementalGzipBytes": 21079, - "incrementalBrotliBytes": 18832 + "minifiedBytes": 72878, + "gzipBytes": 27177, + "brotliBytes": 24111, + "incrementalGzipBytes": 27177, + "incrementalBrotliBytes": 24111 }, "tanstack-area-basic": { - "minifiedBytes": 50430, - "gzipBytes": 19657, - "brotliBytes": 17587, - "incrementalGzipBytes": 19657, - "incrementalBrotliBytes": 17587 + "minifiedBytes": 65736, + "gzipBytes": 24839, + "brotliBytes": 22153, + "incrementalGzipBytes": 24839, + "incrementalBrotliBytes": 22153 }, "tanstack-area-interactive": { - "minifiedBytes": 51348, - "gzipBytes": 19966, - "brotliBytes": 17871, - "incrementalGzipBytes": 19966, - "incrementalBrotliBytes": 17871 + "minifiedBytes": 69777, + "gzipBytes": 26055, + "brotliBytes": 23227, + "incrementalGzipBytes": 26055, + "incrementalBrotliBytes": 23227 }, "tanstack-area-advanced": { - "minifiedBytes": 59760, - "gzipBytes": 22761, - "brotliBytes": 20298, - "incrementalGzipBytes": 22761, - "incrementalBrotliBytes": 20298 + "minifiedBytes": 78190, + "gzipBytes": 28878, + "brotliBytes": 25570, + "incrementalGzipBytes": 28878, + "incrementalBrotliBytes": 25570 }, "tanstack-scatter-basic": { - "minifiedBytes": 50042, - "gzipBytes": 19481, - "brotliBytes": 17421, - "incrementalGzipBytes": 19481, - "incrementalBrotliBytes": 17421 + "minifiedBytes": 65609, + "gzipBytes": 24769, + "brotliBytes": 22029, + "incrementalGzipBytes": 24769, + "incrementalBrotliBytes": 22029 }, "tanstack-scatter-interactive": { - "minifiedBytes": 50958, - "gzipBytes": 19792, - "brotliBytes": 17729, - "incrementalGzipBytes": 19792, - "incrementalBrotliBytes": 17729 + "minifiedBytes": 69650, + "gzipBytes": 25991, + "brotliBytes": 23177, + "incrementalGzipBytes": 25991, + "incrementalBrotliBytes": 23177 }, "tanstack-scatter-advanced": { - "minifiedBytes": 50974, - "gzipBytes": 19799, - "brotliBytes": 17701, - "incrementalGzipBytes": 19799, - "incrementalBrotliBytes": 17701 + "minifiedBytes": 69666, + "gzipBytes": 25997, + "brotliBytes": 23121, + "incrementalGzipBytes": 25997, + "incrementalBrotliBytes": 23121 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/comparison/libraries/tanstack/base.ts b/benchmarks/comparison/libraries/tanstack/base.ts index 3d5b76ae..ced40dbf 100644 --- a/benchmarks/comparison/libraries/tanstack/base.ts +++ b/benchmarks/comparison/libraries/tanstack/base.ts @@ -1,5 +1,8 @@ import { + defineChart, mountChart, + type ChartDefinitionOptions, + type ChartHostOptions, type ChartPoint, type ChartScene, type DynamicChartDefinition, @@ -29,11 +32,7 @@ export function mountDefinition( createDefinition: (input: BenchmarkInput) => DynamicChartDefinition, interactive: boolean, ): BenchmarkHandle { - const options = { - definition: createDefinition(input), - width: input.width, - height: input.height, - ariaLabel: 'Benchmark chart', + const definitionOptions = { keyboard: interactive, tooltip: BENCHMARK_GROUPED_X_FOCUS ? { @@ -55,7 +54,15 @@ export function mountDefinition( } : undefined), animate: false, - } + } satisfies ChartDefinitionOptions + const resolveDefinition = (nextInput: BenchmarkInput) => + defineChart(createDefinition(nextInput), definitionOptions) + const options = { + definition: resolveDefinition(input), + width: input.width, + height: input.height, + ariaLabel: 'Benchmark chart', + } satisfies ChartHostOptions const host = mountChart(container, options) let width = input.width let height = input.height @@ -239,7 +246,7 @@ export function mountDefinition( } host.update({ ...options, - definition: createDefinition(nextInput), + definition: resolveDefinition(nextInput), width: nextInput.width, height: nextInput.height, }) diff --git a/docs/comparison.md b/docs/comparison.md index 83ec24cc..7b66f430 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -1,25 +1,27 @@ --- title: Compare Libraries -description: Compare TanStack Charts with Chart.js, Apache ECharts, Recharts, and Observable Plot using pinned packages and reproducible fixtures. +description: Compare current TanStack Charts workspace source with pinned Chart.js, Apache ECharts, Recharts, and Observable Plot packages. --- -TanStack Charts is currently an unpublished `0.0.0` product proof, not a -production replacement for the established releases below. This comparison -records the architectural differences and evidence available today without -turning untested behavior into a checkmark. +TanStack Charts `0.0.1` is a pre-alpha release. Its results on this page measure +the workspace implementation prepared for `0.0.1`, not the earlier published +`0.0.0` artifact. This comparison records architectural differences and +reproducible evidence without turning untested behavior into a checkmark. ## Tested versions -| Library | Package | Pinned version | -| -------------------------------------------------------------------------------------- | -------------------- | -------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | `0.0.0` | -| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | `4.5.1` | -| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | `6.1.0` | -| [Recharts](https://recharts.github.io/en-US/) | `recharts` | `3.10.1` | -| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | `0.6.17` | +| Library | Package | Measured source | +| -------------------------------------------------------------------------------------- | -------------------- | ------------------- | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `99c08eb` | +| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | +| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | +| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | +| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | -These are exact repository pins, not the latest versions inferred at page -render time. +The competitor versions are exact package pins, not latest versions inferred +at page render time. The TanStack product implementation ends at commit +`a91106c`; the measured workspace revision is `99c08eb`, which adds the +comparison fixture correction and tracked baseline for `0.0.1`. ## Capability matrix @@ -50,7 +52,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-07-29`. +Baseline date: `2026-07-30`. Each range covers 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Full size is @@ -59,14 +61,15 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 19.02–22.23 KiB | — | +| TanStack Charts | 24.19–28.20 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.00–168.18 KiB | 94.88–109.87 KiB | | Observable Plot | 83.34–91.94 KiB | — | -The tracked baseline records the package versions and complete chart/tier -matrix; the deterministic bundle gate rejects either kind of drift. +The tracked baseline distinguishes the TanStack workspace revision from +competitor package versions and records the complete chart/tier matrix; the +deterministic bundle gate rejects either kind of drift. The range is not an install size or a runtime-speed ranking. The comparison builds the current TanStack workspace source and the pinned competitor @@ -82,10 +85,12 @@ reference coverage, not each library's feature ceiling or a list of built-in TanStack chart types. Chart.js participates in the standard and stress suites, not the catalog corpus. -The catalog displays each renderer entry and its case-local data or transform -dependencies. Its report counts the complete transitive authored source and -publishes the source-line ratio for every pair; moving transforms into -`data.ts` does not remove it from the comparison. +The catalog displays each renderer entry, its transitive support and transform +files, and provenance for imported demo datasets. Its report counts the +complete authored source closure and publishes the source-line ratio for every +pair; moving a transform or layout into a support module does not remove it +from the comparison, while raw snapshot rows are not treated as chart +authoring. TanStack deliberately keeps several responsibilities outside the default runtime: @@ -106,10 +111,10 @@ Canvas composition while keeping D3 and state ownership explicit. ## Evidence and reproduction -- [Standard comparison protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/README.md) -- [Tracked bundle baseline](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/bundle-baseline.json) -- [Stress protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/stress/README.md) -- [Catalog conformance protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/conformance/README.md) +- [Standard comparison protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/README.md) +- [Tracked bundle baseline](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/bundle-baseline.json) +- [Stress protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/stress/README.md) +- [Catalog conformance protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/conformance/README.md) ```sh pnpm benchmark:size diff --git a/docs/concepts/chart-definitions.md b/docs/concepts/chart-definitions.md index 12d736a7..462a1a1f 100644 --- a/docs/concepts/chart-definitions.md +++ b/docs/concepts/chart-definitions.md @@ -14,10 +14,22 @@ Pass a complete spec when the chart does not need its resolved surface size: ```ts -import { alphabet } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const letterFrequencies = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], x: { diff --git a/docs/concepts/data-and-channels.md b/docs/concepts/data-and-channels.md index 924dbcbb..b8370be2 100644 --- a/docs/concepts/data-and-channels.md +++ b/docs/concepts/data-and-channels.md @@ -278,10 +278,61 @@ responsive layout work. ## Complete bubble-scatter example ```ts -import { penguins, type PenguinsRow } from '@charts-poc/demo-data/penguins' import { scaleLinear, scaleOrdinal, scaleSqrt } from 'd3-scale' import { colorLegend, defineChart, dot } from '@tanstack/charts' +interface PenguinsRow { + species: string + culmen_length_mm: number | null + culmen_depth_mm: number | null + body_mass_g: number | null +} + +const penguins: readonly PenguinsRow[] = [ + { + species: 'Adelie', + culmen_length_mm: 39.1, + culmen_depth_mm: 18.7, + body_mass_g: 3750, + }, + { + species: 'Adelie', + culmen_length_mm: 40.3, + culmen_depth_mm: 18, + body_mass_g: 3250, + }, + { + species: 'Chinstrap', + culmen_length_mm: 46.5, + culmen_depth_mm: 17.9, + body_mass_g: 3500, + }, + { + species: 'Chinstrap', + culmen_length_mm: 50, + culmen_depth_mm: 19.5, + body_mass_g: 3900, + }, + { + species: 'Gentoo', + culmen_length_mm: 46.1, + culmen_depth_mm: 13.2, + body_mass_g: 4500, + }, + { + species: 'Gentoo', + culmen_length_mm: 50, + culmen_depth_mm: 16.3, + body_mass_g: 5700, + }, + { + species: 'Gentoo', + culmen_length_mm: null, + culmen_depth_mm: null, + body_mass_g: null, + }, +] + type CompletePenguin = PenguinsRow & { culmen_length_mm: number culmen_depth_mm: number diff --git a/docs/concepts/grammar-of-graphics.md b/docs/concepts/grammar-of-graphics.md index fee72584..badabd4b 100644 --- a/docs/concepts/grammar-of-graphics.md +++ b/docs/concepts/grammar-of-graphics.md @@ -26,10 +26,22 @@ The result is one `ChartSpec` compiled into a renderer-neutral scene. ## The smallest useful declaration ```ts -import { alphabet } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +interface LetterFrequency { + letter: string + frequency: number +} + +const alphabet: readonly LetterFrequency[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const chart = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], x: { scale: scaleBand }, @@ -37,9 +49,9 @@ const chart = defineChart({ }) ``` -The mark consumes the published letter-frequency rows directly and maps their -existing fields to x and y. No universal series wrapper or renamed chart fields -sit between the source data and the mark. +The mark consumes the typed letter-frequency rows directly and maps their +existing fields to x and y. No universal series wrapper or renamed chart +fields sit between the source data and the mark. Because this example imports `d3-scale` directly, add `d3-scale` and `@types/d3-scale` as direct dependencies. [Scales and D3](./scales-and-d3.md) explains why scales remain explicit. @@ -163,12 +175,71 @@ Omitted margins are measured from the actual guides. See [Layout, Axes, and Coor Marks render in array order. Put context behind the primary data and annotations above it: ```ts -import { weather } from '@charts-poc/demo-data/weather' import { scaleBand, scaleLinear } from 'd3-scale' import { curveMonotoneX } from 'd3-shape' import { areaY, barY, d3Curve, defineChart, dot, lineY } from '@tanstack/charts' -const rows = weather.filter((row) => row.location === 'Seattle').slice(37, 43) +interface WeatherRow { + location: string + date: Date + precipitation: number + temp_max: number + temp_min: number + wind: number +} + +const weather: readonly WeatherRow[] = [ + { + location: 'Seattle', + date: new Date('2026-03-01T00:00:00Z'), + precipitation: 0.5, + temp_max: 9.4, + temp_min: 3.2, + wind: 4.1, + }, + { + location: 'Seattle', + date: new Date('2026-03-02T00:00:00Z'), + precipitation: 3.1, + temp_max: 8.2, + temp_min: 2.8, + wind: 5.2, + }, + { + location: 'Seattle', + date: new Date('2026-03-03T00:00:00Z'), + precipitation: 1.4, + temp_max: 10.6, + temp_min: 4.1, + wind: 3.8, + }, + { + location: 'Seattle', + date: new Date('2026-03-04T00:00:00Z'), + precipitation: 0, + temp_max: 12.7, + temp_min: 5.3, + wind: 2.9, + }, + { + location: 'Seattle', + date: new Date('2026-03-05T00:00:00Z'), + precipitation: 2.2, + temp_max: 11.1, + temp_min: 4.7, + wind: 4.6, + }, + { + location: 'Seattle', + date: new Date('2026-03-06T00:00:00Z'), + precipitation: 0.3, + temp_max: 13.4, + temp_min: 6.1, + wind: 3.3, + }, +] + +const rows = weather.filter((row) => row.location === 'Seattle') const composedChart = defineChart({ marks: [ diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 4b6c9c5b..018bfa37 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -285,10 +285,25 @@ Automatic margins only reserve space for chart-owned guides and legends. Applica ## Complete horizontal ranking ```ts -import { citywages } from '@charts-poc/demo-data/citywages' import { scaleBand, scaleLinear } from 'd3-scale' import { barX, defineChart, ruleX } from '@tanstack/charts' +interface MetroPopulation { + Metro: string + POP_2015: number +} + +const citywages: readonly MetroPopulation[] = [ + { Metro: 'New York–Newark–Jersey City', POP_2015: 20_182_305 }, + { Metro: 'Los Angeles–Long Beach–Anaheim', POP_2015: 13_340_068 }, + { Metro: 'Chicago–Naperville–Elgin', POP_2015: 9_532_569 }, + { Metro: 'Dallas–Fort Worth–Arlington', POP_2015: 7_206_144 }, + { Metro: 'Houston–The Woodlands–Sugar Land', POP_2015: 6_656_947 }, + { Metro: 'Washington–Arlington–Alexandria', POP_2015: 6_097_684 }, + { Metro: 'Philadelphia–Camden–Wilmington', POP_2015: 6_069_875 }, + { Metro: 'Miami–Fort Lauderdale–West Palm Beach', POP_2015: 6_012_331 }, +] + const rows = [...citywages] .sort((left, right) => right.POP_2015 - left.POP_2015) .slice(0, 8) diff --git a/docs/concepts/marks-and-layering.md b/docs/concepts/marks-and-layering.md index 60267ff4..daa3203d 100644 --- a/docs/concepts/marks-and-layering.md +++ b/docs/concepts/marks-and-layering.md @@ -194,10 +194,24 @@ Clipping applies to the chart’s mark group, not axes or legends. Leave it off ## Complete range-band composition ```ts -import { sfTemperatures } from '@charts-poc/demo-data/sf-temperatures' import { scaleLinear, scaleUtc } from 'd3-scale' import { areaY, defineChart, lineY } from '@tanstack/charts' +interface DailyTemperature { + date: Date + high: number + low: number +} + +const sfTemperatures: readonly DailyTemperature[] = [ + { date: new Date('2026-07-01T00:00:00Z'), high: 68, low: 55 }, + { date: new Date('2026-07-02T00:00:00Z'), high: 71, low: 56 }, + { date: new Date('2026-07-03T00:00:00Z'), high: 66, low: 54 }, + { date: new Date('2026-07-04T00:00:00Z'), high: 69, low: 55 }, + { date: new Date('2026-07-05T00:00:00Z'), high: 73, low: 57 }, + { date: new Date('2026-07-06T00:00:00Z'), high: 70, low: 56 }, +] + const temperatureChart = defineChart({ marks: [ areaY(sfTemperatures, { diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index d2e3412e..92308027 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -43,7 +43,7 @@ Use the official D3 pages as the API reference for each algorithm. TanStack Char | 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 | +| Pan and zoom | [`d3-zoom`](https://d3js.org/d3-zoom) | Own the gesture, update application state, and rebuild the definition with a configured 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` | @@ -313,10 +313,23 @@ When the application owns the gesture, disable the native nearest-point focus st ```ts -import { flare, type FlareRow } from '@charts-poc/demo-data/flare' import { scaleLinear, scaleLog } from 'd3-scale' import { defineChart, dot } from '@tanstack/charts' +interface FlareRow { + name: string + size: number | null +} + +const flare: readonly FlareRow[] = [ + { name: 'flare.analytics.cluster', size: 3938 }, + { name: 'flare.analytics.graph', size: 10_871 }, + { name: 'flare.analytics.optimization', size: 5731 }, + { name: 'flare.display', size: 12_867 }, + { name: 'flare.query', size: 2779 }, + { name: 'flare.unresolved', size: null }, +] + type SizedFlareRow = FlareRow & { size: number } const rows = flare diff --git a/docs/examples/annotations-and-overlays.md b/docs/examples/annotations-and-overlays.md index 5c6f3ac5..7ae13059 100644 --- a/docs/examples/annotations-and-overlays.md +++ b/docs/examples/annotations-and-overlays.md @@ -38,10 +38,11 @@ category's endpoints, and labels the values directly. style="width:100%;height:440px;border:0;" > -Use stable category keys for the links and endpoints. Direct labels remove a -legend lookup, but they need collision policy when values converge. Filter to -meaningful categories, increase vertical space, or use an accessible detail -view rather than allowing unreadable overlap. +Preserve category identity for the links and endpoints; supply `key` only when +the mark cannot infer it. Direct labels remove a legend lookup, but they need +collision policy when values converge. Filter to meaningful categories, +increase vertical space, or use an accessible detail view rather than allowing +unreadable overlap. A slope implies before-to-after order. Label both periods and keep the same quantitative scale. diff --git a/docs/examples/bars-and-rankings.md b/docs/examples/bars-and-rankings.md index de6ad110..04f8c356 100644 --- a/docs/examples/bars-and-rankings.md +++ b/docs/examples/bars-and-rankings.md @@ -107,5 +107,6 @@ contracts are in [Bar and Rect Marks](../reference/marks/bar-and-rect.md). shape can carry the essential comparison. - Verify long labels and rotated ticks with [Responsive Charts](../guides/responsive-charts.md). -- Use stable category keys when values reorder or animate. See +- Preserve unique category values when bars reorder or animate; supply `key` + only when the category does not identify a row. See [Dynamic Data and Animation](../guides/dynamic-data-and-animation.md). diff --git a/docs/examples/facets-and-multiple-views.md b/docs/examples/facets-and-multiple-views.md index 9e2983b6..21a75e2d 100644 --- a/docs/examples/facets-and-multiple-views.md +++ b/docs/examples/facets-and-multiple-views.md @@ -105,7 +105,7 @@ Shared selection, cursor, category, or domain state belongs in the application: 1. A view emits a semantic value through focus, selection, or a controlled gesture. 2. Application state validates and stores that value. -3. Each view derives its own input and configured scales. +3. Each view derives its own definition and configured scales. 4. Each chart compiles a new scene through its normal update path. Do not query one SVG for a pixel and apply that pixel directly to another view. diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index efee88ab..5852347b 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -130,8 +130,8 @@ A complete editor should: - Keep color-independent event labels visible. Do not mutate a rectangle and treat that painted geometry as the saved record. -Update application state, validate it, and let the next definition input -produce the scene. +Update application state, validate it, and let the next definition produce the +scene. ## State and lifecycle diff --git a/docs/examples/lines-and-areas.md b/docs/examples/lines-and-areas.md index 04729d3f..3ae52dc2 100644 --- a/docs/examples/lines-and-areas.md +++ b/docs/examples/lines-and-areas.md @@ -110,7 +110,8 @@ separate layers remain easier to update and extend. - Use a temporal scale for dates and define the domain in application data semantics, as described in [Scales and D3](../concepts/scales-and-d3.md). -- Give each moving row and series a stable key. See +- Preserve row IDs or unique positions across updates, and group series with + `z`; supply `key` only when the mark cannot infer identity. See [Dynamic Data and Animation](../guides/dynamic-data-and-animation.md). - Let automatic layout measure tick labels, then verify the smallest container in [Responsive Charts](../guides/responsive-charts.md). diff --git a/docs/examples/maps-and-spatial.md b/docs/examples/maps-and-spatial.md index 2df68d11..e054dfa8 100644 --- a/docs/examples/maps-and-spatial.md +++ b/docs/examples/maps-and-spatial.md @@ -104,19 +104,69 @@ Cartesian or geo-only consumer bundles. ## Project GeoJSON responsively -Give `geoShape` a projection factory and an explicit fit target. This example -uses Observable Plot's published Westport House floor plan and preserves its -planar coordinates. The mark fits the projection to the final plot bounds -again whenever the chart resizes. +Give `geoShape` a projection factory and an explicit fit target. This +self-contained example uses a small planar floor plan. The mark fits the +projection to the final plot bounds again whenever the chart resizes. ```ts -import { westportHouse } from '@charts-poc/demo-data/westport-house' import { defineChart } from '@tanstack/charts' import { geoShape } from '@tanstack/charts/geo' import { geoIdentity } from 'd3-geo' +interface FloorPlanFeature { + type: 'Feature' + properties: { id: number } + geometry: { + type: 'Polygon' + coordinates: [number, number][][] + } +} + +interface FloorPlan { + type: 'FeatureCollection' + features: FloorPlanFeature[] +} + +const westportHouse: FloorPlan = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { id: 1 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [48, 0], + [48, 28], + [0, 28], + [0, 0], + ], + ], + }, + }, + { + type: 'Feature', + properties: { id: 2 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [52, 0], + [84, 0], + [84, 28], + [52, 28], + [52, 0], + ], + ], + }, + }, + ], +} + const map = defineChart({ marks: [ geoShape(westportHouse.features, { diff --git a/docs/examples/polar-and-radar.md b/docs/examples/polar-and-radar.md index 2efe5bf0..5bb5d929 100644 --- a/docs/examples/polar-and-radar.md +++ b/docs/examples/polar-and-radar.md @@ -32,11 +32,23 @@ nonzero inner radius is a donut. ```ts -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { defineChart } from '@tanstack/charts' import { polar, radialArc } from '@tanstack/charts/polar' import { pie } from 'd3-shape' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const partColors = ['#0ea5e9', '#6366f1', '#a855f7', '#ec4899', '#f97316'] const letters = alphabet.slice(0, 5) const pieLayout = pie() @@ -141,11 +153,25 @@ a separate geometry implementation. ```ts -import { survey, type SurveyRow } from '@charts-poc/demo-data/survey' import { defineChart } from '@tanstack/charts' import { polar, radialArc } from '@tanstack/charts/polar' import { pie } from 'd3-shape' +interface SurveyRow { + Question: string + ID: number + Response: string +} + +const survey: readonly SurveyRow[] = [ + { Question: 'Q1', ID: 1, Response: 'Strongly Agree' }, + { Question: 'Q1', ID: 2, Response: 'Agree' }, + { Question: 'Q1', ID: 3, Response: 'Agree' }, + { Question: 'Q1', ID: 4, Response: 'Neutral' }, + { Question: 'Q1', ID: 5, Response: 'Disagree' }, + { Question: 'Q2', ID: 1, Response: 'Neutral' }, +] + interface GaugePart { id: 'agreement' | 'other' value: number @@ -223,7 +249,6 @@ polar guides and radial marks. TanStack supplies both responsive ranges. ```ts -import { decathlon, type DecathlonRow } from '@charts-poc/demo-data/decathlon' import { defineChart } from '@tanstack/charts' import { angleGrid, @@ -237,6 +262,45 @@ import { extent } from 'd3-array' import { scaleLinear, scalePoint } from 'd3-scale' import { curveLinearClosed } from 'd3-shape' +interface DecathlonRow { + Country: string + '100 Meters': number + 'Long Jump': number + 'High Jump': number + '100 Meter Hurdles': number +} + +const decathlon: readonly DecathlonRow[] = [ + { + Country: 'United States', + '100 Meters': 10.35, + 'Long Jump': 7.96, + 'High Jump': 2.05, + '100 Meter Hurdles': 13.61, + }, + { + Country: 'Great Britain', + '100 Meters': 10.44, + 'Long Jump': 7.74, + 'High Jump': 2.11, + '100 Meter Hurdles': 13.75, + }, + { + Country: 'Germany', + '100 Meters': 10.67, + 'Long Jump': 7.62, + 'High Jump': 2.08, + '100 Meter Hurdles': 14.02, + }, + { + Country: 'France', + '100 Meters': 10.58, + 'Long Jump': 7.81, + 'High Jump': 1.99, + '100 Meter Hurdles': 13.88, + }, +] + const events = [ '100 Meters', 'Long Jump', @@ -264,7 +328,7 @@ function radarProfile(row: DecathlonRow) { } const athlete = decathlon[0] -if (!athlete) throw new Error('The decathlon snapshot is empty') +if (!athlete) throw new Error('The decathlon data is empty') const profile = radarProfile(athlete) const radar = defineChart({ @@ -339,8 +403,6 @@ measurements without renaming those measurements into chart fields. ```ts -import { weather, type WeatherRow } from '@charts-poc/demo-data/weather' -import { wind, type WindRow } from '@charts-poc/demo-data/wind' import { defineChart } from '@tanstack/charts' import { angleGrid, @@ -351,6 +413,59 @@ import { } from '@tanstack/charts/polar' import { scaleLinear } from 'd3-scale' +interface WeatherRow { + location: string + date: Date + temp_max: number +} + +const weather: readonly WeatherRow[] = [ + { + location: 'Seattle', + date: new Date('2012-01-15T00:00:00Z'), + temp_max: 8.3, + }, + { + location: 'Seattle', + date: new Date('2012-03-15T00:00:00Z'), + temp_max: 12.2, + }, + { + location: 'Seattle', + date: new Date('2012-05-15T00:00:00Z'), + temp_max: 18.9, + }, + { + location: 'Seattle', + date: new Date('2012-07-15T00:00:00Z'), + temp_max: 25.6, + }, + { + location: 'Seattle', + date: new Date('2012-09-15T00:00:00Z'), + temp_max: 21.1, + }, + { + location: 'Seattle', + date: new Date('2012-11-15T00:00:00Z'), + temp_max: 11.7, + }, +] + +interface WindRow { + latitude: number + u: number + v: number +} + +const wind: readonly WindRow[] = [ + { latitude: 48.125, u: 4.2, v: 1.6 }, + { latitude: 48.125, u: 2.1, v: 5.8 }, + { latitude: 48.125, u: -3.4, v: 6.2 }, + { latitude: 48.125, u: -5.1, v: -2.3 }, + { latitude: 48.125, u: 1.8, v: -4.7 }, +] + const seattle2012 = weather.filter( (row) => row.location === 'Seattle' && row.date.getUTCFullYear() === 2012, ) @@ -481,7 +596,8 @@ the isolated consumer budgets. - 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. +- Let marks infer identity from source IDs or unique positions; supply a key + when neither is available. - 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. diff --git a/docs/framework/octane/quick-start.md b/docs/framework/octane/quick-start.md index 4b8fb95f..410df5e1 100644 --- a/docs/framework/octane/quick-start.md +++ b/docs/framework/octane/quick-start.md @@ -21,11 +21,23 @@ Definitions are framework-independent and can be shared with any adapter: ```tsx -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' import { Chart } from '@tanstack/octane-charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 16966c35..7e932a5e 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -151,7 +151,7 @@ transient, so display-only content can remain visible but controls should render only while `pinned` is true. Definition `tooltip.portal: true` promotes the whole surface above clipped ancestors without changing this React API. -## Definition and input identity +## Definition identity Define a fixed chart outside component render: diff --git a/docs/framework/react/quick-start.md b/docs/framework/react/quick-start.md index 81ac2725..0d88e12b 100644 --- a/docs/framework/react/quick-start.md +++ b/docs/framework/react/quick-start.md @@ -21,11 +21,23 @@ Definitions are ordinary framework-independent TypeScript: ```tsx -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' import { Chart } from '@tanstack/react-charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, diff --git a/docs/guides/ai-authoring.md b/docs/guides/ai-authoring.md index 6355795d..e9ecfdf9 100644 --- a/docs/guides/ai-authoring.md +++ b/docs/guides/ai-authoring.md @@ -53,7 +53,7 @@ Generated code should include: - a complete chart definition; - complete adapter or host usage; - a meaningful `ariaLabel`; -- stable keys; +- stable inferred or explicit identity; - empty and constant-domain policies when applicable. It should not require readers to invent undeclared variables, hidden imports, @@ -87,8 +87,8 @@ Run, in order: 4. Light and dark visual checks. 5. A narrow production bundle measurement when a new capability is imported. -For dynamic charts, also test reorder, resize, empty data, replacement input, -and a burst that must settle on the latest revision. +For changing charts, also test reorder, resize, empty data, replacement data, +and a burst that must settle on the latest definition. ## Request template diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index df3ebc8a..46733862 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -116,9 +116,11 @@ interaction policies. ## Update efficiently -- Keep definitions at module scope. -- Reuse input references when data is unchanged. -- Give every mutable visual entity a stable key. +- Keep fixed definitions at module scope. +- Memoize captured-data definitions until their application values change. +- Reuse derived data references when source data is unchanged. +- Let marks infer identity from IDs or unique positions; supply `key` only when + that identity is unavailable or can change. - Memoize expensive derived data in the application. - Bound streaming windows. - Build a spatial index only when a measurement justifies it. diff --git a/docs/guides/faceting-and-composition.md b/docs/guides/faceting-and-composition.md index b1adf284..f67f2d3e 100644 --- a/docs/guides/faceting-and-composition.md +++ b/docs/guides/faceting-and-composition.md @@ -109,7 +109,8 @@ explains how to base region geometry on the final chart bounds. ## Composition checklist - Layer order reflects visual occlusion and reading order. -- Each mark keeps its natural data shape and stable keys. +- Each mark keeps its natural data shape and stable inferred or explicit + identity. - Shared scales are used only where direct positional comparison is intended. - Facet axis policy is explicit. - Each independently interactive view has its own accessible name. diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index c01891e3..0f42a044 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -41,7 +41,7 @@ Every application-owned gesture follows the same loop: 3. Convert pointer geometry into semantic values. 4. Clamp, snap, or validate those values as product policy. 5. update application state. -6. Let the normal chart input produce the next scene. +6. Let the next definition produce the scene. Do not mutate SVG geometry directly and then attempt to reconcile application state afterward. @@ -132,8 +132,9 @@ DOM behavior. Decide: - touch pinch and cancellation; - reset and follow-latest behavior. -Use `d3-zoom` and `d3-selection` when they improve modality handling. Feed the -resulting domain back into the chart input. +Use `d3-zoom` and `d3-selection` when they improve modality handling. Store the +resulting domain in application state and rebuild the definition with a +configured scale. ## Linked views diff --git a/docs/guides/ssr-and-hydration.md b/docs/guides/ssr-and-hydration.md index 59f655f9..5a6da3da 100644 --- a/docs/guides/ssr-and-hydration.md +++ b/docs/guides/ssr-and-hydration.md @@ -22,7 +22,7 @@ runtime and renderer on the server and in the browser. | [Alpine](../framework/alpine/adapter.md) | None | Browser-only directive | For adapters with server output, the browser must render the same definition, -input, dimensions, formatters, and component tree. Angular and Lit may run +dimensions, formatters, and component tree. Angular and Lit may run inside applications with their own server infrastructure, but this library does not yet promise or test adapter hydration for them. @@ -53,19 +53,22 @@ See [Responsive Charts](./responsive-charts.md) for the complete size policy. ## Keep output deterministic -Server and first-client output must agree for the same definition, input, size, -and options. In particular: +Server and first-client output must agree for the same definition, size, and +options. In particular: -- create definitions at module scope; +- keep fixed definitions at module scope and recreate captured-data definitions + from the same resolved data; - sort unordered collections before creating marks; - do not read `window`, layout, time, locale, or random values while building a definition; - pass locale-sensitive formatters explicitly; -- use stable keys derived from data identity; +- rely on inferred IDs or unique positions, and supply explicit keys when the + data has no stable identity; - provide `idPrefix` when multiple render roots need coordinated resource IDs. -Dynamic chart functions are synchronous. Fetch and transform data in the -application's server/data layer, then pass the resolved input to the chart. +Responsive chart functions are synchronous. Fetch and transform data in the +application's server/data layer, then capture the resolved data in the +definition. ## Hydration ownership @@ -112,8 +115,8 @@ instead of shipping a font engine to the server. ```ts import { createChartRuntime, renderChartSvg } from '@tanstack/charts' -const runtime = createChartRuntime() -const scene = runtime.render(definition, input, { width: 720, height: 400 }) +const runtime = createChartRuntime() +const scene = runtime.render(definition, { width: 720, height: 400 }) const svg = renderChartSvg(scene, { ariaLabel: 'Daily traffic', @@ -130,9 +133,9 @@ surface mounts. ## Hydration checklist -- Server input is fully resolved before chart rendering. +- Server data is fully resolved before chart rendering. - Initial dimensions are explicit and representative. -- Definition, transformed input, and formatting are deterministic. +- Definition, transformed data, and formatting are deterministic. - Keys and `idPrefix` are stable. - The same adapter and definition render on both sides. - Browser-only work lives in host callbacks or application effects. diff --git a/docs/guides/typescript.md b/docs/guides/typescript.md index 203e0ff8..16c09a8b 100644 --- a/docs/guides/typescript.md +++ b/docs/guides/typescript.md @@ -30,8 +30,8 @@ const definition = defineChart({ y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, }) ``` @@ -50,8 +50,8 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, }) } ``` @@ -77,8 +77,8 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, margin: width < 480 ? 24 : 40, })) } @@ -175,7 +175,7 @@ Do not use it to make application examples compile. ## No-cast checklist -- Datum and input types are declared at the application boundary. +- Datum and captured application values are typed at the application boundary. - Channel fields are checked against the datum. - Scale domains match inferred coordinate types. - Definitions preserve their literal mark tuple. diff --git a/docs/installation.md b/docs/installation.md index 7b1b85dd..0d062c36 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,9 @@ title: Installation description: Install TanStack Charts, a framework adapter, and the granular D3 modules used by your charts. --- -Install the framework-agnostic core in every application that authors chart definitions: +TanStack Charts `0.0.1` publishes the framework-agnostic core and every adapter +listed below. Install the core in each application that authors chart +definitions: ```sh pnpm add @tanstack/charts @@ -62,7 +64,10 @@ needs browser mounting or server rendering. ## Install the D3 modules you import -TanStack Charts accepts configured D3 scales and the output of D3 transforms directly. Your application must declare every `d3-*` module that its source imports. Strict package managers do not expose transitive dependencies as an application import contract. +TanStack Charts accepts D3 scale factories, configured scale instances, and +the output of D3 transforms directly. Your application must declare every +`d3-*` module that its source imports. Strict package managers do not expose +transitive dependencies as an application import contract. A typical cartesian chart uses: diff --git a/docs/overview.md b/docs/overview.md index 68783785..2b6c0b49 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -3,6 +3,9 @@ title: Overview description: Learn what TanStack Charts provides, how its grammar works, and where charting responsibilities belong. --- +TanStack Charts `0.0.1` is a pre-alpha release. Its API may change between +releases. + TanStack Charts is a small, framework-agnostic chart grammar for TypeScript and JavaScript. Give each mark its natural data, map fields or accessors to visual channels, and supply the D3 scales that define the meaning of each axis. TanStack Charts compiles that declaration into a responsive, keyed scene and renders accessible SVG by default, with Canvas available as an opt-in surface. TanStack Charts builds on the grammar-of-graphics tradition established by @@ -27,16 +30,30 @@ adapter. React and Octane also provide optional Canvas entries. ```ts -import { aapl } from '@charts-poc/demo-data/aapl' import { mean } from 'd3-array' import { scaleLinear, scaleUtc } from 'd3-scale' import { areaY, defineChart, lineY } from '@tanstack/charts' -const observations = aapl.slice(0, 120) +interface ClosingPrice { + Date: Date + Close: number +} + +const observations: readonly ClosingPrice[] = [ + { Date: new Date('2013-05-13T00:00:00Z'), Close: 64.96 }, + { Date: new Date('2013-05-14T00:00:00Z'), Close: 63.41 }, + { Date: new Date('2013-05-15T00:00:00Z'), Close: 61.26 }, + { Date: new Date('2013-05-16T00:00:00Z'), Close: 62.08 }, + { Date: new Date('2013-05-17T00:00:00Z'), Close: 61.89 }, + { Date: new Date('2013-05-20T00:00:00Z'), Close: 63.28 }, + { Date: new Date('2013-05-21T00:00:00Z'), Close: 62.81 }, + { Date: new Date('2013-05-22T00:00:00Z'), Close: 63.05 }, +] + const rows = observations.flatMap((row, index) => { - if (index < 19) return [] + if (index < 2) return [] const average = mean( - observations.slice(index - 19, index + 1), + observations.slice(index - 2, index + 1), (observation) => observation.Close, ) return average === undefined ? [] : [{ ...row, average }] @@ -109,12 +126,12 @@ TanStack Charts owns the parts that make a declarative chart reliable inside an TanStack Charts deliberately does not hide data or spatial algorithms behind a second abstraction. -| Responsibility | Owner | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| Scale domains, scale semantics, binning, stacking, grouping, interpolation, and spatial algorithms | Your application using the granular D3 modules it needs | -| Fetching, cleaning, profiling, and exploratory analysis | Your data layer, server, or AI workflow | -| Marks, channels, responsive ranges, guide layout, scenes, rendering, and chart lifecycle | TanStack Charts | -| Page controls, queries, filters, persistence, and application state | Your application | +| Responsibility | Owner | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Scale choice and configuration, fixed semantic domains, transforms, interpolation, and spatial algorithms | Your application using the granular D3 modules it needs | +| Fetching, cleaning, profiling, and exploratory analysis | Your data layer, server, or AI workflow | +| Mark-channel domain inference, responsive ranges, guide layout, scenes, rendering, and chart lifecycle | TanStack Charts | +| Page controls, queries, filters, persistence, and application state | Your application | This division keeps the core small and makes advanced work explicit. Prepared data can come from D3, SQL, a server, or ordinary TypeScript; marks consume it without requiring a special series container. @@ -126,7 +143,8 @@ The normal path is intentionally short: - Omit `margin` to measure axes, tick labels, rotation, and titles automatically. - Supply `ariaLabel`; keyboard focus is enabled by default. - Add `tooltip: true` to the definition when a native value tooltip is enough. -- Use stable `key` channels for rows that can move, enter, or leave. +- Let built-in marks infer stable identity from IDs or unique positions; supply + `key` when that identity is unavailable or can change. - Let field names, datum types, scales, interaction points, and adapters infer without casts. - Use inherited `currentColor` and the `--ts-chart-*` CSS variables for automatic theme integration. diff --git a/docs/quick-start.md b/docs/quick-start.md index eece4bb6..05b1613c 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -20,13 +20,27 @@ The host follows the container width when `width` is omitted. ```ts -import { aapl } from '@charts-poc/demo-data/aapl' import { scaleLinear, scaleUtc } from 'd3-scale' import { defineChart, lineY, mountChart } from '@tanstack/charts' +interface ClosingPrice { + Date: Date + Close: number +} + +const closingPrices: readonly ClosingPrice[] = [ + { Date: new Date('2013-11-01T00:00:00Z'), Close: 74.29 }, + { Date: new Date('2013-12-02T00:00:00Z'), Close: 78.75 }, + { Date: new Date('2014-01-02T00:00:00Z'), Close: 79.02 }, + { Date: new Date('2014-02-03T00:00:00Z'), Close: 71.65 }, + { Date: new Date('2014-03-03T00:00:00Z'), Close: 75.39 }, + { Date: new Date('2014-04-01T00:00:00Z'), Close: 77.38 }, + { Date: new Date('2014-05-01T00:00:00Z'), Close: 84.5 }, +] + const closingPriceChart = defineChart({ marks: [ - lineY(aapl, { + lineY(closingPrices, { id: 'apple-close', x: 'Date', y: (row) => (row.Date.getUTCMonth() < 3 ? null : row.Close), @@ -52,8 +66,9 @@ Because this source imports `d3-scale` directly, add it and `@types/d3-scale` as direct dependencies. See [Installation](./installation.md). The accessor deliberately omits first-quarter observations, creating visible -breaks instead of misleading segments. The original AAPL row flows through the -mark and into interaction callbacks; no cast or manual chart generic is needed. +breaks instead of misleading segments. The original closing-price row flows +through the mark and into interaction callbacks; no cast or manual chart +generic is needed. ## 3. Mount it @@ -112,7 +127,8 @@ Destroying the host removes observers, event listeners, animations, tooltips, an ## What the declaration means -- `lineY(aapl, ...)` chooses a line mark and keeps each original AAPL row as the interaction datum. +- `lineY(closingPrices, ...)` chooses a line mark and keeps each source row as + the interaction datum. - `x: 'Date'` maps the source date field; the y accessor returns `Close` or an intentional gap. - The unique date gives each observation stable positional identity across updates. - D3 scale factories infer domains from mark channels and own mapping behavior. diff --git a/docs/reference/index.md b/docs/reference/index.md index 3c5e3d49..ef59e598 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -62,7 +62,7 @@ capabilities and individual marks independently tree-shakeable. | Import | Public values | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@tanstack/charts` | Common marks, legends, D3 curve bridges, `createMark`, `defineChart`, `createChartScene`, `createChartRuntime`, `mountChart`, `renderChartSvg`, and runtime comparison helpers | +| `@tanstack/charts` | Common marks, legends, D3 curve bridges, `createMark`, `defineChart`, `createChartScene`, `createChartRuntime`, `isDynamicChartDefinition`, `mountChart`, and `renderChartSvg` | | `@tanstack/charts/adapter` | `createChartAdapter`, `resolveChartAdapterLayout`, `ChartAdapter`, `ChartAdapterLayout`, and `ChartAdapterLayoutOptions` | | `@tanstack/charts/adapter/renderer` | `createChartRendererAdapter` | | `@tanstack/charts/area` | `areaY` | @@ -90,7 +90,7 @@ capabilities and individual marks independently tree-shakeable. | `@tanstack/charts/rect` | `rect`, `cell` | | `@tanstack/charts/renderer` | `mountChartRenderer` | | `@tanstack/charts/rule` | `ruleX`, `ruleY` | -| `@tanstack/charts/runtime` | `createChartRuntime`, definition and input comparison helpers | +| `@tanstack/charts/runtime` | `createChartRuntime`, `isDynamicChartDefinition` | | `@tanstack/charts/scene` | `defineChart`, `createChartScene`, `defaultChartTheme`, `findNearestPoint` | | `@tanstack/charts/svg` | `renderChartSvg` | | `@tanstack/charts/svg/renderer` | `createSvgChartRenderer`, `svgChartRenderer` | diff --git a/docs/reference/rendering-and-export.md b/docs/reference/rendering-and-export.md index 90e3ba17..b664eb85 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -476,7 +476,6 @@ Use `mountChartRenderer` from `@tanstack/charts/renderer`, or the React and Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartSurfaceRenderOptions`, `ChartSurface`, `ChartRenderer`, `ChartRendererRenderContext`, `ChartRendererHostCommonOptions`, -`StaticChartRendererHostOptions`, `DynamicChartRendererHostOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. diff --git a/docs/reference/types.md b/docs/reference/types.md index 6209effd..ae2b33ab 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -4,9 +4,9 @@ description: Public TypeScript types, inference rules, channels, definitions, sc --- TanStack Charts is inference-first. A mark's source data and channel selectors -flow through its definition into scales, axis formatters, host input, focus -callbacks, and selection callbacks. Normal application code should not cast -chart definitions or supply adapter generics. +flow through its definition into scales, axis formatters, host and adapter +callbacks, focus callbacks, and selection callbacks. Normal application code +should not cast chart definitions or supply adapter generics. ## Values and channels diff --git a/llms.txt b/llms.txt index c1cb045a..9761b941 100644 --- a/llms.txt +++ b/llms.txt @@ -5,7 +5,7 @@ TanStack Charts is a framework-agnostic, type-safe visualization grammar with th Read the canonical pages below. Each concept is documented once; guides and examples link back to its owner page. - docs/overview.md — Overview: Learn what TanStack Charts provides, how its grammar works, and where charting responsibilities belong. -- docs/comparison.md — Compare Libraries: Compare TanStack Charts with Chart.js, Apache ECharts, Recharts, and Observable Plot using pinned packages and reproducible fixtures. +- docs/comparison.md — Compare Libraries: Compare current TanStack Charts workspace source with pinned Chart.js, Apache ECharts, Recharts, and Observable Plot packages. - docs/installation.md — Installation: Install TanStack Charts, a framework adapter, and the granular D3 modules used by your charts. - docs/quick-start.md — Quick Start: Build, mount, update, and clean up a responsive TanStack Charts line chart with fully inferred types. - docs/framework/react/quick-start.md — React Quick Start: Install the React adapter, define a typed chart, render responsive SVG, and add native interaction. @@ -89,10 +89,10 @@ Read the canonical pages below. Each concept is documented once; guides and exam Authoring rules: - Use direct, granular d3-* imports for scales and analytical preparation; never import the d3 umbrella. -- Let TanStack Charts own responsive pixel ranges while configured D3 scales own domains, ticks, and formatting. +- Let TanStack Charts own responsive pixel ranges. D3 factories infer domains from mark channels; configured instances preserve application-owned domains. - Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model. - Memoize the complete definition against captured application values; definition identity is the application update boundary. -- Use stable datum keys for updates, animation, and selection. +- Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. - Prefer built-in marks, then composition, then a custom mark or application-owned overlay. - Treat docs/concepts/scales-and-d3.md as the sole D3 integration contract and follow its official D3 links for D3 API details. - Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript. diff --git a/package.json b/package.json index 3849ec3b..7db1474f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "conformance:size": "node scripts/compare-plot-catalog.mjs --size-only", "performance": "node scripts/measure-rendering.mjs", "package:check": "node scripts/check-packed-consumers.mjs && pnpm adapters:check", + "release:artifacts": "node scripts/build-release-artifacts.mjs", + "release:check": "node scripts/publish-release.mjs --check", + "release:publish": "node scripts/publish-release.mjs", "test": "vitest run && vitest run --config vitest.solid.config.ts && vitest run --config vitest.solid-ssr.config.ts && vitest run --config vitest.svelte.config.ts && vitest run --config vitest.svelte-ssr.config.ts && vitest run --config vitest.octane.config.ts && vitest run --config vitest.octane-client.config.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, @@ -117,6 +120,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "3.10.1", + "semver": "7.8.5", "solid-js": "^1.9.13", "svelte": "^5.56.2", "topojson-client": "3.1.0", diff --git a/packages/alpine-charts/package.json b/packages/alpine-charts/package.json index 31d3b6d1..b1cd4701 100644 --- a/packages/alpine-charts/package.json +++ b/packages/alpine-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/alpine-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/alpine-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -24,6 +29,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/angular-charts/package.json b/packages/angular-charts/package.json index b26b1be2..edb0a471 100644 --- a/packages/angular-charts/package.json +++ b/packages/angular-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/angular-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/angular-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -30,6 +35,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/types/tanstack-angular-charts.d.ts", diff --git a/packages/charts-core/README.md b/packages/charts-core/README.md index cb2ec3f9..4fbe663a 100644 --- a/packages/charts-core/README.md +++ b/packages/charts-core/README.md @@ -1,6 +1,6 @@ # TanStack Charts -Tiny chart grammar for TypeScript and JavaScript. Marks consume your data +A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. D3 supplies battle-tested algorithms; TanStack supplies the grammar, scene compiler, responsive range adapter, rendering, and @@ -10,19 +10,20 @@ TanStack Charts is an independent implementation for typed application infrastructure. Project lineage is recorded in the repository [`ACKNOWLEDGEMENTS.md`](https://github.com/TanStack/charts/blob/main/ACKNOWLEDGEMENTS.md). -Install the granular D3 modules your chart imports as direct application -dependencies. Strict package managers do not expose TanStack Charts' -transitive dependencies for application imports: +Install TanStack Charts with the granular D3 modules your chart imports as +direct application dependencies. Strict package managers do not expose +TanStack Charts' transitive dependencies for application imports: ```sh -pnpm add @tanstack/charts d3-array d3-scale d3-shape -pnpm add -D @types/d3-array @types/d3-scale @types/d3-shape +pnpm add @tanstack/charts d3-scale d3-shape +pnpm add -D @types/d3-scale @types/d3-shape ``` Omit any D3 module and matching type package that your chart does not use. + + ```ts -import { extent, max } from 'd3-array' import { scaleLinear, scaleOrdinal, scaleUtc } from 'd3-scale' import { curveMonotoneX } from 'd3-shape' import { colorLegend, d3Curve, defineChart, lineY } from '@tanstack/charts' @@ -55,32 +56,25 @@ const data: readonly DownloadRow[] = [ }, ] -const [firstDate, lastDate] = extent(data, (row) => row.date) -const dateDomain: [Date, Date] = - firstDate && lastDate - ? [firstDate, lastDate] - : [new Date(0), new Date(86_400_000)] -const downloadMax = max(data, (row) => row.downloads) ?? 0 -const packages = [...new Set(data.map((row) => row.package))] - const downloads = defineChart({ marks: [ lineY(data, { x: 'date', y: 'downloads', z: 'package', - key: 'id', curve: d3Curve(curveMonotoneX), }), ], - x: { scale: scaleUtc().domain(dateDomain).nice() }, + x: { scale: scaleUtc, nice: true }, y: { - scale: scaleLinear().domain([0, downloadMax]).nice(), + scale: scaleLinear, + nice: true, label: 'Weekly downloads', grid: true, }, color: { - scale: scaleOrdinal(packages, ['#0ea5e9', '#f97316', '#10b981']), + scale: () => + scaleOrdinal().range(['#0ea5e9', '#f97316', '#10b981']), legend: colorLegend({ label: 'Package' }), }, animate: true, @@ -155,16 +149,18 @@ ordinary bundles. - Marks: `lineY`, `areaX`, `areaY`, `barX`, `barY`, `dot`, `rect`, `cell`, `ruleX`, `ruleY`, `text`, `arrow`, `frame`, `hexagon`, `link`, `tickX`, `tickY`, `vector`, and responsive `facet` composition -- Scales: required raw D3 positional scales through the responsive range - adapter; raw D3 color and radius scales are consumed directly +- Scales: D3 factories with inferred domains or configured D3 instances with + application-owned domains, copied through responsive range adapters - Guides: responsive axes, grids, labels, categorical legends, and gradient legends - Data preparation: direct `d3-array` and `d3-shape` output, server-prepared intervals, and application-derived rows flow into ordinary marks -- Runtime: stable dynamic definitions, responsive measurement, keyed +- Runtime: object and responsive definitions, definition-identity updates, + responsive measurement, keyed reconciliation, interruptible animation, pointer and keyboard focus, point activation, native tooltips, SSR, and hydration -- Renderers: static SVG and a vanilla DOM host +- Renderers: static SVG, a vanilla DOM host, optional Canvas, and custom + renderer hosts - Optional export: standalone SVG and browser raster export from `@tanstack/charts/export` - Optional dense interaction: an application-supplied @@ -188,7 +184,6 @@ overhang, and axis titles. The solve may resolve guide scales more than once, but marks render once against the final plot rectangle. ```ts -import { max } from 'd3-array' import { scaleBand, scaleLinear } from 'd3-scale' import { barX, defineChart } from '@tanstack/charts' @@ -197,18 +192,16 @@ const rankingRows = [ { package: 'Router', downloads: 420_000 }, { package: 'Table', downloads: 360_000 }, ] -const maximum = max(rankingRows, (row) => row.downloads) ?? 0 const chart = defineChart({ marks: [barX(rankingRows, { x: 'downloads', y: 'package' })], x: { - scale: scaleLinear().domain([0, maximum]).nice(), + scale: scaleLinear, + nice: true, label: 'Weekly downloads', }, y: { - scale: scaleBand() - .domain(rankingRows.map((row) => row.package)) - .padding(0.1), + scale: () => scaleBand().padding(0.1), }, }) ``` @@ -229,11 +222,11 @@ supply `measureText` on the host, adapter, runtime, or `createChartScene` layout options. Its returned `x` and `y` are the painted box offsets relative to the requested anchor and baseline. -Definitions accept configured D3 scales directly, and `createChartScene` -rejects missing positional scales. TanStack copies each caller scale, applies -the responsive pixel range, and centers D3 band output. The supplied scale owns -its domain, mapping, ticks, and formatting and is never mutated. Named D3 -imports keep each capability tree-shakeable: +Definitions accept D3 factories for inferred domains and configured instances +for application-owned domains. `createChartScene` rejects missing positional +scales. TanStack copies each scale, applies the responsive pixel range, and +centers D3 band output without mutating the source. Named D3 imports keep each +capability tree-shakeable: ```ts import { createChartScene, defineChart, lineY } from '@tanstack/charts' diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 83ec24cc..7b66f430 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -1,25 +1,27 @@ --- title: Compare Libraries -description: Compare TanStack Charts with Chart.js, Apache ECharts, Recharts, and Observable Plot using pinned packages and reproducible fixtures. +description: Compare current TanStack Charts workspace source with pinned Chart.js, Apache ECharts, Recharts, and Observable Plot packages. --- -TanStack Charts is currently an unpublished `0.0.0` product proof, not a -production replacement for the established releases below. This comparison -records the architectural differences and evidence available today without -turning untested behavior into a checkmark. +TanStack Charts `0.0.1` is a pre-alpha release. Its results on this page measure +the workspace implementation prepared for `0.0.1`, not the earlier published +`0.0.0` artifact. This comparison records architectural differences and +reproducible evidence without turning untested behavior into a checkmark. ## Tested versions -| Library | Package | Pinned version | -| -------------------------------------------------------------------------------------- | -------------------- | -------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | `0.0.0` | -| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | `4.5.1` | -| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | `6.1.0` | -| [Recharts](https://recharts.github.io/en-US/) | `recharts` | `3.10.1` | -| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | `0.6.17` | +| Library | Package | Measured source | +| -------------------------------------------------------------------------------------- | -------------------- | ------------------- | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `99c08eb` | +| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | +| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | +| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | +| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | -These are exact repository pins, not the latest versions inferred at page -render time. +The competitor versions are exact package pins, not latest versions inferred +at page render time. The TanStack product implementation ends at commit +`a91106c`; the measured workspace revision is `99c08eb`, which adds the +comparison fixture correction and tracked baseline for `0.0.1`. ## Capability matrix @@ -50,7 +52,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-07-29`. +Baseline date: `2026-07-30`. Each range covers 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Full size is @@ -59,14 +61,15 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 19.02–22.23 KiB | — | +| TanStack Charts | 24.19–28.20 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.00–168.18 KiB | 94.88–109.87 KiB | | Observable Plot | 83.34–91.94 KiB | — | -The tracked baseline records the package versions and complete chart/tier -matrix; the deterministic bundle gate rejects either kind of drift. +The tracked baseline distinguishes the TanStack workspace revision from +competitor package versions and records the complete chart/tier matrix; the +deterministic bundle gate rejects either kind of drift. The range is not an install size or a runtime-speed ranking. The comparison builds the current TanStack workspace source and the pinned competitor @@ -82,10 +85,12 @@ reference coverage, not each library's feature ceiling or a list of built-in TanStack chart types. Chart.js participates in the standard and stress suites, not the catalog corpus. -The catalog displays each renderer entry and its case-local data or transform -dependencies. Its report counts the complete transitive authored source and -publishes the source-line ratio for every pair; moving transforms into -`data.ts` does not remove it from the comparison. +The catalog displays each renderer entry, its transitive support and transform +files, and provenance for imported demo datasets. Its report counts the +complete authored source closure and publishes the source-line ratio for every +pair; moving a transform or layout into a support module does not remove it +from the comparison, while raw snapshot rows are not treated as chart +authoring. TanStack deliberately keeps several responsibilities outside the default runtime: @@ -106,10 +111,10 @@ Canvas composition while keeping D3 and state ownership explicit. ## Evidence and reproduction -- [Standard comparison protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/README.md) -- [Tracked bundle baseline](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/bundle-baseline.json) -- [Stress protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/comparison/stress/README.md) -- [Catalog conformance protocol](https://github.com/TanStack/charts/blob/9d23a50af0cb2ea9fa157e06dabf9f7ce4255b1b/benchmarks/conformance/README.md) +- [Standard comparison protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/README.md) +- [Tracked bundle baseline](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/bundle-baseline.json) +- [Stress protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/comparison/stress/README.md) +- [Catalog conformance protocol](https://github.com/TanStack/charts/blob/v0.0.1/benchmarks/conformance/README.md) ```sh pnpm benchmark:size diff --git a/packages/charts-core/docs/concepts/chart-definitions.md b/packages/charts-core/docs/concepts/chart-definitions.md index 12d736a7..462a1a1f 100644 --- a/packages/charts-core/docs/concepts/chart-definitions.md +++ b/packages/charts-core/docs/concepts/chart-definitions.md @@ -14,10 +14,22 @@ Pass a complete spec when the chart does not need its resolved surface size: ```ts -import { alphabet } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const letterFrequencies = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], x: { diff --git a/packages/charts-core/docs/concepts/data-and-channels.md b/packages/charts-core/docs/concepts/data-and-channels.md index 924dbcbb..b8370be2 100644 --- a/packages/charts-core/docs/concepts/data-and-channels.md +++ b/packages/charts-core/docs/concepts/data-and-channels.md @@ -278,10 +278,61 @@ responsive layout work. ## Complete bubble-scatter example ```ts -import { penguins, type PenguinsRow } from '@charts-poc/demo-data/penguins' import { scaleLinear, scaleOrdinal, scaleSqrt } from 'd3-scale' import { colorLegend, defineChart, dot } from '@tanstack/charts' +interface PenguinsRow { + species: string + culmen_length_mm: number | null + culmen_depth_mm: number | null + body_mass_g: number | null +} + +const penguins: readonly PenguinsRow[] = [ + { + species: 'Adelie', + culmen_length_mm: 39.1, + culmen_depth_mm: 18.7, + body_mass_g: 3750, + }, + { + species: 'Adelie', + culmen_length_mm: 40.3, + culmen_depth_mm: 18, + body_mass_g: 3250, + }, + { + species: 'Chinstrap', + culmen_length_mm: 46.5, + culmen_depth_mm: 17.9, + body_mass_g: 3500, + }, + { + species: 'Chinstrap', + culmen_length_mm: 50, + culmen_depth_mm: 19.5, + body_mass_g: 3900, + }, + { + species: 'Gentoo', + culmen_length_mm: 46.1, + culmen_depth_mm: 13.2, + body_mass_g: 4500, + }, + { + species: 'Gentoo', + culmen_length_mm: 50, + culmen_depth_mm: 16.3, + body_mass_g: 5700, + }, + { + species: 'Gentoo', + culmen_length_mm: null, + culmen_depth_mm: null, + body_mass_g: null, + }, +] + type CompletePenguin = PenguinsRow & { culmen_length_mm: number culmen_depth_mm: number diff --git a/packages/charts-core/docs/concepts/grammar-of-graphics.md b/packages/charts-core/docs/concepts/grammar-of-graphics.md index fee72584..badabd4b 100644 --- a/packages/charts-core/docs/concepts/grammar-of-graphics.md +++ b/packages/charts-core/docs/concepts/grammar-of-graphics.md @@ -26,10 +26,22 @@ The result is one `ChartSpec` compiled into a renderer-neutral scene. ## The smallest useful declaration ```ts -import { alphabet } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +interface LetterFrequency { + letter: string + frequency: number +} + +const alphabet: readonly LetterFrequency[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const chart = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], x: { scale: scaleBand }, @@ -37,9 +49,9 @@ const chart = defineChart({ }) ``` -The mark consumes the published letter-frequency rows directly and maps their -existing fields to x and y. No universal series wrapper or renamed chart fields -sit between the source data and the mark. +The mark consumes the typed letter-frequency rows directly and maps their +existing fields to x and y. No universal series wrapper or renamed chart +fields sit between the source data and the mark. Because this example imports `d3-scale` directly, add `d3-scale` and `@types/d3-scale` as direct dependencies. [Scales and D3](./scales-and-d3.md) explains why scales remain explicit. @@ -163,12 +175,71 @@ Omitted margins are measured from the actual guides. See [Layout, Axes, and Coor Marks render in array order. Put context behind the primary data and annotations above it: ```ts -import { weather } from '@charts-poc/demo-data/weather' import { scaleBand, scaleLinear } from 'd3-scale' import { curveMonotoneX } from 'd3-shape' import { areaY, barY, d3Curve, defineChart, dot, lineY } from '@tanstack/charts' -const rows = weather.filter((row) => row.location === 'Seattle').slice(37, 43) +interface WeatherRow { + location: string + date: Date + precipitation: number + temp_max: number + temp_min: number + wind: number +} + +const weather: readonly WeatherRow[] = [ + { + location: 'Seattle', + date: new Date('2026-03-01T00:00:00Z'), + precipitation: 0.5, + temp_max: 9.4, + temp_min: 3.2, + wind: 4.1, + }, + { + location: 'Seattle', + date: new Date('2026-03-02T00:00:00Z'), + precipitation: 3.1, + temp_max: 8.2, + temp_min: 2.8, + wind: 5.2, + }, + { + location: 'Seattle', + date: new Date('2026-03-03T00:00:00Z'), + precipitation: 1.4, + temp_max: 10.6, + temp_min: 4.1, + wind: 3.8, + }, + { + location: 'Seattle', + date: new Date('2026-03-04T00:00:00Z'), + precipitation: 0, + temp_max: 12.7, + temp_min: 5.3, + wind: 2.9, + }, + { + location: 'Seattle', + date: new Date('2026-03-05T00:00:00Z'), + precipitation: 2.2, + temp_max: 11.1, + temp_min: 4.7, + wind: 4.6, + }, + { + location: 'Seattle', + date: new Date('2026-03-06T00:00:00Z'), + precipitation: 0.3, + temp_max: 13.4, + temp_min: 6.1, + wind: 3.3, + }, +] + +const rows = weather.filter((row) => row.location === 'Seattle') const composedChart = defineChart({ marks: [ diff --git a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md index 4b6c9c5b..018bfa37 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -285,10 +285,25 @@ Automatic margins only reserve space for chart-owned guides and legends. Applica ## Complete horizontal ranking ```ts -import { citywages } from '@charts-poc/demo-data/citywages' import { scaleBand, scaleLinear } from 'd3-scale' import { barX, defineChart, ruleX } from '@tanstack/charts' +interface MetroPopulation { + Metro: string + POP_2015: number +} + +const citywages: readonly MetroPopulation[] = [ + { Metro: 'New York–Newark–Jersey City', POP_2015: 20_182_305 }, + { Metro: 'Los Angeles–Long Beach–Anaheim', POP_2015: 13_340_068 }, + { Metro: 'Chicago–Naperville–Elgin', POP_2015: 9_532_569 }, + { Metro: 'Dallas–Fort Worth–Arlington', POP_2015: 7_206_144 }, + { Metro: 'Houston–The Woodlands–Sugar Land', POP_2015: 6_656_947 }, + { Metro: 'Washington–Arlington–Alexandria', POP_2015: 6_097_684 }, + { Metro: 'Philadelphia–Camden–Wilmington', POP_2015: 6_069_875 }, + { Metro: 'Miami–Fort Lauderdale–West Palm Beach', POP_2015: 6_012_331 }, +] + const rows = [...citywages] .sort((left, right) => right.POP_2015 - left.POP_2015) .slice(0, 8) diff --git a/packages/charts-core/docs/concepts/marks-and-layering.md b/packages/charts-core/docs/concepts/marks-and-layering.md index 60267ff4..daa3203d 100644 --- a/packages/charts-core/docs/concepts/marks-and-layering.md +++ b/packages/charts-core/docs/concepts/marks-and-layering.md @@ -194,10 +194,24 @@ Clipping applies to the chart’s mark group, not axes or legends. Leave it off ## Complete range-band composition ```ts -import { sfTemperatures } from '@charts-poc/demo-data/sf-temperatures' import { scaleLinear, scaleUtc } from 'd3-scale' import { areaY, defineChart, lineY } from '@tanstack/charts' +interface DailyTemperature { + date: Date + high: number + low: number +} + +const sfTemperatures: readonly DailyTemperature[] = [ + { date: new Date('2026-07-01T00:00:00Z'), high: 68, low: 55 }, + { date: new Date('2026-07-02T00:00:00Z'), high: 71, low: 56 }, + { date: new Date('2026-07-03T00:00:00Z'), high: 66, low: 54 }, + { date: new Date('2026-07-04T00:00:00Z'), high: 69, low: 55 }, + { date: new Date('2026-07-05T00:00:00Z'), high: 73, low: 57 }, + { date: new Date('2026-07-06T00:00:00Z'), high: 70, low: 56 }, +] + const temperatureChart = defineChart({ marks: [ areaY(sfTemperatures, { diff --git a/packages/charts-core/docs/concepts/scales-and-d3.md b/packages/charts-core/docs/concepts/scales-and-d3.md index d2e3412e..92308027 100644 --- a/packages/charts-core/docs/concepts/scales-and-d3.md +++ b/packages/charts-core/docs/concepts/scales-and-d3.md @@ -43,7 +43,7 @@ Use the official D3 pages as the API reference for each algorithm. TanStack Char | 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 | +| Pan and zoom | [`d3-zoom`](https://d3js.org/d3-zoom) | Own the gesture, update application state, and rebuild the definition with a configured 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` | @@ -313,10 +313,23 @@ When the application owns the gesture, disable the native nearest-point focus st ```ts -import { flare, type FlareRow } from '@charts-poc/demo-data/flare' import { scaleLinear, scaleLog } from 'd3-scale' import { defineChart, dot } from '@tanstack/charts' +interface FlareRow { + name: string + size: number | null +} + +const flare: readonly FlareRow[] = [ + { name: 'flare.analytics.cluster', size: 3938 }, + { name: 'flare.analytics.graph', size: 10_871 }, + { name: 'flare.analytics.optimization', size: 5731 }, + { name: 'flare.display', size: 12_867 }, + { name: 'flare.query', size: 2779 }, + { name: 'flare.unresolved', size: null }, +] + type SizedFlareRow = FlareRow & { size: number } const rows = flare diff --git a/packages/charts-core/docs/examples/annotations-and-overlays.md b/packages/charts-core/docs/examples/annotations-and-overlays.md index 5c6f3ac5..7ae13059 100644 --- a/packages/charts-core/docs/examples/annotations-and-overlays.md +++ b/packages/charts-core/docs/examples/annotations-and-overlays.md @@ -38,10 +38,11 @@ category's endpoints, and labels the values directly. style="width:100%;height:440px;border:0;" > -Use stable category keys for the links and endpoints. Direct labels remove a -legend lookup, but they need collision policy when values converge. Filter to -meaningful categories, increase vertical space, or use an accessible detail -view rather than allowing unreadable overlap. +Preserve category identity for the links and endpoints; supply `key` only when +the mark cannot infer it. Direct labels remove a legend lookup, but they need +collision policy when values converge. Filter to meaningful categories, +increase vertical space, or use an accessible detail view rather than allowing +unreadable overlap. A slope implies before-to-after order. Label both periods and keep the same quantitative scale. diff --git a/packages/charts-core/docs/examples/bars-and-rankings.md b/packages/charts-core/docs/examples/bars-and-rankings.md index de6ad110..04f8c356 100644 --- a/packages/charts-core/docs/examples/bars-and-rankings.md +++ b/packages/charts-core/docs/examples/bars-and-rankings.md @@ -107,5 +107,6 @@ contracts are in [Bar and Rect Marks](../reference/marks/bar-and-rect.md). shape can carry the essential comparison. - Verify long labels and rotated ticks with [Responsive Charts](../guides/responsive-charts.md). -- Use stable category keys when values reorder or animate. See +- Preserve unique category values when bars reorder or animate; supply `key` + only when the category does not identify a row. See [Dynamic Data and Animation](../guides/dynamic-data-and-animation.md). diff --git a/packages/charts-core/docs/examples/facets-and-multiple-views.md b/packages/charts-core/docs/examples/facets-and-multiple-views.md index 9e2983b6..21a75e2d 100644 --- a/packages/charts-core/docs/examples/facets-and-multiple-views.md +++ b/packages/charts-core/docs/examples/facets-and-multiple-views.md @@ -105,7 +105,7 @@ Shared selection, cursor, category, or domain state belongs in the application: 1. A view emits a semantic value through focus, selection, or a controlled gesture. 2. Application state validates and stores that value. -3. Each view derives its own input and configured scales. +3. Each view derives its own definition and configured scales. 4. Each chart compiles a new scene through its normal update path. Do not query one SVG for a pixel and apply that pixel directly to another view. diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index efee88ab..5852347b 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -130,8 +130,8 @@ A complete editor should: - Keep color-independent event labels visible. Do not mutate a rectangle and treat that painted geometry as the saved record. -Update application state, validate it, and let the next definition input -produce the scene. +Update application state, validate it, and let the next definition produce the +scene. ## State and lifecycle diff --git a/packages/charts-core/docs/examples/lines-and-areas.md b/packages/charts-core/docs/examples/lines-and-areas.md index 04729d3f..3ae52dc2 100644 --- a/packages/charts-core/docs/examples/lines-and-areas.md +++ b/packages/charts-core/docs/examples/lines-and-areas.md @@ -110,7 +110,8 @@ separate layers remain easier to update and extend. - Use a temporal scale for dates and define the domain in application data semantics, as described in [Scales and D3](../concepts/scales-and-d3.md). -- Give each moving row and series a stable key. See +- Preserve row IDs or unique positions across updates, and group series with + `z`; supply `key` only when the mark cannot infer identity. See [Dynamic Data and Animation](../guides/dynamic-data-and-animation.md). - Let automatic layout measure tick labels, then verify the smallest container in [Responsive Charts](../guides/responsive-charts.md). diff --git a/packages/charts-core/docs/examples/maps-and-spatial.md b/packages/charts-core/docs/examples/maps-and-spatial.md index 2df68d11..e054dfa8 100644 --- a/packages/charts-core/docs/examples/maps-and-spatial.md +++ b/packages/charts-core/docs/examples/maps-and-spatial.md @@ -104,19 +104,69 @@ Cartesian or geo-only consumer bundles. ## Project GeoJSON responsively -Give `geoShape` a projection factory and an explicit fit target. This example -uses Observable Plot's published Westport House floor plan and preserves its -planar coordinates. The mark fits the projection to the final plot bounds -again whenever the chart resizes. +Give `geoShape` a projection factory and an explicit fit target. This +self-contained example uses a small planar floor plan. The mark fits the +projection to the final plot bounds again whenever the chart resizes. ```ts -import { westportHouse } from '@charts-poc/demo-data/westport-house' import { defineChart } from '@tanstack/charts' import { geoShape } from '@tanstack/charts/geo' import { geoIdentity } from 'd3-geo' +interface FloorPlanFeature { + type: 'Feature' + properties: { id: number } + geometry: { + type: 'Polygon' + coordinates: [number, number][][] + } +} + +interface FloorPlan { + type: 'FeatureCollection' + features: FloorPlanFeature[] +} + +const westportHouse: FloorPlan = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { id: 1 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [48, 0], + [48, 28], + [0, 28], + [0, 0], + ], + ], + }, + }, + { + type: 'Feature', + properties: { id: 2 }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [52, 0], + [84, 0], + [84, 28], + [52, 28], + [52, 0], + ], + ], + }, + }, + ], +} + const map = defineChart({ marks: [ geoShape(westportHouse.features, { diff --git a/packages/charts-core/docs/examples/polar-and-radar.md b/packages/charts-core/docs/examples/polar-and-radar.md index 2efe5bf0..5bb5d929 100644 --- a/packages/charts-core/docs/examples/polar-and-radar.md +++ b/packages/charts-core/docs/examples/polar-and-radar.md @@ -32,11 +32,23 @@ nonzero inner radius is a donut. ```ts -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { defineChart } from '@tanstack/charts' import { polar, radialArc } from '@tanstack/charts/polar' import { pie } from 'd3-shape' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const partColors = ['#0ea5e9', '#6366f1', '#a855f7', '#ec4899', '#f97316'] const letters = alphabet.slice(0, 5) const pieLayout = pie() @@ -141,11 +153,25 @@ a separate geometry implementation. ```ts -import { survey, type SurveyRow } from '@charts-poc/demo-data/survey' import { defineChart } from '@tanstack/charts' import { polar, radialArc } from '@tanstack/charts/polar' import { pie } from 'd3-shape' +interface SurveyRow { + Question: string + ID: number + Response: string +} + +const survey: readonly SurveyRow[] = [ + { Question: 'Q1', ID: 1, Response: 'Strongly Agree' }, + { Question: 'Q1', ID: 2, Response: 'Agree' }, + { Question: 'Q1', ID: 3, Response: 'Agree' }, + { Question: 'Q1', ID: 4, Response: 'Neutral' }, + { Question: 'Q1', ID: 5, Response: 'Disagree' }, + { Question: 'Q2', ID: 1, Response: 'Neutral' }, +] + interface GaugePart { id: 'agreement' | 'other' value: number @@ -223,7 +249,6 @@ polar guides and radial marks. TanStack supplies both responsive ranges. ```ts -import { decathlon, type DecathlonRow } from '@charts-poc/demo-data/decathlon' import { defineChart } from '@tanstack/charts' import { angleGrid, @@ -237,6 +262,45 @@ import { extent } from 'd3-array' import { scaleLinear, scalePoint } from 'd3-scale' import { curveLinearClosed } from 'd3-shape' +interface DecathlonRow { + Country: string + '100 Meters': number + 'Long Jump': number + 'High Jump': number + '100 Meter Hurdles': number +} + +const decathlon: readonly DecathlonRow[] = [ + { + Country: 'United States', + '100 Meters': 10.35, + 'Long Jump': 7.96, + 'High Jump': 2.05, + '100 Meter Hurdles': 13.61, + }, + { + Country: 'Great Britain', + '100 Meters': 10.44, + 'Long Jump': 7.74, + 'High Jump': 2.11, + '100 Meter Hurdles': 13.75, + }, + { + Country: 'Germany', + '100 Meters': 10.67, + 'Long Jump': 7.62, + 'High Jump': 2.08, + '100 Meter Hurdles': 14.02, + }, + { + Country: 'France', + '100 Meters': 10.58, + 'Long Jump': 7.81, + 'High Jump': 1.99, + '100 Meter Hurdles': 13.88, + }, +] + const events = [ '100 Meters', 'Long Jump', @@ -264,7 +328,7 @@ function radarProfile(row: DecathlonRow) { } const athlete = decathlon[0] -if (!athlete) throw new Error('The decathlon snapshot is empty') +if (!athlete) throw new Error('The decathlon data is empty') const profile = radarProfile(athlete) const radar = defineChart({ @@ -339,8 +403,6 @@ measurements without renaming those measurements into chart fields. ```ts -import { weather, type WeatherRow } from '@charts-poc/demo-data/weather' -import { wind, type WindRow } from '@charts-poc/demo-data/wind' import { defineChart } from '@tanstack/charts' import { angleGrid, @@ -351,6 +413,59 @@ import { } from '@tanstack/charts/polar' import { scaleLinear } from 'd3-scale' +interface WeatherRow { + location: string + date: Date + temp_max: number +} + +const weather: readonly WeatherRow[] = [ + { + location: 'Seattle', + date: new Date('2012-01-15T00:00:00Z'), + temp_max: 8.3, + }, + { + location: 'Seattle', + date: new Date('2012-03-15T00:00:00Z'), + temp_max: 12.2, + }, + { + location: 'Seattle', + date: new Date('2012-05-15T00:00:00Z'), + temp_max: 18.9, + }, + { + location: 'Seattle', + date: new Date('2012-07-15T00:00:00Z'), + temp_max: 25.6, + }, + { + location: 'Seattle', + date: new Date('2012-09-15T00:00:00Z'), + temp_max: 21.1, + }, + { + location: 'Seattle', + date: new Date('2012-11-15T00:00:00Z'), + temp_max: 11.7, + }, +] + +interface WindRow { + latitude: number + u: number + v: number +} + +const wind: readonly WindRow[] = [ + { latitude: 48.125, u: 4.2, v: 1.6 }, + { latitude: 48.125, u: 2.1, v: 5.8 }, + { latitude: 48.125, u: -3.4, v: 6.2 }, + { latitude: 48.125, u: -5.1, v: -2.3 }, + { latitude: 48.125, u: 1.8, v: -4.7 }, +] + const seattle2012 = weather.filter( (row) => row.location === 'Seattle' && row.date.getUTCFullYear() === 2012, ) @@ -481,7 +596,8 @@ the isolated consumer budgets. - 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. +- Let marks infer identity from source IDs or unique positions; supply a key + when neither is available. - 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. diff --git a/packages/charts-core/docs/framework/octane/quick-start.md b/packages/charts-core/docs/framework/octane/quick-start.md index 4b8fb95f..410df5e1 100644 --- a/packages/charts-core/docs/framework/octane/quick-start.md +++ b/packages/charts-core/docs/framework/octane/quick-start.md @@ -21,11 +21,23 @@ Definitions are framework-independent and can be shared with any adapter: ```tsx -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' import { Chart } from '@tanstack/octane-charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 16966c35..7e932a5e 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -151,7 +151,7 @@ transient, so display-only content can remain visible but controls should render only while `pinned` is true. Definition `tooltip.portal: true` promotes the whole surface above clipped ancestors without changing this React API. -## Definition and input identity +## Definition identity Define a fixed chart outside component render: diff --git a/packages/charts-core/docs/framework/react/quick-start.md b/packages/charts-core/docs/framework/react/quick-start.md index 81ac2725..0d88e12b 100644 --- a/packages/charts-core/docs/framework/react/quick-start.md +++ b/packages/charts-core/docs/framework/react/quick-start.md @@ -21,11 +21,23 @@ Definitions are ordinary framework-independent TypeScript: ```tsx -import { alphabet, type AlphabetRow } from '@charts-poc/demo-data/alphabet' import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' import { Chart } from '@tanstack/react-charts' +interface AlphabetRow { + letter: string + frequency: number +} + +const alphabet: readonly AlphabetRow[] = [ + { letter: 'E', frequency: 0.12702 }, + { letter: 'T', frequency: 0.09056 }, + { letter: 'A', frequency: 0.08167 }, + { letter: 'O', frequency: 0.07507 }, + { letter: 'I', frequency: 0.06966 }, +] + const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, diff --git a/packages/charts-core/docs/guides/ai-authoring.md b/packages/charts-core/docs/guides/ai-authoring.md index 6355795d..e9ecfdf9 100644 --- a/packages/charts-core/docs/guides/ai-authoring.md +++ b/packages/charts-core/docs/guides/ai-authoring.md @@ -53,7 +53,7 @@ Generated code should include: - a complete chart definition; - complete adapter or host usage; - a meaningful `ariaLabel`; -- stable keys; +- stable inferred or explicit identity; - empty and constant-domain policies when applicable. It should not require readers to invent undeclared variables, hidden imports, @@ -87,8 +87,8 @@ Run, in order: 4. Light and dark visual checks. 5. A narrow production bundle measurement when a new capability is imported. -For dynamic charts, also test reorder, resize, empty data, replacement input, -and a burst that must settle on the latest revision. +For changing charts, also test reorder, resize, empty data, replacement data, +and a burst that must settle on the latest definition. ## Request template 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 df3ebc8a..46733862 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -116,9 +116,11 @@ interaction policies. ## Update efficiently -- Keep definitions at module scope. -- Reuse input references when data is unchanged. -- Give every mutable visual entity a stable key. +- Keep fixed definitions at module scope. +- Memoize captured-data definitions until their application values change. +- Reuse derived data references when source data is unchanged. +- Let marks infer identity from IDs or unique positions; supply `key` only when + that identity is unavailable or can change. - Memoize expensive derived data in the application. - Bound streaming windows. - Build a spatial index only when a measurement justifies it. diff --git a/packages/charts-core/docs/guides/faceting-and-composition.md b/packages/charts-core/docs/guides/faceting-and-composition.md index b1adf284..f67f2d3e 100644 --- a/packages/charts-core/docs/guides/faceting-and-composition.md +++ b/packages/charts-core/docs/guides/faceting-and-composition.md @@ -109,7 +109,8 @@ explains how to base region geometry on the final chart bounds. ## Composition checklist - Layer order reflects visual occlusion and reading order. -- Each mark keeps its natural data shape and stable keys. +- Each mark keeps its natural data shape and stable inferred or explicit + identity. - Shared scales are used only where direct positional comparison is intended. - Facet axis policy is explicit. - Each independently interactive view has its own accessible name. diff --git a/packages/charts-core/docs/guides/interactions-and-selections.md b/packages/charts-core/docs/guides/interactions-and-selections.md index c01891e3..0f42a044 100644 --- a/packages/charts-core/docs/guides/interactions-and-selections.md +++ b/packages/charts-core/docs/guides/interactions-and-selections.md @@ -41,7 +41,7 @@ Every application-owned gesture follows the same loop: 3. Convert pointer geometry into semantic values. 4. Clamp, snap, or validate those values as product policy. 5. update application state. -6. Let the normal chart input produce the next scene. +6. Let the next definition produce the scene. Do not mutate SVG geometry directly and then attempt to reconcile application state afterward. @@ -132,8 +132,9 @@ DOM behavior. Decide: - touch pinch and cancellation; - reset and follow-latest behavior. -Use `d3-zoom` and `d3-selection` when they improve modality handling. Feed the -resulting domain back into the chart input. +Use `d3-zoom` and `d3-selection` when they improve modality handling. Store the +resulting domain in application state and rebuild the definition with a +configured scale. ## Linked views diff --git a/packages/charts-core/docs/guides/ssr-and-hydration.md b/packages/charts-core/docs/guides/ssr-and-hydration.md index 59f655f9..5a6da3da 100644 --- a/packages/charts-core/docs/guides/ssr-and-hydration.md +++ b/packages/charts-core/docs/guides/ssr-and-hydration.md @@ -22,7 +22,7 @@ runtime and renderer on the server and in the browser. | [Alpine](../framework/alpine/adapter.md) | None | Browser-only directive | For adapters with server output, the browser must render the same definition, -input, dimensions, formatters, and component tree. Angular and Lit may run +dimensions, formatters, and component tree. Angular and Lit may run inside applications with their own server infrastructure, but this library does not yet promise or test adapter hydration for them. @@ -53,19 +53,22 @@ See [Responsive Charts](./responsive-charts.md) for the complete size policy. ## Keep output deterministic -Server and first-client output must agree for the same definition, input, size, -and options. In particular: +Server and first-client output must agree for the same definition, size, and +options. In particular: -- create definitions at module scope; +- keep fixed definitions at module scope and recreate captured-data definitions + from the same resolved data; - sort unordered collections before creating marks; - do not read `window`, layout, time, locale, or random values while building a definition; - pass locale-sensitive formatters explicitly; -- use stable keys derived from data identity; +- rely on inferred IDs or unique positions, and supply explicit keys when the + data has no stable identity; - provide `idPrefix` when multiple render roots need coordinated resource IDs. -Dynamic chart functions are synchronous. Fetch and transform data in the -application's server/data layer, then pass the resolved input to the chart. +Responsive chart functions are synchronous. Fetch and transform data in the +application's server/data layer, then capture the resolved data in the +definition. ## Hydration ownership @@ -112,8 +115,8 @@ instead of shipping a font engine to the server. ```ts import { createChartRuntime, renderChartSvg } from '@tanstack/charts' -const runtime = createChartRuntime() -const scene = runtime.render(definition, input, { width: 720, height: 400 }) +const runtime = createChartRuntime() +const scene = runtime.render(definition, { width: 720, height: 400 }) const svg = renderChartSvg(scene, { ariaLabel: 'Daily traffic', @@ -130,9 +133,9 @@ surface mounts. ## Hydration checklist -- Server input is fully resolved before chart rendering. +- Server data is fully resolved before chart rendering. - Initial dimensions are explicit and representative. -- Definition, transformed input, and formatting are deterministic. +- Definition, transformed data, and formatting are deterministic. - Keys and `idPrefix` are stable. - The same adapter and definition render on both sides. - Browser-only work lives in host callbacks or application effects. diff --git a/packages/charts-core/docs/guides/typescript.md b/packages/charts-core/docs/guides/typescript.md index 203e0ff8..16c09a8b 100644 --- a/packages/charts-core/docs/guides/typescript.md +++ b/packages/charts-core/docs/guides/typescript.md @@ -30,8 +30,8 @@ const definition = defineChart({ y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, }) ``` @@ -50,8 +50,8 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, }) } ``` @@ -77,8 +77,8 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime() }, - y: { scale: scaleLinear() }, + x: { scale: scaleTime }, + y: { scale: scaleLinear }, margin: width < 480 ? 24 : 40, })) } @@ -175,7 +175,7 @@ Do not use it to make application examples compile. ## No-cast checklist -- Datum and input types are declared at the application boundary. +- Datum and captured application values are typed at the application boundary. - Channel fields are checked against the datum. - Scale domains match inferred coordinate types. - Definitions preserve their literal mark tuple. diff --git a/packages/charts-core/docs/installation.md b/packages/charts-core/docs/installation.md index 7b1b85dd..0d062c36 100644 --- a/packages/charts-core/docs/installation.md +++ b/packages/charts-core/docs/installation.md @@ -3,7 +3,9 @@ title: Installation description: Install TanStack Charts, a framework adapter, and the granular D3 modules used by your charts. --- -Install the framework-agnostic core in every application that authors chart definitions: +TanStack Charts `0.0.1` publishes the framework-agnostic core and every adapter +listed below. Install the core in each application that authors chart +definitions: ```sh pnpm add @tanstack/charts @@ -62,7 +64,10 @@ needs browser mounting or server rendering. ## Install the D3 modules you import -TanStack Charts accepts configured D3 scales and the output of D3 transforms directly. Your application must declare every `d3-*` module that its source imports. Strict package managers do not expose transitive dependencies as an application import contract. +TanStack Charts accepts D3 scale factories, configured scale instances, and +the output of D3 transforms directly. Your application must declare every +`d3-*` module that its source imports. Strict package managers do not expose +transitive dependencies as an application import contract. A typical cartesian chart uses: diff --git a/packages/charts-core/docs/overview.md b/packages/charts-core/docs/overview.md index 68783785..2b6c0b49 100644 --- a/packages/charts-core/docs/overview.md +++ b/packages/charts-core/docs/overview.md @@ -3,6 +3,9 @@ title: Overview description: Learn what TanStack Charts provides, how its grammar works, and where charting responsibilities belong. --- +TanStack Charts `0.0.1` is a pre-alpha release. Its API may change between +releases. + TanStack Charts is a small, framework-agnostic chart grammar for TypeScript and JavaScript. Give each mark its natural data, map fields or accessors to visual channels, and supply the D3 scales that define the meaning of each axis. TanStack Charts compiles that declaration into a responsive, keyed scene and renders accessible SVG by default, with Canvas available as an opt-in surface. TanStack Charts builds on the grammar-of-graphics tradition established by @@ -27,16 +30,30 @@ adapter. React and Octane also provide optional Canvas entries. ```ts -import { aapl } from '@charts-poc/demo-data/aapl' import { mean } from 'd3-array' import { scaleLinear, scaleUtc } from 'd3-scale' import { areaY, defineChart, lineY } from '@tanstack/charts' -const observations = aapl.slice(0, 120) +interface ClosingPrice { + Date: Date + Close: number +} + +const observations: readonly ClosingPrice[] = [ + { Date: new Date('2013-05-13T00:00:00Z'), Close: 64.96 }, + { Date: new Date('2013-05-14T00:00:00Z'), Close: 63.41 }, + { Date: new Date('2013-05-15T00:00:00Z'), Close: 61.26 }, + { Date: new Date('2013-05-16T00:00:00Z'), Close: 62.08 }, + { Date: new Date('2013-05-17T00:00:00Z'), Close: 61.89 }, + { Date: new Date('2013-05-20T00:00:00Z'), Close: 63.28 }, + { Date: new Date('2013-05-21T00:00:00Z'), Close: 62.81 }, + { Date: new Date('2013-05-22T00:00:00Z'), Close: 63.05 }, +] + const rows = observations.flatMap((row, index) => { - if (index < 19) return [] + if (index < 2) return [] const average = mean( - observations.slice(index - 19, index + 1), + observations.slice(index - 2, index + 1), (observation) => observation.Close, ) return average === undefined ? [] : [{ ...row, average }] @@ -109,12 +126,12 @@ TanStack Charts owns the parts that make a declarative chart reliable inside an TanStack Charts deliberately does not hide data or spatial algorithms behind a second abstraction. -| Responsibility | Owner | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| Scale domains, scale semantics, binning, stacking, grouping, interpolation, and spatial algorithms | Your application using the granular D3 modules it needs | -| Fetching, cleaning, profiling, and exploratory analysis | Your data layer, server, or AI workflow | -| Marks, channels, responsive ranges, guide layout, scenes, rendering, and chart lifecycle | TanStack Charts | -| Page controls, queries, filters, persistence, and application state | Your application | +| Responsibility | Owner | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Scale choice and configuration, fixed semantic domains, transforms, interpolation, and spatial algorithms | Your application using the granular D3 modules it needs | +| Fetching, cleaning, profiling, and exploratory analysis | Your data layer, server, or AI workflow | +| Mark-channel domain inference, responsive ranges, guide layout, scenes, rendering, and chart lifecycle | TanStack Charts | +| Page controls, queries, filters, persistence, and application state | Your application | This division keeps the core small and makes advanced work explicit. Prepared data can come from D3, SQL, a server, or ordinary TypeScript; marks consume it without requiring a special series container. @@ -126,7 +143,8 @@ The normal path is intentionally short: - Omit `margin` to measure axes, tick labels, rotation, and titles automatically. - Supply `ariaLabel`; keyboard focus is enabled by default. - Add `tooltip: true` to the definition when a native value tooltip is enough. -- Use stable `key` channels for rows that can move, enter, or leave. +- Let built-in marks infer stable identity from IDs or unique positions; supply + `key` when that identity is unavailable or can change. - Let field names, datum types, scales, interaction points, and adapters infer without casts. - Use inherited `currentColor` and the `--ts-chart-*` CSS variables for automatic theme integration. diff --git a/packages/charts-core/docs/quick-start.md b/packages/charts-core/docs/quick-start.md index eece4bb6..05b1613c 100644 --- a/packages/charts-core/docs/quick-start.md +++ b/packages/charts-core/docs/quick-start.md @@ -20,13 +20,27 @@ The host follows the container width when `width` is omitted. ```ts -import { aapl } from '@charts-poc/demo-data/aapl' import { scaleLinear, scaleUtc } from 'd3-scale' import { defineChart, lineY, mountChart } from '@tanstack/charts' +interface ClosingPrice { + Date: Date + Close: number +} + +const closingPrices: readonly ClosingPrice[] = [ + { Date: new Date('2013-11-01T00:00:00Z'), Close: 74.29 }, + { Date: new Date('2013-12-02T00:00:00Z'), Close: 78.75 }, + { Date: new Date('2014-01-02T00:00:00Z'), Close: 79.02 }, + { Date: new Date('2014-02-03T00:00:00Z'), Close: 71.65 }, + { Date: new Date('2014-03-03T00:00:00Z'), Close: 75.39 }, + { Date: new Date('2014-04-01T00:00:00Z'), Close: 77.38 }, + { Date: new Date('2014-05-01T00:00:00Z'), Close: 84.5 }, +] + const closingPriceChart = defineChart({ marks: [ - lineY(aapl, { + lineY(closingPrices, { id: 'apple-close', x: 'Date', y: (row) => (row.Date.getUTCMonth() < 3 ? null : row.Close), @@ -52,8 +66,9 @@ Because this source imports `d3-scale` directly, add it and `@types/d3-scale` as direct dependencies. See [Installation](./installation.md). The accessor deliberately omits first-quarter observations, creating visible -breaks instead of misleading segments. The original AAPL row flows through the -mark and into interaction callbacks; no cast or manual chart generic is needed. +breaks instead of misleading segments. The original closing-price row flows +through the mark and into interaction callbacks; no cast or manual chart +generic is needed. ## 3. Mount it @@ -112,7 +127,8 @@ Destroying the host removes observers, event listeners, animations, tooltips, an ## What the declaration means -- `lineY(aapl, ...)` chooses a line mark and keeps each original AAPL row as the interaction datum. +- `lineY(closingPrices, ...)` chooses a line mark and keeps each source row as + the interaction datum. - `x: 'Date'` maps the source date field; the y accessor returns `Close` or an intentional gap. - The unique date gives each observation stable positional identity across updates. - D3 scale factories infer domains from mark channels and own mapping behavior. diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index 3c5e3d49..ef59e598 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -62,7 +62,7 @@ capabilities and individual marks independently tree-shakeable. | Import | Public values | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@tanstack/charts` | Common marks, legends, D3 curve bridges, `createMark`, `defineChart`, `createChartScene`, `createChartRuntime`, `mountChart`, `renderChartSvg`, and runtime comparison helpers | +| `@tanstack/charts` | Common marks, legends, D3 curve bridges, `createMark`, `defineChart`, `createChartScene`, `createChartRuntime`, `isDynamicChartDefinition`, `mountChart`, and `renderChartSvg` | | `@tanstack/charts/adapter` | `createChartAdapter`, `resolveChartAdapterLayout`, `ChartAdapter`, `ChartAdapterLayout`, and `ChartAdapterLayoutOptions` | | `@tanstack/charts/adapter/renderer` | `createChartRendererAdapter` | | `@tanstack/charts/area` | `areaY` | @@ -90,7 +90,7 @@ capabilities and individual marks independently tree-shakeable. | `@tanstack/charts/rect` | `rect`, `cell` | | `@tanstack/charts/renderer` | `mountChartRenderer` | | `@tanstack/charts/rule` | `ruleX`, `ruleY` | -| `@tanstack/charts/runtime` | `createChartRuntime`, definition and input comparison helpers | +| `@tanstack/charts/runtime` | `createChartRuntime`, `isDynamicChartDefinition` | | `@tanstack/charts/scene` | `defineChart`, `createChartScene`, `defaultChartTheme`, `findNearestPoint` | | `@tanstack/charts/svg` | `renderChartSvg` | | `@tanstack/charts/svg/renderer` | `createSvgChartRenderer`, `svgChartRenderer` | diff --git a/packages/charts-core/docs/reference/rendering-and-export.md b/packages/charts-core/docs/reference/rendering-and-export.md index 90e3ba17..b664eb85 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -476,7 +476,6 @@ Use `mountChartRenderer` from `@tanstack/charts/renderer`, or the React and Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartSurfaceRenderOptions`, `ChartSurface`, `ChartRenderer`, `ChartRendererRenderContext`, `ChartRendererHostCommonOptions`, -`StaticChartRendererHostOptions`, `DynamicChartRendererHostOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 6209effd..ae2b33ab 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -4,9 +4,9 @@ description: Public TypeScript types, inference rules, channels, definitions, sc --- TanStack Charts is inference-first. A mark's source data and channel selectors -flow through its definition into scales, axis formatters, host input, focus -callbacks, and selection callbacks. Normal application code should not cast -chart definitions or supply adapter generics. +flow through its definition into scales, axis formatters, host and adapter +callbacks, focus callbacks, and selection callbacks. Normal application code +should not cast chart definitions or supply adapter generics. ## Values and channels diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index c1cb045a..9761b941 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -5,7 +5,7 @@ TanStack Charts is a framework-agnostic, type-safe visualization grammar with th Read the canonical pages below. Each concept is documented once; guides and examples link back to its owner page. - docs/overview.md — Overview: Learn what TanStack Charts provides, how its grammar works, and where charting responsibilities belong. -- docs/comparison.md — Compare Libraries: Compare TanStack Charts with Chart.js, Apache ECharts, Recharts, and Observable Plot using pinned packages and reproducible fixtures. +- docs/comparison.md — Compare Libraries: Compare current TanStack Charts workspace source with pinned Chart.js, Apache ECharts, Recharts, and Observable Plot packages. - docs/installation.md — Installation: Install TanStack Charts, a framework adapter, and the granular D3 modules used by your charts. - docs/quick-start.md — Quick Start: Build, mount, update, and clean up a responsive TanStack Charts line chart with fully inferred types. - docs/framework/react/quick-start.md — React Quick Start: Install the React adapter, define a typed chart, render responsive SVG, and add native interaction. @@ -89,10 +89,10 @@ Read the canonical pages below. Each concept is documented once; guides and exam Authoring rules: - Use direct, granular d3-* imports for scales and analytical preparation; never import the d3 umbrella. -- Let TanStack Charts own responsive pixel ranges while configured D3 scales own domains, ticks, and formatting. +- Let TanStack Charts own responsive pixel ranges. D3 factories infer domains from mark channels; configured instances preserve application-owned domains. - Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model. - Memoize the complete definition against captured application values; definition identity is the application update boundary. -- Use stable datum keys for updates, animation, and selection. +- Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. - Prefer built-in marks, then composition, then a custom mark or application-owned overlay. - Treat docs/concepts/scales-and-d3.md as the sole D3 integration contract and follow its official D3 links for D3 API details. - Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript. diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index 15d2b98b..4fcb201c 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/charts-core" + }, "type": "module", "sideEffects": false, "files": [ @@ -64,6 +69,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/lit-charts/package.json b/packages/lit-charts/package.json index da9686f9..4127d9ff 100644 --- a/packages/lit-charts/package.json +++ b/packages/lit-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/lit-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/lit-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -24,6 +29,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/octane-charts/package.json b/packages/octane-charts/package.json index 426c0a15..f134fd15 100644 --- a/packages/octane-charts/package.json +++ b/packages/octane-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/octane-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/octane-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -27,6 +32,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/preact-charts/package.json b/packages/preact-charts/package.json index a2f6bdec..c9fba74d 100644 --- a/packages/preact-charts/package.json +++ b/packages/preact-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/preact-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/preact-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -25,6 +30,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/react-charts/README.md b/packages/react-charts/README.md index e907a205..cd888189 100644 --- a/packages/react-charts/README.md +++ b/packages/react-charts/README.md @@ -2,12 +2,12 @@ React lifecycle adapter for `@tanstack/charts`. -Declare the adapter, core grammar, framework peer, and each D3 module used by +Declare the adapter, core grammar, framework peers, and each D3 module used by your chart directly: ```sh -pnpm add @tanstack/charts @tanstack/react-charts react d3-scale -pnpm add -D @types/d3-scale @types/react +pnpm add @tanstack/charts @tanstack/react-charts react react-dom d3-scale +pnpm add -D @types/d3-scale @types/react @types/react-dom ``` Add or omit granular `d3-*` modules and their matching type packages with the @@ -45,8 +45,9 @@ pulls Canvas into the default bundle. The adapter server-renders the complete shared SVG. On the client, React owns only the outer host; the framework-neutral chart host owns measurement, -reconciliation, animation, and interaction. Shallow-equal inline plain-object -input does not replace the live SVG. +reconciliation, animation, and interaction. Reuse the definition while its +captured values are unchanged; a new definition updates the mounted surface +without replacing it. The definition drives all prop inference. Focus, group, selection, and render callbacks infer the original datum. Do not add adapter generics or cast adapter diff --git a/packages/react-charts/package.json b/packages/react-charts/package.json index 8b013937..210ba616 100644 --- a/packages/react-charts/package.json +++ b/packages/react-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/react-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/react-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -28,6 +33,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/solid-charts/package.json b/packages/solid-charts/package.json index 1eb12954..4df1a081 100644 --- a/packages/solid-charts/package.json +++ b/packages/solid-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/solid-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/solid-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -28,6 +33,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/svelte-charts/package.json b/packages/svelte-charts/package.json index 6199ce4f..17edd450 100644 --- a/packages/svelte-charts/package.json +++ b/packages/svelte-charts/package.json @@ -1,11 +1,16 @@ { "name": "@tanstack/svelte-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/svelte-charts" + }, "type": "module", "sideEffects": false, - "svelte": "./src/index.ts", + "svelte": "./dist/index.js", "files": [ "dist", "LICENSE", @@ -32,6 +37,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/vue-charts/package.json b/packages/vue-charts/package.json index a0f2d30a..4febf087 100644 --- a/packages/vue-charts/package.json +++ b/packages/vue-charts/package.json @@ -1,8 +1,13 @@ { "name": "@tanstack/vue-charts", - "version": "0.0.0", - "private": true, + "version": "0.0.1", + "private": false, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/vue-charts" + }, "type": "module", "sideEffects": false, "files": [ @@ -25,6 +30,7 @@ }, "publishConfig": { "access": "public", + "provenance": true, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22bb92a6..7da4a9b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: recharts: specifier: 3.10.1 version: 3.10.1(@types/react@19.2.17)(react-dom@19.2.3(react@19.2.3))(react-is@19.2.8)(react@19.2.3)(redux@5.0.1) + semver: + specifier: 7.8.5 + version: 7.8.5 solid-js: specifier: ^1.9.13 version: 1.9.14 diff --git a/scripts/build-release-artifacts.mjs b/scripts/build-release-artifacts.mjs new file mode 100644 index 00000000..215f33e3 --- /dev/null +++ b/scripts/build-release-artifacts.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + createReleaseArtifactManifest, + validateReleaseArtifacts, +} from './release-artifacts.mjs' +import { releaseArtifactsDirectoryName } from './release-package-config.mjs' + +const repositoryRoot = resolve(import.meta.dirname, '..') +const artifactDirectory = resolve(repositoryRoot, releaseArtifactsDirectoryName) + +assert.equal( + artifactDirectory, + resolve(repositoryRoot, '.release-artifacts'), + 'Refusing to replace an unexpected artifact directory', +) + +await rm(artifactDirectory, { recursive: true, force: true }) +await mkdir(artifactDirectory, { recursive: true }) + +for (const script of [ + 'check-packed-consumers.mjs', + 'check-framework-adapters.mjs', +]) { + await run(process.execPath, [ + resolve(repositoryRoot, 'scripts', script), + '--artifacts-dir', + artifactDirectory, + ]) +} + +await createReleaseArtifactManifest(repositoryRoot) +const { artifacts, version } = await validateReleaseArtifacts(repositoryRoot) +console.log( + `Validated ${artifacts.length} release artifacts for ${version} in ${artifactDirectory}.`, +) + +function run(command, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env: { ...process.env, CI: 'true' }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + reject( + new Error( + `${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}`, + ), + ) + }) + }) +} diff --git a/scripts/check-framework-adapters.mjs b/scripts/check-framework-adapters.mjs index 1fbabbb7..6a51acc8 100644 --- a/scripts/check-framework-adapters.mjs +++ b/scripts/check-framework-adapters.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { + mkdir, mkdtemp, readFile, readdir, @@ -19,9 +20,11 @@ import { validatePackedMarkdownLinks } from './packed-markdown-links.mjs' const execFileAsync = promisify(execFile) const root = resolve(import.meta.dirname, '..') +const artifactDirectory = parseArtifactDirectory(process.argv.slice(2)) const temporaryDirectory = await mkdtemp( resolve(tmpdir(), 'tanstack-charts-framework-packages-'), ) +const tarballDirectory = artifactDirectory ?? temporaryDirectory const packageNames = [ 'preact-charts', 'vue-charts', @@ -39,6 +42,7 @@ const standardPackages = [ ] try { + await mkdir(tarballDirectory, { recursive: true }) for (const [directory, jsxImportSource] of standardPackages) { await buildStandardPackage(directory, jsxImportSource) } @@ -193,8 +197,10 @@ async function verifyPackage(directory) { const manifest = JSON.parse( await readFile(resolve(packageRoot, 'package.json'), 'utf8'), ) + assert.equal(manifest.private, false) assert.equal(manifest.type, 'module') assert.equal(manifest.sideEffects, false) + assert.equal(manifest.publishConfig?.provenance, true) assert.deepEqual( Object.keys(manifest.exports).sort(), Object.keys(manifest.publishConfig.exports).sort(), @@ -209,7 +215,10 @@ async function verifyPackage(directory) { } } - const tarball = resolve(temporaryDirectory, `${directory}.tgz`) + const tarball = resolve( + tarballDirectory, + `${directory}-${manifest.version}.tgz`, + ) const { stdout } = await run( 'pnpm', ['pack', '--out', tarball, '--json'], @@ -331,3 +340,18 @@ function formatDiagnostics(diagnostics) { getNewLine: () => '\n', }) } + +function parseArtifactDirectory(args) { + if (args.length === 0) return null + assert.deepEqual( + args.slice(0, 1), + ['--artifacts-dir'], + 'Usage: node scripts/check-framework-adapters.mjs [--artifacts-dir ]', + ) + assert.equal( + args.length, + 2, + 'Usage: node scripts/check-framework-adapters.mjs [--artifacts-dir ]', + ) + return resolve(process.cwd(), args[1]) +} diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index 85027db6..e0bd690d 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -23,11 +23,12 @@ import { validatePackedMarkdownLinks } from './packed-markdown-links.mjs' const execFileAsync = promisify(execFile) const root = resolve(import.meta.dirname, '..') const rootManifest = JSON.parse(await readFile(resolve(root, 'package.json'))) +const artifactDirectory = parseArtifactDirectory(process.argv.slice(2)) const temporaryRoot = await mkdtemp( resolve(tmpdir(), 'tanstack-charts-packed-consumer-'), ) const buildWorkspace = resolve(temporaryRoot, 'build') -const tarballDirectory = resolve(temporaryRoot, 'tarballs') +const tarballDirectory = artifactDirectory ?? resolve(temporaryRoot, 'tarballs') const fixtureDirectory = resolve(temporaryRoot, 'consumer') const packages = [ @@ -111,6 +112,7 @@ async function buildPackage(packageInfo) { function validateManifest(packageInfo) { const { manifest } = packageInfo + assert.equal(manifest.private, false, `${manifest.name} must be publishable`) assert.equal(manifest.type, 'module', `${manifest.name} must publish ESM`) assert.equal( manifest.sideEffects, @@ -125,6 +127,11 @@ function validateManifest(packageInfo) { manifest.publishConfig?.exports, `${manifest.name} requires publishConfig.exports`, ) + assert.equal( + manifest.publishConfig?.provenance, + true, + `${manifest.name} must publish provenance`, + ) assert.deepEqual( Object.keys(manifest.publishConfig.exports).sort(), Object.keys(manifest.exports).sort(), @@ -1352,6 +1359,21 @@ function formatDiagnostics(diagnostics) { }) } +function parseArtifactDirectory(args) { + if (args.length === 0) return null + assert.deepEqual( + args.slice(0, 1), + ['--artifacts-dir'], + 'Usage: node scripts/check-packed-consumers.mjs [--artifacts-dir ]', + ) + assert.equal( + args.length, + 2, + 'Usage: node scripts/check-packed-consumers.mjs [--artifacts-dir ]', + ) + return resolve(process.cwd(), args[1]) +} + function formatBytes(bytes) { return `${(bytes / 1024).toFixed(2)} kB` } diff --git a/scripts/compare-chart-libraries.mjs b/scripts/compare-chart-libraries.mjs index f5483ef5..2ccac740 100644 --- a/scripts/compare-chart-libraries.mjs +++ b/scripts/compare-chart-libraries.mjs @@ -15,6 +15,10 @@ import { comparisonTiers, formatComparisonImplementationDetail, } from './benchmark/comparison-capabilities.mjs' +import { + tanstackComparisonRevision, + tanstackComparisonSourceFailure, +} from './comparison-source-revision.mjs' const root = resolve(import.meta.dirname, '..') const comparisonDirectory = resolve(root, 'benchmarks/comparison') @@ -1079,10 +1083,26 @@ async function writeBundleBaseline( baselineChartTypes, baselineTiers, ) { + const sourceRevision = tanstackComparisonRevision(root) const baseline = { - schemaVersion: 2, + schemaVersion: 3, generatedAt: new Date().toISOString(), - versions: baselineVersions, + packageVersions: baselineVersions, + sources: Object.fromEntries( + libraries.map((library) => [ + library.id, + library.id === 'tanstack' + ? { + kind: 'workspace', + revision: sourceRevision, + } + : { + kind: 'package', + packageName: library.packageName, + version: baselineVersions[library.id], + }, + ]), + ), matrix: { chartTypes: baselineChartTypes, tiers: baselineTiers, @@ -1118,7 +1138,8 @@ async function checkBundleBaseline(bundles, actualVersions) { } const failures = [] - if (baseline.schemaVersion !== 2) { + const expectedTanStackRevision = tanstackComparisonRevision(root) + if (baseline.schemaVersion !== 3) { failures.push( 'bundle baseline schema is stale; run pnpm benchmark:update-baseline', ) @@ -1133,7 +1154,7 @@ async function checkBundleBaseline(bundles, actualVersions) { ) } for (const library of libraries) { - const expectedVersion = baseline.versions?.[library.id] + const expectedVersion = baseline.packageVersions?.[library.id] if (!expectedVersion) { failures.push( `${library.label}: bundle baseline is missing its package version`, @@ -1146,6 +1167,22 @@ async function checkBundleBaseline(bundles, actualVersions) { `${library.label}: installed version ${actualVersion} does not match baseline ${expectedVersion}`, ) } + const source = baseline.sources?.[library.id] + if (library.id === 'tanstack') { + const sourceFailure = tanstackComparisonSourceFailure( + source, + expectedTanStackRevision, + ) + if (sourceFailure) failures.push(`${library.label}: ${sourceFailure}`) + } else if ( + source?.kind !== 'package' || + source.packageName !== library.packageName || + source.version !== expectedVersion + ) { + failures.push( + `${library.label}: bundle baseline package provenance is missing or stale`, + ) + } } const actualIds = new Set(bundles.map((bundle) => bundle.id)) const expectedIds = new Set(Object.keys(baseline.bundles)) diff --git a/scripts/comparison-source-revision.mjs b/scripts/comparison-source-revision.mjs new file mode 100644 index 00000000..341acbab --- /dev/null +++ b/scripts/comparison-source-revision.mjs @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process' + +export const tanstackComparisonInputPaths = [ + 'benchmarks/comparison/libraries/tanstack', + 'packages/charts-core/src', + 'benchmarks/comparison/libraries/tier.ts', + 'benchmarks/comparison/stress/operation.ts', + 'benchmarks/comparison/types.ts', +] + +export function tanstackComparisonRevision(repositoryRoot) { + const revision = execFileSync( + 'git', + ['log', '-1', '--format=%H', '--', ...tanstackComparisonInputPaths], + { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim() + + if (!/^[0-9a-f]{40}$/u.test(revision)) { + throw new Error( + 'Unable to resolve the TanStack comparison input revision from Git history', + ) + } + return revision +} + +export function tanstackComparisonSourceFailure(source, expectedRevision) { + if ( + source?.kind !== 'workspace' || + !/^[0-9a-f]{40}$/u.test(source.revision) + ) { + return 'bundle baseline must record its workspace revision' + } + if (source.revision !== expectedRevision) { + return `bundle baseline workspace revision ${source.revision} does not match measured inputs ${expectedRevision}` + } +} diff --git a/scripts/comparison-source-revision.test.mjs b/scripts/comparison-source-revision.test.mjs new file mode 100644 index 00000000..d5e5c83b --- /dev/null +++ b/scripts/comparison-source-revision.test.mjs @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + tanstackComparisonInputPaths, + tanstackComparisonRevision, + tanstackComparisonSourceFailure, +} from './comparison-source-revision.mjs' + +const temporaryRepositories = [] + +afterEach(async () => { + await Promise.all( + temporaryRepositories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('TanStack comparison source provenance', () => { + it('uses the last commit that changed a measured input', async () => { + const repository = await mkdtemp( + resolve(tmpdir(), 'charts-comparison-revision-'), + ) + temporaryRepositories.push(repository) + runGit(repository, 'init') + + const coreInput = resolve( + repository, + tanstackComparisonInputPaths[1], + 'index.ts', + ) + await mkdir(dirname(coreInput), { recursive: true }) + await writeFile(coreInput, 'export const value = 1\n') + commitAll(repository, 'Add measured source') + const measuredRevision = runGit(repository, 'rev-parse', 'HEAD') + + await writeFile(resolve(repository, 'README.md'), '# Documentation\n') + commitAll(repository, 'Update documentation') + + expect(tanstackComparisonRevision(repository)).toBe(measuredRevision) + }) + + it('rejects a well-formed revision from different inputs', () => { + const expectedRevision = 'a'.repeat(40) + const recordedRevision = 'b'.repeat(40) + + expect( + tanstackComparisonSourceFailure( + { kind: 'workspace', revision: recordedRevision }, + expectedRevision, + ), + ).toBe( + `bundle baseline workspace revision ${recordedRevision} does not match measured inputs ${expectedRevision}`, + ) + }) +}) + +function commitAll(repository, message) { + runGit(repository, 'add', '.') + runGit( + repository, + '-c', + 'user.name=TanStack Charts Test', + '-c', + 'user.email=charts-test@example.com', + 'commit', + '-m', + message, + ) +} + +function runGit(repository, ...args) { + return execFileSync('git', args, { + cwd: repository, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() +} diff --git a/scripts/docs-contract.mjs b/scripts/docs-contract.mjs index 350499a6..fa66a226 100644 --- a/scripts/docs-contract.mjs +++ b/scripts/docs-contract.mjs @@ -32,6 +32,20 @@ const allowedChartLibraryLinks = new Map([ ['comparison.md', new Set(Object.values(comparisonOfficialSources))], ]) +const publicEntryPaths = [ + 'README.md', + 'packages/charts-core/README.md', + 'packages/preact-charts/README.md', + 'packages/react-charts/README.md', + 'packages/vue-charts/README.md', + 'packages/solid-charts/README.md', + 'packages/svelte-charts/README.md', + 'packages/angular-charts/README.md', + 'packages/lit-charts/README.md', + 'packages/alpine-charts/README.md', + 'packages/octane-charts/README.md', +] + export async function validateDocsContract(repositoryRoot) { const docsRoot = resolve(repositoryRoot, 'docs') const configPath = resolve(docsRoot, 'config.json') @@ -55,17 +69,26 @@ export async function validateDocsContract(repositoryRoot) { validateIframes(path, source, cases, embeddedCases, failures) } - await validatePublicEntryLinks(repositoryRoot, failures) + const publicEntrySources = new Map() + for (const path of publicEntryPaths) { + publicEntrySources.set( + path, + await readFile(resolve(repositoryRoot, path), 'utf8'), + ) + } + const publicSources = new Map([...markdownSources, ...publicEntrySources]) + + validatePublicEntryLinks(publicEntrySources, failures) await validateApiCoverage(repositoryRoot, markdownSources, failures) await validateComparisonEvidence(repositoryRoot, markdownSources, failures) await validateDocumentedTanStackImports( repositoryRoot, - markdownSources, + publicSources, failures, ) const standaloneExamples = validateStandaloneExamples( repositoryRoot, - markdownSources, + publicSources, failures, ) @@ -155,8 +178,8 @@ export function isPublicChartLibraryLinkAllowed(path, href) { export function comparisonBaselineContractFailures(baseline, expectedVersions) { const failures = [] - if (baseline.schemaVersion !== 2) { - failures.push('comparison bundle baseline must use schema version 2') + if (baseline.schemaVersion !== 3) { + failures.push('comparison bundle baseline must use schema version 3') } if ( !sameStrings(baseline.matrix?.chartTypes ?? [], comparisonChartTypes) || @@ -166,11 +189,30 @@ export function comparisonBaselineContractFailures(baseline, expectedVersions) { } for (const library of chartLibraries) { const manifestVersion = expectedVersions[library.id] - if (baseline.versions?.[library.id] !== manifestVersion) { + if (baseline.packageVersions?.[library.id] !== manifestVersion) { failures.push( `comparison bundle baseline version is stale for ${library.label}: expected ${manifestVersion}`, ) } + const source = baseline.sources?.[library.id] + if (library.id === 'tanstack') { + if ( + source?.kind !== 'workspace' || + !/^[0-9a-f]{40}$/u.test(source.revision) + ) { + failures.push( + 'comparison bundle baseline must record the TanStack workspace revision', + ) + } + } else if ( + source?.kind !== 'package' || + source.packageName !== library.packageName || + source.version !== manifestVersion + ) { + failures.push( + `comparison bundle baseline package provenance is stale for ${library.label}`, + ) + } } const expectedBundleIds = new Set( @@ -412,27 +454,9 @@ function validateIframes(path, source, cases, embeddedCases, failures) { } } -async function validatePublicEntryLinks(repositoryRoot, failures) { - const paths = [ - 'README.md', - 'packages/charts-core/README.md', - 'packages/preact-charts/README.md', - 'packages/react-charts/README.md', - 'packages/vue-charts/README.md', - 'packages/solid-charts/README.md', - 'packages/svelte-charts/README.md', - 'packages/angular-charts/README.md', - 'packages/lit-charts/README.md', - 'packages/alpine-charts/README.md', - 'packages/octane-charts/README.md', - ] - - for (const path of paths) { - validatePublicLinks( - path, - await readFile(resolve(repositoryRoot, path), 'utf8'), - failures, - ) +function validatePublicEntryLinks(sources, failures) { + for (const [path, source] of sources) { + validatePublicLinks(path, source, failures) } } @@ -475,30 +499,20 @@ async function validateDocumentedTanStackImports( } } - const sources = new Map(markdownSources) - for (const path of [ - 'README.md', - 'packages/charts-core/README.md', - 'packages/preact-charts/README.md', - 'packages/react-charts/README.md', - 'packages/vue-charts/README.md', - 'packages/solid-charts/README.md', - 'packages/svelte-charts/README.md', - 'packages/angular-charts/README.md', - 'packages/lit-charts/README.md', - 'packages/alpine-charts/README.md', - 'packages/octane-charts/README.md', - ]) { - sources.set(path, await readFile(resolve(repositoryRoot, path), 'utf8')) - } - - for (const [path, source] of sources) { + for (const [path, source] of markdownSources) { for (const error of typedCodeFenceSyntaxErrors(source)) { failures.push( `${path} typed code fence ${error.fence} has invalid syntax: ${error.message}`, ) } for (const code of typedCodeFences(source)) { + for (const match of code.matchAll( + /(?:\bfrom\s+|\bimport\s*\(\s*)['"](@charts-poc\/[^'"]+)['"]/g, + )) { + failures.push( + `${path} imports private workspace package ${match[1]} from public documentation`, + ) + } for (const [specifier, names] of importedNamesBySpecifier(code)) { if (!specifier.startsWith('@tanstack/')) continue const available = exportsBySpecifier.get(specifier) @@ -560,6 +574,12 @@ async function validateComparisonEvidence( const rootManifest = JSON.parse( await readFile(resolve(repositoryRoot, 'package.json'), 'utf8'), ) + const baseline = JSON.parse( + await readFile( + resolve(repositoryRoot, 'benchmarks/comparison/bundle-baseline.json'), + 'utf8', + ), + ) const manifestVersions = new Map() for (const library of chartLibraries) { @@ -571,14 +591,15 @@ async function validateComparisonEvidence( rootManifest.devDependencies?.[library.packageName]) manifestVersions.set(library.id, version) const packageCell = `\`${library.packageName}\`` - const versionCell = `\`${version}\`` + const sourceCell = + library.id === 'tanstack' + ? `workspace \`${baseline.sources?.tanstack?.revision?.slice(0, 7)}\`` + : `npm \`${version}\`` if ( - !rows.some( - (row) => row.includes(packageCell) && row.includes(versionCell), - ) + !rows.some((row) => row.includes(packageCell) && row.includes(sourceCell)) ) { failures.push( - `comparison.md must pair ${packageCell} with pinned version ${versionCell}`, + `comparison.md must pair ${packageCell} with measured source ${sourceCell}`, ) } } @@ -606,12 +627,6 @@ async function validateComparisonEvidence( } } - const baseline = JSON.parse( - await readFile( - resolve(repositoryRoot, 'benchmarks/comparison/bundle-baseline.json'), - 'utf8', - ), - ) failures.push( ...comparisonBaselineContractFailures( baseline, diff --git a/scripts/docs-contract.test.mjs b/scripts/docs-contract.test.mjs index 1617e2fb..410517ab 100644 --- a/scripts/docs-contract.test.mjs +++ b/scripts/docs-contract.test.mjs @@ -114,8 +114,34 @@ Body 'observable-plot': '0.6.17', } const baseline = { - schemaVersion: 2, - versions, + schemaVersion: 3, + packageVersions: versions, + sources: { + tanstack: { + kind: 'workspace', + revision: '1'.repeat(40), + }, + chartjs: { + kind: 'package', + packageName: 'chart.js', + version: versions.chartjs, + }, + echarts: { + kind: 'package', + packageName: 'echarts', + version: versions.echarts, + }, + recharts: { + kind: 'package', + packageName: 'recharts', + version: versions.recharts, + }, + 'observable-plot': { + kind: 'package', + packageName: '@observablehq/plot', + version: versions['observable-plot'], + }, + }, matrix: { chartTypes, tiers }, bundles: Object.fromEntries( libraryIds.flatMap((library) => @@ -132,11 +158,13 @@ Body expect(comparisonBaselineContractFailures(baseline, versions)).toEqual([]) const stale = structuredClone(baseline) - stale.versions.chartjs = '4.5.0' + stale.packageVersions.chartjs = '4.5.0' + stale.sources.tanstack.revision = 'unknown' delete stale.bundles['tanstack-line-basic'] expect(comparisonBaselineContractFailures(stale, versions)).toEqual( expect.arrayContaining([ 'comparison bundle baseline version is stale for Chart.js: expected 4.5.1', + 'comparison bundle baseline must record the TanStack workspace revision', 'comparison bundle baseline must contain the complete 60-case matrix', ]), ) diff --git a/scripts/evaluate-chart-authoring.mjs b/scripts/evaluate-chart-authoring.mjs index 83199076..0dcf8092 100644 --- a/scripts/evaluate-chart-authoring.mjs +++ b/scripts/evaluate-chart-authoring.mjs @@ -17,8 +17,18 @@ import { spawn } from 'node:child_process' import { gzipSync } from 'node:zlib' import { chromium } from 'playwright' import ts from 'typescript' +import { isExactNpmPackageVersion } from './package-version.mjs' const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const chartsPackageVersion = JSON.parse( + await readFile( + resolve(repositoryRoot, 'packages/charts-core/package.json'), + 'utf8', + ), +).version +if (!isExactNpmPackageVersion(chartsPackageVersion)) { + throw new TypeError('@tanstack/charts requires a package version') +} const cohortRoot = resolve( repositoryRoot, '.benchmark-output/conformance/ai/smoke-v1', @@ -203,7 +213,7 @@ function workspaceFiles(entry, renderer) { '@observablehq/plot': '0.6.17', } : { - '@tanstack/charts': '0.0.0', + '@tanstack/charts': chartsPackageVersion, 'd3-array': '3.2.4', 'd3-scale': '4.0.2', } @@ -367,26 +377,34 @@ the relevant primitives, not a complete implementation. return `# Routed TanStack Charts notes -Pinned packages: \`@tanstack/charts@0.0.0\`, \`d3-array@3.2.4\`, and +Pinned packages: \`@tanstack/charts@${chartsPackageVersion}\`, +\`d3-array@3.2.4\`, and \`d3-scale@4.0.2\`. This offline synopsis is pinned from the package README and task-oriented recipes shipped with the local package. -- Marks consume prepared rows. D3 owns aggregation and binning. -- \`defineChart({ marks, x, y })\` creates a static definition. -- Both positional axes require configured D3 scales. TanStack copies their - domains and owns their responsive pixel ranges. -- \`mountChart(container, { definition, width, height, ariaLabel, animate: - false, keyboard: false })\` returns a host with \`destroy()\`. +- Marks consume application-owned rows; keep D3 transforms beside the + definition. +- \`defineChart({ marks, x, y, animate: false, keyboard: false })\` creates a + static definition. Chart behavior belongs to the definition. +- Each materialized positional dimension requires a D3 scale factory or + configured instance. A factory infers its domain from mark channels; an + instance keeps its authored domain. TanStack owns responsive pixel ranges. +- \`mountChart(container, { definition, width, height, ariaLabel })\` returns a + host with \`destroy()\`. ${ entry.id === 'bar-vertical-sorted' ? `- Use granular \`rollups\` and \`sum\` from \`d3-array\` to aggregate the raw rows. - \`barY(rows, { x, y, key, fill, inset })\` renders vertical bars. -- A \`scaleBand\` domain owns category order. A \`scaleLinear\` domain owns the zero baseline and value extent.` +- Sort the aggregated rows from largest to smallest, then use a \`scaleBand\` + factory to infer that category order. +- Use a configured \`scaleLinear().domain([0, 140])\` instance for the required + fixed y domain.` : `- Use granular \`bin\` from \`d3-array\`. D3 treats a threshold array as interior cuts, so set the first and last boundaries as the bin domain and pass only the interior boundaries to \`thresholds\`. - \`rect(rows, { x1, x2, y1, y2, key, fill, inset })\` renders interval rectangles. -- Use configured \`scaleLinear\` values for both axes.` +- Use configured \`scaleLinear\` instances for the required x domain + \`[20, 90]\` and y domain \`[0, 80]\`.` } Use the public package declarations for exact option types. These notes describe diff --git a/scripts/package-version.mjs b/scripts/package-version.mjs new file mode 100644 index 00000000..91c8c37d --- /dev/null +++ b/scripts/package-version.mjs @@ -0,0 +1,9 @@ +import semver from 'semver' + +export function isExactNpmPackageVersion(value) { + return ( + typeof value === 'string' && + /^\d/u.test(value) && + semver.valid(value) !== null + ) +} diff --git a/scripts/package-version.test.mjs b/scripts/package-version.test.mjs new file mode 100644 index 00000000..3c1b513d --- /dev/null +++ b/scripts/package-version.test.mjs @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isExactNpmPackageVersion } from './package-version.mjs' + +describe('release package versions', () => { + it.each(['0.0.1', '1.2.3-beta.1', '1.2.3-rc.1+build.5'])( + 'accepts exact npm semver %s', + (version) => { + expect(isExactNpmPackageVersion(version)).toBe(true) + }, + ) + + it.each(['latest', ' ', 'v1.2.3', '1.2'])( + 'rejects non-version value %j', + (version) => { + expect(isExactNpmPackageVersion(version)).toBe(false) + }, + ) +}) diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100644 index 00000000..60bfde6d --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { + normalizeRegistryPackageMetadata, + validateReleaseArtifacts, +} from './release-artifacts.mjs' +import { validateTrustedPublishingNpmVersion } from './release-security.mjs' + +const execFileAsync = promisify(execFile) +const repositoryRoot = resolve(import.meta.dirname, '..') +const checkOnly = process.argv.includes('--check') + +assert.deepEqual( + process.argv.slice(2).filter((argument) => argument !== '--check'), + [], + 'Usage: node scripts/publish-release.mjs [--check]', +) + +const { artifacts, manifest, version } = + await validateReleaseArtifacts(repositoryRoot) + +if (checkOnly) { + console.log( + `Release artifact contract passed for ${artifacts.length} packages at ${version}.`, + ) + process.exit(0) +} + +assert.equal( + process.env.GITHUB_ACTIONS, + 'true', + 'Publishing is restricted to GitHub Actions', +) +assert.equal( + process.env.GITHUB_REF_TYPE, + 'tag', + 'Publishing requires a tag event', +) +assert.equal( + process.env.GITHUB_REF_NAME, + manifest.tag, + `Expected release tag ${manifest.tag}`, +) +assert.match( + process.env.GITHUB_SHA ?? '', + /^[0-9a-f]{40}$/, + 'Publishing requires an exact GitHub revision', +) +validateTrustedPublishingNpmVersion((await runNpm(['--version'])).stdout) + +const states = new Map() +for (const artifact of artifacts) { + const registry = await readRegistryPackage(artifact.name, version) + if (registry === null) { + states.set(artifact.name, 'missing') + continue + } + validateRegistryPackage(artifact, registry) + states.set(artifact.name, 'published') +} + +for (const artifact of artifacts) { + if (states.get(artifact.name) === 'published') { + console.log(`Already published: ${artifact.name}@${version}`) + continue + } + + await runNpm([ + 'publish', + artifact.tarball, + '--access', + 'public', + '--tag', + 'latest', + '--provenance', + ]) + const registry = await waitForRegistryPackage(artifact, version) + validateRegistryPackage(artifact, registry) + console.log(`Published: ${artifact.name}@${version}`) +} + +console.log(`Published ${manifest.tag} with verified integrity and provenance.`) + +async function waitForRegistryPackage(artifact, releaseVersion) { + let lastResult = null + for (let attempt = 0; attempt < 60; attempt += 1) { + lastResult = await readRegistryPackage(artifact.name, releaseVersion) + if ( + lastResult?.dist?.integrity === artifact.integrity && + hasAttestations(lastResult) + ) { + return lastResult + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)) + } + assert.fail( + `${artifact.name}@${releaseVersion} registry metadata did not stabilize after 120 seconds ` + + `(integrity: ${lastResult?.dist?.integrity ?? 'missing'}; provenance: ${hasAttestations(lastResult) ? 'present' : 'missing'})`, + ) +} + +async function readRegistryPackage(name, releaseVersion) { + try { + const { stdout } = await runNpm([ + 'view', + `${name}@${releaseVersion}`, + 'name', + 'version', + 'dist.integrity', + 'dist.attestations', + '--json', + ]) + return normalizeRegistryPackageMetadata(JSON.parse(stdout)) + } catch (error) { + if (error?.stderr?.includes('E404')) return null + throw error + } +} + +function validateRegistryPackage(artifact, registry) { + assert.equal( + registry.name, + artifact.name, + `${artifact.name} registry name differs`, + ) + assert.equal( + registry.version, + artifact.manifest.version, + `${artifact.name} registry version differs`, + ) + assert.equal( + registry.dist?.integrity, + artifact.integrity, + `${artifact.name}@${artifact.manifest.version} already exists with different contents`, + ) + assert.ok( + hasAttestations(registry), + `${artifact.name}@${artifact.manifest.version} lacks provenance attestations`, + ) +} + +function hasAttestations(registry) { + return ( + registry?.dist?.attestations !== undefined && + registry.dist.attestations !== null + ) +} + +function runNpm(args) { + return execFileAsync('npm', args, { + cwd: repositoryRoot, + env: { ...process.env }, + maxBuffer: 20 * 1024 * 1024, + }) +} diff --git a/scripts/release-artifacts.mjs b/scripts/release-artifacts.mjs new file mode 100644 index 00000000..7568e6ba --- /dev/null +++ b/scripts/release-artifacts.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import { basename, resolve } from 'node:path' +import { promisify } from 'node:util' +import { + readReleasePackages, + releaseArtifactsDirectoryName, + releaseTag, +} from './release-package-config.mjs' + +const execFileAsync = promisify(execFile) +const packedEntryFields = [ + 'main', + 'module', + 'browser', + 'types', + 'typings', + 'svelte', + 'style', +] + +export async function createReleaseArtifactManifest(repositoryRoot) { + const packages = await readReleasePackages(repositoryRoot) + const artifactDirectory = resolve( + repositoryRoot, + releaseArtifactsDirectoryName, + ) + const entries = [] + + for (const packageInfo of packages) { + const tarball = resolve(artifactDirectory, packageInfo.artifactFilename) + const integrity = await tarballIntegrity(tarball) + const packedManifest = await readPackedManifest(tarball) + const packedFiles = await readPackedFiles(tarball) + validatePackedManifest(packageInfo, packedManifest, packedFiles) + entries.push({ + name: packageInfo.name, + directory: packageInfo.directory, + filename: packageInfo.artifactFilename, + integrity, + }) + } + + const version = packages[0].manifest.version + const manifest = { + schemaVersion: 1, + version, + tag: releaseTag(version), + packages: entries, + } + await writeFile( + resolve(artifactDirectory, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + return { artifactDirectory, manifest, packages } +} + +export async function validateReleaseArtifacts(repositoryRoot) { + const packages = await readReleasePackages(repositoryRoot) + const artifactDirectory = resolve( + repositoryRoot, + releaseArtifactsDirectoryName, + ) + const manifest = JSON.parse( + await readFile(resolve(artifactDirectory, 'manifest.json'), 'utf8'), + ) + const version = packages[0].manifest.version + + assert.equal(manifest.schemaVersion, 1, 'Release artifact schema is stale') + assert.equal(manifest.version, version, 'Release artifact version is stale') + assert.equal( + manifest.tag, + releaseTag(version), + 'Release artifact tag is stale', + ) + assert.equal( + manifest.packages?.length, + packages.length, + 'Release artifact package count is stale', + ) + + const artifacts = [] + for (const [index, packageInfo] of packages.entries()) { + const entry = manifest.packages[index] + assert.deepEqual( + { + name: entry?.name, + directory: entry?.directory, + filename: entry?.filename, + }, + { + name: packageInfo.name, + directory: packageInfo.directory, + filename: packageInfo.artifactFilename, + }, + `Release artifact order is stale at ${packageInfo.name}`, + ) + assert.equal( + basename(entry.filename), + entry.filename, + `${packageInfo.name} artifact must stay inside the artifact directory`, + ) + + const tarball = resolve(artifactDirectory, entry.filename) + const integrity = await tarballIntegrity(tarball) + assert.equal( + entry.integrity, + integrity, + `${packageInfo.name} artifact integrity changed`, + ) + const packedManifest = await readPackedManifest(tarball) + const packedFiles = await readPackedFiles(tarball) + validatePackedManifest(packageInfo, packedManifest, packedFiles) + artifacts.push({ + ...packageInfo, + tarball, + integrity, + packedManifest, + }) + } + + return { artifactDirectory, artifacts, manifest, version } +} + +export function normalizeRegistryPackageMetadata(metadata) { + if (metadata?.dist) return metadata + return { + ...metadata, + dist: { + integrity: metadata?.['dist.integrity'], + attestations: metadata?.['dist.attestations'], + }, + } +} + +async function tarballIntegrity(tarball) { + const contents = await readFile(tarball) + return `sha512-${createHash('sha512').update(contents).digest('base64')}` +} + +async function readPackedManifest(tarball) { + const { stdout } = await execFileAsync( + 'tar', + ['-xzOf', tarball, 'package/package.json'], + { maxBuffer: 5 * 1024 * 1024 }, + ) + return JSON.parse(stdout) +} + +async function readPackedFiles(tarball) { + const { stdout } = await execFileAsync('tar', ['-tzf', tarball], { + maxBuffer: 5 * 1024 * 1024, + }) + return new Set( + stdout + .split('\n') + .filter(Boolean) + .map((file) => file.replace(/^package\//, '')), + ) +} + +function validatePackedManifest(packageInfo, packedManifest, packedFiles) { + const { manifest } = packageInfo + assert.equal( + packedManifest.name, + manifest.name, + `${manifest.name} packed the wrong name`, + ) + assert.equal( + packedManifest.version, + manifest.version, + `${manifest.name} packed the wrong version`, + ) + assert.equal( + packedManifest.private, + false, + `${manifest.name} packed as private`, + ) + assert.deepEqual( + packedManifest.repository, + manifest.repository, + `${manifest.name} packed stale repository metadata`, + ) + assert.deepEqual( + packedManifest.exports, + manifest.publishConfig.exports, + `${manifest.name} packed stale exports`, + ) + assert.equal( + packedManifest.publishConfig?.exports, + undefined, + `${manifest.name} retained source export overrides`, + ) + assert.equal( + packedManifest.publishConfig?.access, + 'public', + `${manifest.name} packed without public access`, + ) + assert.equal( + packedManifest.publishConfig?.provenance, + true, + `${manifest.name} packed without provenance`, + ) + validatePackedEntryFiles(manifest.name, packedManifest, packedFiles) + + for (const range of Object.values(packedManifest.dependencies ?? {})) { + assert.equal( + range.startsWith('workspace:'), + false, + `${manifest.name} retained workspace dependency ${range}`, + ) + } + + if (manifest.name !== '@tanstack/charts') { + assert.equal( + packedManifest.dependencies?.['@tanstack/charts'], + manifest.version, + `${manifest.name} must pin @tanstack/charts@${manifest.version}`, + ) + } +} + +export function validatePackedEntryFiles( + packageName, + packedManifest, + packedFiles, +) { + for (const field of packedEntryFields) { + const target = packedManifest[field] + if (target === undefined) continue + if (field === 'browser' && typeof target === 'object' && target !== null) { + continue + } + assert.equal( + typeof target, + 'string', + `${packageName} packed ${field} entry must identify one file`, + ) + assert.ok( + packedFiles.has(target.replace(/^\.\//, '')), + `${packageName} packed ${field} entry is missing from tarball: ${target}`, + ) + } +} diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs new file mode 100644 index 00000000..a308966f --- /dev/null +++ b/scripts/release-artifacts.test.mjs @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeRegistryPackageMetadata, + validatePackedEntryFiles, +} from './release-artifacts.mjs' + +describe('release registry metadata', () => { + it('normalizes dotted npm view fields', () => { + expect( + normalizeRegistryPackageMetadata({ + name: '@tanstack/charts', + version: '0.0.1', + 'dist.integrity': 'sha512-example', + 'dist.attestations': { url: 'https://registry.example/attestations' }, + }), + ).toMatchObject({ + name: '@tanstack/charts', + version: '0.0.1', + dist: { + integrity: 'sha512-example', + attestations: { url: 'https://registry.example/attestations' }, + }, + }) + }) + + it('preserves nested registry metadata', () => { + const metadata = { + name: '@tanstack/charts', + version: '0.0.1', + dist: { + integrity: 'sha512-example', + attestations: { url: 'https://registry.example/attestations' }, + }, + } + expect(normalizeRegistryPackageMetadata(metadata)).toBe(metadata) + }) +}) + +describe('packed package entry fields', () => { + it('rejects a Svelte entry that is absent from the tarball', () => { + expect(() => + validatePackedEntryFiles( + '@tanstack/svelte-charts', + { svelte: './src/index.ts' }, + new Set(['package.json', 'dist/index.js']), + ), + ).toThrow( + '@tanstack/svelte-charts packed svelte entry is missing from tarball: ./src/index.ts', + ) + }) + + it.each(['main', 'module', 'browser', 'types', 'typings', 'svelte', 'style'])( + 'requires the top-level %s entry to name a packed file', + (field) => { + expect(() => + validatePackedEntryFiles( + 'example-package', + { [field]: './dist/missing.js' }, + new Set(['package.json', 'dist/index.js']), + ), + ).toThrow( + `example-package packed ${field} entry is missing from tarball: ./dist/missing.js`, + ) + }, + ) + + it('accepts top-level entries that are present in the tarball', () => { + expect(() => + validatePackedEntryFiles( + 'example-package', + { + main: './dist/index.js', + types: './dist/index.d.ts', + svelte: './dist/index.js', + }, + new Set(['package.json', 'dist/index.js', 'dist/index.d.ts']), + ), + ).not.toThrow() + }) +}) diff --git a/scripts/release-package-config.mjs b/scripts/release-package-config.mjs new file mode 100644 index 00000000..578694f1 --- /dev/null +++ b/scripts/release-package-config.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +export const releaseArtifactsDirectoryName = '.release-artifacts' +export const releaseRepository = { + type: 'git', + url: 'https://github.com/TanStack/charts.git', +} + +export const releasePackageConfigs = [ + ['charts-core', '@tanstack/charts'], + ['react-charts', '@tanstack/react-charts'], + ['octane-charts', '@tanstack/octane-charts'], + ['preact-charts', '@tanstack/preact-charts'], + ['vue-charts', '@tanstack/vue-charts'], + ['solid-charts', '@tanstack/solid-charts'], + ['svelte-charts', '@tanstack/svelte-charts'], + ['angular-charts', '@tanstack/angular-charts'], + ['lit-charts', '@tanstack/lit-charts'], + ['alpine-charts', '@tanstack/alpine-charts'], +].map(([directory, name]) => ({ directory, name })) + +export async function readReleasePackages(repositoryRoot) { + const packages = [] + + for (const config of releasePackageConfigs) { + const manifestPath = resolve( + repositoryRoot, + 'packages', + config.directory, + 'package.json', + ) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + + assert.equal( + manifest.name, + config.name, + `${manifestPath} has the wrong name`, + ) + assert.match( + manifest.version, + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/, + `${manifest.name} requires a publishable version`, + ) + assert.equal( + manifest.private, + false, + `${manifest.name} must be explicitly publishable`, + ) + assert.equal(manifest.license, 'MIT', `${manifest.name} must include MIT`) + assert.deepEqual( + manifest.repository, + { + ...releaseRepository, + directory: `packages/${config.directory}`, + }, + `${manifest.name} repository metadata is stale`, + ) + assert.equal( + manifest.publishConfig?.access, + 'public', + `${manifest.name} must publish publicly`, + ) + assert.equal( + manifest.publishConfig?.provenance, + true, + `${manifest.name} must publish provenance`, + ) + assert.ok( + manifest.publishConfig?.exports, + `${manifest.name} requires published exports`, + ) + + if (manifest.name === '@tanstack/charts') { + assert.equal( + manifest.dependencies?.['@tanstack/charts'], + undefined, + '@tanstack/charts cannot depend on itself', + ) + } else { + assert.equal( + manifest.dependencies?.['@tanstack/charts'], + 'workspace:*', + `${manifest.name} must depend on @tanstack/charts via workspace:*`, + ) + } + + packages.push({ + ...config, + manifest, + manifestPath, + artifactFilename: `${config.directory}-${manifest.version}.tgz`, + }) + } + + const versions = new Set( + packages.map((packageInfo) => packageInfo.manifest.version), + ) + assert.equal( + versions.size, + 1, + `Release packages must share one version: ${[...versions].join(', ')}`, + ) + + return packages +} + +export function releaseTag(version) { + return `v${version}` +} diff --git a/scripts/release-security.mjs b/scripts/release-security.mjs new file mode 100644 index 00000000..829243bc --- /dev/null +++ b/scripts/release-security.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict' + +export const releaseRepositorySlug = 'TanStack/charts' +export const releaseRepositoryUrl = `https://github.com/${releaseRepositorySlug}` +export const releaseWorkflowPath = '.github/workflows/release.yml' +export const provenancePredicateType = 'https://slsa.dev/provenance/v1' +export const githubWorkflowBuildType = + 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1' + +export function validateReleaseEnvironment({ + env, + expectedTag, + expectedRevision = env.GITHUB_SHA, +}) { + assert.equal(env.GITHUB_ACTIONS, 'true', 'Release requires GitHub Actions') + assert.equal(env.GITHUB_EVENT_NAME, 'push', 'Release requires a push event') + assert.equal(env.GITHUB_REF_TYPE, 'tag', 'Release requires a tag ref') + assert.equal( + env.GITHUB_REF_NAME, + expectedTag, + `Release requires tag ${expectedTag}`, + ) + assert.equal( + env.GITHUB_REF, + `refs/tags/${expectedTag}`, + `Release requires refs/tags/${expectedTag}`, + ) + assert.equal( + env.GITHUB_REPOSITORY, + releaseRepositorySlug, + `Release requires ${releaseRepositorySlug}`, + ) + assert.match( + expectedRevision ?? '', + /^[0-9a-f]{40}$/, + 'Release requires an exact GitHub revision', + ) + assert.equal( + env.GITHUB_SHA, + expectedRevision, + 'Release revision differs from GITHUB_SHA', + ) +} + +export function validateTrustedPublishingNpmVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()) + assert.ok(match, `Invalid npm version: ${version}`) + const parts = match.slice(1).map(Number) + assert.ok( + compareVersions(parts, [11, 5, 1]) >= 0, + `npm ${version.trim()} cannot use trusted publishing; expected 11.5.1 or newer`, + ) +} + +export function expectedPackagePurl(name, version) { + const scoped = /^(@[^/]+)\/([^/]+)$/.exec(name) + const encodedName = scoped + ? `${encodeURIComponent(scoped[1])}/${encodeURIComponent(scoped[2])}` + : encodeURIComponent(name) + return `pkg:npm/${encodedName}@${encodeURIComponent(version)}` +} + +export function integritySha512Hex(integrity) { + const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(integrity) + assert.ok(match, `Expected sha512 integrity, received ${integrity}`) + const digest = Buffer.from(match[1], 'base64') + assert.equal(digest.byteLength, 64, 'sha512 integrity must contain 64 bytes') + assert.equal( + digest.toString('base64'), + match[1], + 'sha512 integrity is not canonical base64', + ) + return digest.toString('hex') +} + +export function decodeProvenanceStatement(attestationDocument) { + assert.ok( + Array.isArray(attestationDocument?.attestations), + 'Attestation response must include attestations', + ) + const matches = attestationDocument.attestations.filter( + (attestation) => attestation?.predicateType === provenancePredicateType, + ) + assert.equal( + matches.length, + 1, + 'Attestation response must include exactly one SLSA provenance bundle', + ) + const envelope = matches[0].bundle?.dsseEnvelope + assert.equal( + envelope?.payloadType, + 'application/vnd.in-toto+json', + 'Provenance bundle has the wrong DSSE payload type', + ) + assert.ok( + Array.isArray(envelope?.signatures) && envelope.signatures.length > 0, + 'Provenance bundle must include a DSSE signature', + ) + assert.match( + envelope?.payload ?? '', + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, + 'Provenance payload must be canonical base64', + ) + return JSON.parse(Buffer.from(envelope.payload, 'base64').toString('utf8')) +} + +export function validatePackageProvenance({ + artifact, + attestationDocument, + revision, + tag, +}) { + const statement = decodeProvenanceStatement(attestationDocument) + const expectedRef = `refs/tags/${tag}` + const expectedPurl = expectedPackagePurl( + artifact.name, + artifact.manifest.version, + ) + const expectedSha512 = integritySha512Hex(artifact.integrity) + + assert.equal( + statement?._type, + 'https://in-toto.io/Statement/v1', + `${artifact.name} provenance has the wrong statement type`, + ) + assert.equal( + statement?.predicateType, + provenancePredicateType, + `${artifact.name} provenance has the wrong predicate type`, + ) + assert.equal( + statement?.subject?.length, + 1, + `${artifact.name} provenance must have one subject`, + ) + assert.equal( + statement.subject[0]?.name, + expectedPurl, + `${artifact.name} provenance has the wrong package PURL`, + ) + assert.equal( + statement.subject[0]?.digest?.sha512, + expectedSha512, + `${artifact.name} provenance has the wrong tarball digest`, + ) + + const buildDefinition = statement.predicate?.buildDefinition + assert.equal( + buildDefinition?.buildType, + githubWorkflowBuildType, + `${artifact.name} provenance has the wrong build type`, + ) + assert.deepEqual( + buildDefinition?.externalParameters?.workflow, + { + ref: expectedRef, + repository: releaseRepositoryUrl, + path: releaseWorkflowPath, + }, + `${artifact.name} provenance has the wrong workflow identity`, + ) + + const expectedDependency = `git+${releaseRepositoryUrl}@${expectedRef}` + const resolvedDependencies = buildDefinition?.resolvedDependencies + assert.ok( + Array.isArray(resolvedDependencies), + `${artifact.name} provenance lacks resolved dependencies`, + ) + const sourceDependencies = resolvedDependencies.filter( + (dependency) => dependency?.uri === expectedDependency, + ) + assert.equal( + sourceDependencies.length, + 1, + `${artifact.name} provenance must resolve the tagged repository once`, + ) + assert.equal( + sourceDependencies[0]?.digest?.gitCommit, + revision, + `${artifact.name} provenance resolved the wrong Git commit`, + ) + return statement +} + +export function verifiedAttestationBundles(artifacts, audit) { + assert.deepEqual( + audit?.invalid, + [], + 'npm found invalid registry signatures or attestations', + ) + assert.deepEqual(audit?.missing, [], 'npm found missing registry signatures') + assert.ok( + Array.isArray(audit?.verified), + 'npm signature audit omitted verified packages', + ) + + const bundles = new Map() + for (const artifact of artifacts) { + const matches = audit.verified.filter( + (entry) => + entry?.name === artifact.name && + entry?.version === artifact.manifest.version, + ) + assert.equal( + matches.length, + 1, + `${artifact.name}@${artifact.manifest.version} must have one verified npm attestation`, + ) + assert.ok( + Array.isArray(matches[0].attestationBundles), + `${artifact.name}@${artifact.manifest.version} lacks verified attestation bundles`, + ) + decodeProvenanceStatement({ + attestations: matches[0].attestationBundles, + }) + bundles.set(artifact.name, matches[0].attestationBundles) + } + return bundles +} + +function compareVersions(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index] + } + return 0 +} diff --git a/scripts/release-security.test.mjs b/scripts/release-security.test.mjs new file mode 100644 index 00000000..4c6b4199 --- /dev/null +++ b/scripts/release-security.test.mjs @@ -0,0 +1,261 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' +import { + decodeProvenanceStatement, + expectedPackagePurl, + githubWorkflowBuildType, + integritySha512Hex, + provenancePredicateType, + releaseRepositoryUrl, + releaseWorkflowPath, + validatePackageProvenance, + validateReleaseEnvironment, + validateTrustedPublishingNpmVersion, + verifiedAttestationBundles, +} from './release-security.mjs' +import { + parseRemoteRefs, + validateReleaseRevisionEvidence, +} from './verify-release-revision.mjs' + +const revision = 'a'.repeat(40) +const tag = 'v0.0.1' +const integrity = `sha512-${Buffer.alloc(64, 0xab).toString('base64')}` +const artifact = { + name: '@tanstack/charts', + integrity, + manifest: { version: '0.0.1' }, +} + +describe('release environment', () => { + it('requires the exact repository, tag ref, and revision', () => { + expect(() => + validateReleaseEnvironment({ + env: releaseEnvironment(), + expectedTag: tag, + expectedRevision: revision, + }), + ).not.toThrow() + expect(() => + validateReleaseEnvironment({ + env: { + ...releaseEnvironment(), + GITHUB_REF: 'refs/heads/main', + }, + expectedTag: tag, + expectedRevision: revision, + }), + ).toThrow(/refs\/tags\/v0\.0\.1/) + }) + + it('requires an npm version with trusted publishing support', () => { + expect(() => validateTrustedPublishingNpmVersion('11.5.1\n')).not.toThrow() + expect(() => validateTrustedPublishingNpmVersion('11.18.0')).not.toThrow() + expect(() => validateTrustedPublishingNpmVersion('11.5.0')).toThrow( + /11\.5\.1 or newer/, + ) + }) +}) + +describe('release ref evidence', () => { + it('requires an annotated remote tag peeled to the checked-out main commit', () => { + const refs = parseRemoteRefs( + `${'b'.repeat(40)}\trefs/tags/${tag}\n${revision}\trefs/tags/${tag}^{}\n`, + ) + expect(() => + validateReleaseRevisionEvidence({ + headRevision: revision, + mainRevision: 'c'.repeat(40), + remoteRefs: refs, + revision, + tag, + isMainAncestor: true, + }), + ).not.toThrow() + refs.delete(`refs/tags/${tag}^{}`) + expect(() => + validateReleaseRevisionEvidence({ + headRevision: revision, + mainRevision: 'c'.repeat(40), + remoteRefs: refs, + revision, + tag, + isMainAncestor: true, + }), + ).toThrow(/must be annotated/) + }) +}) + +describe('npm provenance', () => { + it('derives scoped PURLs and sha512 digests exactly', () => { + expect(expectedPackagePurl('@tanstack/charts', '0.0.1')).toBe( + 'pkg:npm/%40tanstack/charts@0.0.1', + ) + expect(integritySha512Hex(integrity)).toBe('ab'.repeat(64)) + }) + + it('decodes and validates the exact workflow and tagged source revision', () => { + const statement = provenanceStatement() + const document = attestationDocument(statement) + expect(decodeProvenanceStatement(document)).toEqual(statement) + expect( + validatePackageProvenance({ + artifact, + attestationDocument: document, + revision, + tag, + }), + ).toEqual(statement) + }) + + it('rejects a valid-looking provenance statement for another source revision', () => { + const document = attestationDocument( + provenanceStatement({ sourceRevision: 'f'.repeat(40) }), + ) + expect(() => + validatePackageProvenance({ + artifact, + attestationDocument: document, + revision, + tag, + }), + ).toThrow(/resolved the wrong Git commit/) + }) + + it.each([ + [ + 'package PURL', + (statement) => { + statement.subject[0].name = 'pkg:npm/%40tanstack/react-charts@0.0.1' + }, + /wrong package PURL/, + ], + [ + 'tarball digest', + (statement) => { + statement.subject[0].digest.sha512 = 'ff'.repeat(64) + }, + /wrong tarball digest/, + ], + [ + 'repository', + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.repository = + 'https://github.com/example/charts' + }, + /wrong workflow identity/, + ], + [ + 'workflow path', + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.path = + '.github/workflows/other.yml' + }, + /wrong workflow identity/, + ], + [ + 'tag ref', + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.ref = + 'refs/heads/main' + }, + /wrong workflow identity/, + ], + ])('rejects provenance with the wrong %s', (_, mutate, error) => { + const statement = provenanceStatement() + mutate(statement) + expect(() => + validatePackageProvenance({ + artifact, + attestationDocument: attestationDocument(statement), + revision, + tag, + }), + ).toThrow(error) + }) + + it('requires npm to cryptographically verify every release package attestation', () => { + const bundles = attestationDocument(provenanceStatement()).attestations + expect( + verifiedAttestationBundles([artifact], { + invalid: [], + missing: [], + verified: [ + { + name: artifact.name, + version: artifact.manifest.version, + attestationBundles: bundles, + }, + ], + }).get(artifact.name), + ).toEqual(bundles) + expect(() => + verifiedAttestationBundles([artifact], { + invalid: [], + missing: [], + verified: [], + }), + ).toThrow(/must have one verified npm attestation/) + }) +}) + +function releaseEnvironment() { + return { + GITHUB_ACTIONS: 'true', + GITHUB_EVENT_NAME: 'push', + GITHUB_REF_TYPE: 'tag', + GITHUB_REF_NAME: tag, + GITHUB_REF: `refs/tags/${tag}`, + GITHUB_REPOSITORY: 'TanStack/charts', + GITHUB_SHA: revision, + } +} + +function provenanceStatement({ sourceRevision = revision } = {}) { + const ref = `refs/tags/${tag}` + return { + _type: 'https://in-toto.io/Statement/v1', + subject: [ + { + name: 'pkg:npm/%40tanstack/charts@0.0.1', + digest: { sha512: 'ab'.repeat(64) }, + }, + ], + predicateType: provenancePredicateType, + predicate: { + buildDefinition: { + buildType: githubWorkflowBuildType, + externalParameters: { + workflow: { + ref, + repository: releaseRepositoryUrl, + path: releaseWorkflowPath, + }, + }, + resolvedDependencies: [ + { + uri: `git+${releaseRepositoryUrl}@${ref}`, + digest: { gitCommit: sourceRevision }, + }, + ], + }, + }, + } +} + +function attestationDocument(statement) { + return { + attestations: [ + { + predicateType: provenancePredicateType, + bundle: { + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(statement)).toString('base64'), + payloadType: 'application/vnd.in-toto+json', + signatures: [{ sig: 'example' }], + }, + }, + }, + ], + } +} diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs new file mode 100644 index 00000000..0e29842d --- /dev/null +++ b/scripts/release-workflow.test.mjs @@ -0,0 +1,58 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const workflow = await readFile( + resolve(import.meta.dirname, '../.github/workflows/release.yml'), + 'utf8', +) + +describe('release workflow security boundary', () => { + it('keeps validation and post-publish verification outside OIDC', () => { + expect(job('validate')).not.toContain('id-token: write') + expect(job('verify')).not.toContain('id-token: write') + expect(job('release')).not.toContain('id-token: write') + expect(job('publish')).toContain('id-token: write') + expect(workflow.match(/id-token: write/g)).toHaveLength(1) + }) + + it('publishes only checked artifacts from an exact-SHA checkout', () => { + const publish = job('publish') + expect(publish).toContain('needs: validate') + expect(publish).toContain('ref: ${{ github.sha }}') + expect(publish).toContain('actions/download-artifact@') + expect(publish).toContain('path: .release-artifacts') + expect(publish).not.toMatch(/\b(?:npm|pnpm) install\b/) + expect(publish).not.toContain('corepack enable') + expect(publish).not.toMatch(/\bpnpm (?:test|typecheck|docs:check)\b/) + expect(publish).toMatch( + /node scripts\/verify-release-revision\.mjs\s+node scripts\/publish-release\.mjs/, + ) + }) + + it('gates the GitHub release on independent package verification and fresh refs', () => { + expect(job('verify')).toMatch( + /needs: publish[\s\S]*node scripts\/verify-published-release\.mjs/, + ) + const release = job('release') + expect(release).toContain('needs: verify') + expect(release).toContain('ref: ${{ github.sha }}') + expect(release).toMatch( + /node scripts\/verify-release-revision\.mjs[\s\S]*gh release create/, + ) + }) +}) + +function job(name) { + const lines = workflow.split('\n') + const start = lines.findIndex((line) => line === ` ${name}:`) + expect(start, `job ${name}`).toBeGreaterThan(-1) + let end = lines.length + for (let index = start + 1; index < lines.length; index += 1) { + if (/^ [a-z][a-z0-9_-]*:$/.test(lines[index])) { + end = index + break + } + } + return lines.slice(start, end).join('\n') +} diff --git a/scripts/sync-package-docs.mjs b/scripts/sync-package-docs.mjs index af181519..f19a0c13 100644 --- a/scripts/sync-package-docs.mjs +++ b/scripts/sync-package-docs.mjs @@ -79,10 +79,10 @@ export async function createLlmsIndex(root) { 'Authoring rules:', '', '- Use direct, granular d3-* imports for scales and analytical preparation; never import the d3 umbrella.', - '- Let TanStack Charts own responsive pixel ranges while configured D3 scales own domains, ticks, and formatting.', + '- Let TanStack Charts own responsive pixel ranges. D3 factories infer domains from mark channels; configured instances preserve application-owned domains.', '- Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model.', '- Memoize the complete definition against captured application values; definition identity is the application update boundary.', - '- Use stable datum keys for updates, animation, and selection.', + '- Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable.', '- Prefer built-in marks, then composition, then a custom mark or application-owned overlay.', '- Treat docs/concepts/scales-and-d3.md as the sole D3 integration contract and follow its official D3 links for D3 API details.', '- Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript.', diff --git a/scripts/verify-published-release.mjs b/scripts/verify-published-release.mjs new file mode 100644 index 00000000..703c3f3d --- /dev/null +++ b/scripts/verify-published-release.mjs @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { + normalizeRegistryPackageMetadata, + validateReleaseArtifacts, +} from './release-artifacts.mjs' +import { + validatePackageProvenance, + validateReleaseEnvironment, + validateTrustedPublishingNpmVersion, + verifiedAttestationBundles, +} from './release-security.mjs' + +const execFileAsync = promisify(execFile) +const repositoryRoot = resolve(import.meta.dirname, '..') +const registry = 'https://registry.npmjs.org' +const { artifacts, manifest, version } = + await validateReleaseArtifacts(repositoryRoot) + +validateReleaseEnvironment({ + env: process.env, + expectedTag: manifest.tag, + expectedRevision: process.env.GITHUB_SHA, +}) +validateTrustedPublishingNpmVersion( + (await runNpm(['--version'], repositoryRoot)).stdout, +) + +const installDirectory = await mkdtemp( + resolve(tmpdir(), 'tanstack-charts-release-verification-'), +) +try { + await writeFile( + resolve(installDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'tanstack-charts-release-verification', + version: '0.0.0', + private: true, + dependencies: Object.fromEntries( + artifacts.map((artifact) => [artifact.name, version]), + ), + }, + null, + 2, + )}\n`, + ) + await runNpm( + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--save-exact', + `--registry=${registry}`, + ], + installDirectory, + true, + ) + const signatureAudit = JSON.parse( + ( + await runNpm( + [ + 'audit', + 'signatures', + '--json', + '--include-attestations', + `--registry=${registry}`, + ], + installDirectory, + ) + ).stdout, + ) + const auditedBundles = verifiedAttestationBundles(artifacts, signatureAudit) + console.log( + `npm verified registry signatures and provenance for ${auditedBundles.size} release packages.`, + ) + + const lockfile = JSON.parse( + await readFile(resolve(installDirectory, 'package-lock.json'), 'utf8'), + ) + for (const artifact of artifacts) { + await verifyInstalledPackage(artifact, installDirectory, lockfile) + const metadata = await waitForRegistryMetadata(artifact) + const attestationDocument = await waitForAttestations( + metadata.dist.attestations.url, + ) + assert.deepEqual( + attestationDocument.attestations, + auditedBundles.get(artifact.name), + `${artifact.name} fetched attestations differ from npm's verified bundles`, + ) + validatePackageProvenance({ + artifact, + attestationDocument, + revision: process.env.GITHUB_SHA, + tag: manifest.tag, + }) + console.log(`Verified published provenance: ${artifact.name}@${version}`) + } +} finally { + await rm(installDirectory, { recursive: true, force: true }) +} + +console.log( + `Verified ${artifacts.length} installed packages, registry signatures, and provenance bundles for ${manifest.tag}.`, +) + +async function verifyInstalledPackage(artifact, directory, lockfile) { + const packagePath = resolve( + directory, + 'node_modules', + ...artifact.name.split('/'), + 'package.json', + ) + const installedManifest = JSON.parse(await readFile(packagePath, 'utf8')) + assert.equal( + installedManifest.name, + artifact.name, + `${artifact.name} installed with the wrong name`, + ) + assert.equal( + installedManifest.version, + artifact.manifest.version, + `${artifact.name} installed at the wrong version`, + ) + assert.deepEqual( + installedManifest.repository, + artifact.packedManifest.repository, + `${artifact.name} installed with stale repository metadata`, + ) + if (artifact.name !== '@tanstack/charts') { + assert.equal( + installedManifest.dependencies?.['@tanstack/charts'], + artifact.manifest.version, + `${artifact.name} installed with the wrong core dependency`, + ) + } + + const lockEntry = lockfile.packages?.[`node_modules/${artifact.name}`] + assert.equal( + lockEntry?.version, + artifact.manifest.version, + `${artifact.name} lock entry has the wrong version`, + ) + assert.equal( + lockEntry?.integrity, + artifact.integrity, + `${artifact.name} installed tarball differs from the checked artifact`, + ) +} + +async function waitForRegistryMetadata(artifact) { + let lastMetadata = null + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const { stdout } = await runNpm( + [ + 'view', + `${artifact.name}@${artifact.manifest.version}`, + 'name', + 'version', + 'dist.integrity', + 'dist.attestations', + '--json', + `--registry=${registry}`, + ], + repositoryRoot, + ) + lastMetadata = normalizeRegistryPackageMetadata(JSON.parse(stdout)) + if ( + lastMetadata.name === artifact.name && + lastMetadata.version === artifact.manifest.version && + lastMetadata.dist?.integrity === artifact.integrity && + typeof lastMetadata.dist?.attestations?.url === 'string' + ) { + assertAttestationUrl(lastMetadata.dist.attestations.url) + return lastMetadata + } + } catch (error) { + if (!error?.stderr?.includes('E404')) throw error + } + await delay() + } + assert.fail( + `${artifact.name}@${artifact.manifest.version} registry metadata did not stabilize: ${JSON.stringify(lastMetadata)}`, + ) +} + +async function waitForAttestations(url) { + assertAttestationUrl(url) + let lastStatus = null + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await fetch(url, { + headers: { accept: 'application/json' }, + redirect: 'error', + }) + lastStatus = response.status + if (response.ok) return response.json() + await response.body?.cancel() + if (response.status !== 404) { + assert.fail(`Attestation endpoint returned HTTP ${response.status}`) + } + await delay() + } + assert.fail(`Attestation endpoint did not stabilize; last HTTP ${lastStatus}`) +} + +function assertAttestationUrl(value) { + const url = new URL(value) + assert.equal( + url.origin, + registry, + 'Attestation URL must use the npm registry', + ) + assert.ok( + url.pathname.startsWith('/-/npm/v1/attestations/'), + 'Attestation URL has an unexpected path', + ) + assert.equal(url.search, '', 'Attestation URL must not include a query') + assert.equal(url.hash, '', 'Attestation URL must not include a fragment') +} + +function delay() { + return new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)) +} + +async function runNpm(args, cwd, logOutput = false) { + const result = await execFileAsync('npm', args, { + cwd, + env: { ...process.env }, + maxBuffer: 50 * 1024 * 1024, + }) + if (logOutput && result.stdout) process.stdout.write(result.stdout) + if (logOutput && result.stderr) process.stderr.write(result.stderr) + return result +} diff --git a/scripts/verify-release-revision.mjs b/scripts/verify-release-revision.mjs new file mode 100644 index 00000000..5f144489 --- /dev/null +++ b/scripts/verify-release-revision.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { readReleasePackages, releaseTag } from './release-package-config.mjs' +import { + releaseRepositoryUrl, + validateReleaseEnvironment, +} from './release-security.mjs' + +const execFileAsync = promisify(execFile) + +export function parseRemoteRefs(output) { + const refs = new Map() + for (const line of output.trim().split('\n')) { + if (!line) continue + const match = /^([0-9a-f]{40})\t(.+)$/.exec(line) + assert.ok(match, `Invalid git ls-remote line: ${line}`) + assert.equal(refs.has(match[2]), false, `Duplicate remote ref ${match[2]}`) + refs.set(match[2], match[1]) + } + return refs +} + +export function validateReleaseRevisionEvidence({ + headRevision, + mainRevision, + remoteRefs, + revision, + tag, + isMainAncestor, +}) { + const tagRef = `refs/tags/${tag}` + assert.equal( + headRevision, + revision, + `Checked-out revision ${headRevision} differs from ${revision}`, + ) + assert.match(mainRevision, /^[0-9a-f]{40}$/, 'Remote main is not a commit') + assert.ok(remoteRefs.has(tagRef), `Remote tag ${tagRef} does not exist`) + assert.ok( + remoteRefs.has(`${tagRef}^{}`), + `Remote tag ${tagRef} must be annotated`, + ) + assert.equal( + remoteRefs.get(`${tagRef}^{}`), + revision, + `Remote tag ${tagRef} peels to the wrong commit`, + ) + assert.equal( + isMainAncestor, + true, + `Release revision ${revision} is not on remote main ${mainRevision}`, + ) +} + +export async function verifyReleaseRevision({ + env = process.env, + repositoryRoot = resolve(import.meta.dirname, '..'), +} = {}) { + const packages = await readReleasePackages(repositoryRoot) + const expectedTag = releaseTag(packages[0].manifest.version) + const revision = env.GITHUB_SHA + validateReleaseEnvironment({ env, expectedTag, expectedRevision: revision }) + + const remoteUrl = ( + await runGit(repositoryRoot, ['remote', 'get-url', 'origin']) + ).trim() + assert.ok( + remoteUrl === releaseRepositoryUrl || + remoteUrl === `${releaseRepositoryUrl}.git`, + `origin is ${remoteUrl}, expected ${releaseRepositoryUrl}`, + ) + + await runGit(repositoryRoot, [ + 'fetch', + '--no-tags', + 'origin', + 'refs/heads/main', + ]) + const mainRevision = ( + await runGit(repositoryRoot, ['rev-parse', 'FETCH_HEAD^{commit}']) + ).trim() + const tagRef = `refs/tags/${expectedTag}` + const remoteRefs = parseRemoteRefs( + await runGit(repositoryRoot, [ + 'ls-remote', + '--exit-code', + 'origin', + tagRef, + `${tagRef}^{}`, + ]), + ) + const headRevision = ( + await runGit(repositoryRoot, ['rev-parse', 'HEAD^{commit}']) + ).trim() + const isMainAncestor = await gitSucceeds(repositoryRoot, [ + 'merge-base', + '--is-ancestor', + revision, + mainRevision, + ]) + + validateReleaseRevisionEvidence({ + headRevision, + mainRevision, + remoteRefs, + revision, + tag: expectedTag, + isMainAncestor, + }) + console.log( + `Verified ${tagRef} peels to ${revision} on remote main ${mainRevision}.`, + ) +} + +async function runGit(repositoryRoot, args) { + const { stdout } = await execFileAsync('git', args, { + cwd: repositoryRoot, + env: { ...process.env }, + maxBuffer: 5 * 1024 * 1024, + }) + return stdout +} + +async function gitSucceeds(repositoryRoot, args) { + try { + await runGit(repositoryRoot, args) + return true + } catch (error) { + if (error?.code === 1) return false + throw error + } +} + +const entrypoint = process.argv[1] +if (entrypoint && import.meta.url === pathToFileURL(resolve(entrypoint)).href) { + await verifyReleaseRevision() +}