From 268c283cfa637710953c0a917929adf7f9ffdd2d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 30 Jul 2026 22:54:19 -0600 Subject: [PATCH 1/5] Extract tooltip and compact scale extensions --- API-FRICTION.md | 89 +- CHANGELOG.md | 46 + PLAN.md | 53 +- README.md | 3 +- benchmarks/bundle-size/README.md | 17 +- .../bundle-size/universal-baseline.json | 40 +- .../comparison/libraries/tanstack/base.ts | 6 +- .../cases/34-pointer-tooltip/tanstack.ts | 2 + .../cases/35-grouped-tooltip/tanstack.ts | 5 +- .../65-voronoi-nearest-tooltip/tanstack.ts | 2 + .../85-scrollable-resource-lanes/tanstack.ts | 2 + .../tanstack.ts | 2 + benchmarks/conformance/shared/mount.ts | 8 +- .../entries/charts-compact-linear-scene.ts | 13 + ...harts-react-compact-line-tooltip-portal.ts | 16 + .../charts-react-compact-line-tooltip.ts | 12 + .../entries/charts-react-compact-line.ts | 10 + .../entries/charts-react-stats-parity.tsx | 5 +- .../entries/charts-scale-band-kernel.ts | 16 + .../entries/charts-scale-family-kernel.ts | 4 + .../entries/charts-scale-linear-kernel.ts | 13 + .../entries/charts-scale-ordinal-kernel.ts | 14 + .../entries/charts-scale-point-kernel.ts | 15 + benchmarks/entries/charts-stats-parity.ts | 5 +- benchmarks/entries/charts-tooltip-kernel.ts | 1 + .../entries/charts-tooltip-portal-kernel.ts | 1 + docs/concepts/chart-definitions.md | 8 +- docs/concepts/scales-and-d3.md | 37 +- docs/examples/interactive-charts.md | 7 +- docs/framework/angular/adapter.md | 3 +- docs/framework/lit/adapter.md | 3 +- docs/framework/octane/quick-start.md | 5 +- docs/framework/preact/adapter.md | 3 +- docs/framework/react/adapter.md | 33 +- docs/framework/react/quick-start.md | 31 +- docs/framework/react/reference/chart.md | 32 +- docs/framework/solid/adapter.md | 3 +- docs/framework/svelte/adapter.md | 3 +- docs/framework/vue/adapter.md | 3 +- docs/guides/bundle-size-and-performance.md | 22 + docs/guides/themes-and-styling.md | 19 +- docs/guides/tooltips-and-focus.md | 161 +-- docs/installation.md | 17 + docs/overview.md | 4 +- docs/quick-start.md | 3 +- docs/reference/chart-definitions.md | 6 +- docs/reference/dom-host.md | 15 +- docs/reference/focus-and-interaction.md | 117 +- docs/reference/rendering-and-export.md | 3 +- docs/reference/scales-guides-and-color.md | 31 + docs/reference/types.md | 62 +- examples/charts-octane/src/App.tsrx | 4 +- examples/charts-react/src/App.tsx | 10 +- examples/sandbox/src/App.tsx | 7 + llms.txt | 4 +- package.json | 1 + packages/alpine-charts/src/index.test.ts | 2 + packages/angular-charts/README.md | 3 +- packages/angular-charts/src/Chart.test.ts | 5 +- packages/charts-core/README.md | 3 +- .../docs/concepts/chart-definitions.md | 8 +- .../docs/concepts/scales-and-d3.md | 37 +- .../docs/examples/interactive-charts.md | 7 +- .../docs/framework/angular/adapter.md | 3 +- .../charts-core/docs/framework/lit/adapter.md | 3 +- .../docs/framework/octane/quick-start.md | 5 +- .../docs/framework/preact/adapter.md | 3 +- .../docs/framework/react/adapter.md | 33 +- .../docs/framework/react/quick-start.md | 31 +- .../docs/framework/react/reference/chart.md | 32 +- .../docs/framework/solid/adapter.md | 3 +- .../docs/framework/svelte/adapter.md | 3 +- .../charts-core/docs/framework/vue/adapter.md | 3 +- .../guides/bundle-size-and-performance.md | 22 + .../docs/guides/themes-and-styling.md | 19 +- .../docs/guides/tooltips-and-focus.md | 161 +-- packages/charts-core/docs/installation.md | 17 + packages/charts-core/docs/overview.md | 4 +- packages/charts-core/docs/quick-start.md | 3 +- .../docs/reference/chart-definitions.md | 6 +- .../charts-core/docs/reference/dom-host.md | 15 +- .../docs/reference/focus-and-interaction.md | 117 +- .../docs/reference/rendering-and-export.md | 3 +- .../docs/reference/scales-guides-and-color.md | 31 + packages/charts-core/docs/reference/types.md | 62 +- packages/charts-core/llms.txt | 4 +- packages/charts-core/package.json | 14 +- packages/charts-core/src/canvas.test.ts | 3 +- packages/charts-core/src/color-scale.test.ts | 15 + packages/charts-core/src/exports.test.ts | 13 + packages/charts-core/src/index.ts | 12 + packages/charts-core/src/mark.ts | 6 +- packages/charts-core/src/renderer.test.ts | 23 +- packages/charts-core/src/renderer.ts | 1048 ++--------------- packages/charts-core/src/runtime.test.ts | 36 +- packages/charts-core/src/scales.ts | 39 +- packages/charts-core/src/tooltip-portal.ts | 221 ++++ packages/charts-core/src/tooltip-position.ts | 155 +++ packages/charts-core/src/tooltip.ts | 768 ++++++++++++ .../charts-core/src/type-contract.test.ts | 17 +- packages/charts-core/src/types.ts | 102 +- packages/charts-scales/LICENSE | 21 + packages/charts-scales/README.md | 13 + packages/charts-scales/package.json | 54 + packages/charts-scales/src/band-kernel.ts | 182 +++ packages/charts-scales/src/band.ts | 21 + .../charts-scales/src/categorical.test.ts | 138 +++ packages/charts-scales/src/exports.test.ts | 17 + packages/charts-scales/src/intern.ts | 21 + packages/charts-scales/src/linear.test.ts | 69 ++ packages/charts-scales/src/linear.ts | 131 +++ packages/charts-scales/src/ordinal.ts | 85 ++ packages/charts-scales/src/point.ts | 21 + packages/lit-charts/README.md | 3 +- packages/lit-charts/src/Chart.test.ts | 5 +- packages/octane-charts/README.md | 3 +- .../src/CanvasChart.client.test.tsrx | 3 +- .../octane-charts/src/Chart.client.test.tsrx | 5 +- packages/preact-charts/README.md | 3 +- packages/preact-charts/src/Chart.test.tsx | 5 +- packages/react-charts/README.md | 27 +- packages/react-charts/package.json | 7 +- .../react-charts/src/CanvasChart.test.tsx | 3 +- packages/react-charts/src/CanvasChart.tsx | 24 +- packages/react-charts/src/Chart.test.tsx | 10 +- packages/react-charts/src/Chart.tsx | 31 +- packages/react-charts/src/RendererChart.tsx | 150 +-- packages/react-charts/src/canvas.ts | 2 +- packages/react-charts/src/core.ts | 2 +- packages/react-charts/src/index.ts | 2 +- packages/react-charts/src/tooltip.tsx | 252 ++++ packages/solid-charts/README.md | 3 +- packages/solid-charts/src/Chart.test.tsx | 5 +- packages/svelte-charts/README.md | 3 +- packages/svelte-charts/tests/Chart.test.ts | 5 +- packages/vue-charts/README.md | 3 +- packages/vue-charts/src/Chart.test.ts | 5 +- pnpm-lock.yaml | 25 +- scripts/check-canvas-renderer.mjs | 5 +- scripts/check-packed-consumers.mjs | 253 +++- scripts/docs-contract.mjs | 3 + scripts/measure-bundles.mjs | 404 ++++++- scripts/release-artifacts.mjs | 5 +- scripts/release-package-config.mjs | 8 +- scripts/sync-package-docs.mjs | 4 +- 145 files changed, 4535 insertions(+), 1683 deletions(-) create mode 100644 benchmarks/entries/charts-compact-linear-scene.ts create mode 100644 benchmarks/entries/charts-react-compact-line-tooltip-portal.ts create mode 100644 benchmarks/entries/charts-react-compact-line-tooltip.ts create mode 100644 benchmarks/entries/charts-react-compact-line.ts create mode 100644 benchmarks/entries/charts-scale-band-kernel.ts create mode 100644 benchmarks/entries/charts-scale-family-kernel.ts create mode 100644 benchmarks/entries/charts-scale-linear-kernel.ts create mode 100644 benchmarks/entries/charts-scale-ordinal-kernel.ts create mode 100644 benchmarks/entries/charts-scale-point-kernel.ts create mode 100644 benchmarks/entries/charts-tooltip-kernel.ts create mode 100644 benchmarks/entries/charts-tooltip-portal-kernel.ts create mode 100644 packages/charts-core/src/tooltip-portal.ts create mode 100644 packages/charts-core/src/tooltip-position.ts create mode 100644 packages/charts-core/src/tooltip.ts create mode 100644 packages/charts-scales/LICENSE create mode 100644 packages/charts-scales/README.md create mode 100644 packages/charts-scales/package.json create mode 100644 packages/charts-scales/src/band-kernel.ts create mode 100644 packages/charts-scales/src/band.ts create mode 100644 packages/charts-scales/src/categorical.test.ts create mode 100644 packages/charts-scales/src/exports.test.ts create mode 100644 packages/charts-scales/src/intern.ts create mode 100644 packages/charts-scales/src/linear.test.ts create mode 100644 packages/charts-scales/src/linear.ts create mode 100644 packages/charts-scales/src/ordinal.ts create mode 100644 packages/charts-scales/src/point.ts create mode 100644 packages/react-charts/src/tooltip.tsx diff --git a/API-FRICTION.md b/API-FRICTION.md index 70e42d5..5672329 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -176,6 +176,7 @@ Each entry records: | F-138 | Publisher pin predated explicit trust permissions | Tooling/Release | resolved | | F-139 | Top-level entries bypassed tarball validation | Tooling/Release | resolved | | F-140 | Behavior config could erase responsive datum inference | API | monitoring | +| F-141 | Optional tooltip code burdened every chart consumer | API | resolved | ## Findings @@ -472,7 +473,8 @@ Each entry records: - Status: resolved - Severity: high -- Observed in: strict migration of fixtures, sandbox, and Stats +- Observed in: strict migration of fixtures, sandbox, and Stats, followed by + the 15 KiB representative React bundle target - Friction: `scaleUtc`, `scaleTime`, `scaleLog`, `scaleSymlog`, `scaleSqrt`, `configuredScale`, `ChartScaleTransform`, and inferred scale types remain exported beside native `d3-scale` values. Their names are easier for both @@ -480,13 +482,18 @@ Each entry records: longer consumes inferred axis options. - Decision: remove the legacy inferred scale and transform surface after its historical tests and bundle fixtures have been relabeled or deleted. Keep - D3 imports visibly sourced from `d3-scale`. + D3 imports visibly sourced from `d3-scale` for its complete semantics. Offer + the deliberately smaller linear, band, point, and ordinal subset only from + exact `@tanstack/charts-scales/*` entries, with no root export. The compact + package is a constrained bundle option, not a second general-purpose scale + API. - Verification: the obsolete scale, radius, color, curve, transform, and spatial wrappers and subpaths are gone; the inferred-scale builder and its tests are deleted; fixtures and histogram benchmarks use direct `d3.bin`; - repository search finds only direct D3 imports in product definitions. - TypeScript, the standard test suite, both four-test Octane matrices, and - every bundle budget pass. + differential tests cover the compact subset against D3; packed-consumer + checks resolve every exact entry. The complete compact family is 1,877 gzip + bytes, and a representative compact React line is 14,225 gzip bytes without + retaining `d3-scale`, `d3-format`, or `d3-interpolate`. ### F-016 — Stats animated export still renders through Plot @@ -1063,7 +1070,10 @@ Each entry records: consumers are byte-locked. Optional features have isolated gzip budgets; comparison and exploratory kernels remain measurement-only. Baseline changes require an explicit reviewed command, including decreases, so saved - bytes cannot become silent future headroom. + bytes cannot become silent future headroom. Input-boundary checks use only + modules with retained output bytes, so an imported-but-eliminated module + cannot create a false pass or failure. The compact React line has a hard + 15 KiB ceiling; tooltip and portal are measured as separate increments. - Verification: repeated builds match the exact minified and gzip baseline. `pnpm bundle:check` passes the locked consumers and the tightened histogram, facet, curve, time, transform, spatial, arrow, frame, link, and tick feature @@ -1074,8 +1084,12 @@ Each entry records: now locked rather than hidden inside unused ceiling headroom. The canonical byte lock runs on pinned Ubuntu 24.04 and Node 24.18.0; this prevents runner and compressor upgrades from masquerading as library-size changes. The - current canonical baseline records the unchanged source tree under that - environment. + bundle-reduction work locks the compact scene at 6,711 gzip bytes and its + React consumer at 14,225. Tooltip adds 3,382 bytes and portal adds another 806. Retained-output assertions prove the base excludes tooltip, portal, + React tooltip composition, and the D3 scale/format/interpolate stack. + Replacing the core-owned D3 ordinal default required reviewed 0.05 KiB + adjustments to the tick and polar-composition feature ceilings; all other + policy changes are exact locks or new isolated budgets. ### F-041 — Bounded segments and caps required custom marks @@ -1112,9 +1126,12 @@ Each entry records: `items` array. - Current decision: ordered items use one uniformly typed `text(point, context)` callback, preserving contextual datum and coordinate - types for every item kind. For a separately hoisted complete tooltip object, - document an explicit `ChartTooltipOptions` annotation as a normal - type-introduction boundary; never recommend a cast. + types for every item kind. The extension form + `{ use: tooltip, format(point) {} }` remains inside `defineChart`, so the + definition supplies contextual datum and coordinate types at the exact + option location. For a separately hoisted complete tooltip object, document + an explicit `ChartTooltipOptions` annotation as a normal type-introduction + boundary; never recommend a cast. - Follow-up: if raw-host examples repeat this pattern, add a small definition-correlated options helper rather than weakening callback types. @@ -3174,14 +3191,15 @@ Each entry records: contexts could clip it or place it below adjacent UI. Escaping those boundaries required rebuilding focus, placement, collision, pinning, and cleanup in an application-owned overlay. -- Decision: add definition-owned `tooltip.portal`. The host opens the tooltip - itself as a manual Popover in the browser top layer where supported, keeping - its chart DOM ancestry and inherited styling. If Popover is unavailable or - fails, the tooltip moves directly under the `ownerDocument` body with fixed - high-stack positioning. Both paths map scene anchors to client coordinates, - collide against the viewport, and reposition on scroll, viewport resize, - chart resize, and tooltip content resize. Local positioning remains the - default. Documentation calls out the fallback's CSS inheritance boundary. +- Decision: make portal transport an exact opt-in extension imported from + `@tanstack/charts/tooltip/portal` and installed as the nested `portal` option + on `{ use: tooltip }`. It opens the tooltip as a manual Popover in the + browser top layer where supported, keeping its chart DOM ancestry and + inherited styling. If Popover is unavailable or fails, the tooltip moves + directly under the `ownerDocument` body with fixed high-stack positioning. + Both paths map scene anchors to client coordinates, collide against the + viewport, and reposition on scroll, viewport resize, chart resize, and + tooltip content resize. Local positioning remains the default. - Verification: DOM-host regressions cover top-layer and fixed fallback parenting, client-coordinate mapping, viewport collision, scroll/resize/content repositioning, local-to-portal updates, renderer @@ -3189,10 +3207,11 @@ Each entry records: composition regression exercises a pinned custom body inside the portaled surface and removes it on unmount. Catalog case 35 passes its real-Chromium quick profile, including both widths and every interaction step. The primary - suite passes 2,459 tests, all framework matrices pass, and type, docs, + suite passes 3,003 tests, all framework matrices pass, and type, docs, packed-consumer, seven-adapter, formatting, and bundle gates pass. The - reviewed bundle change is 1,486 gzip bytes for the DOM host and 1,947 for the - React adapter; it adds no bundled dependency. + portal transport is absent from retained base and tooltip-only graphs. Its + isolated kernel is 1,580 gzip bytes and its measured increment on the + representative React tooltip consumer is 806 bytes. ### F-134 — Demo fixtures modeled charts instead of source data @@ -3394,3 +3413,29 @@ Each entry records: - Follow-up: add a focused type regression for the single-call form and decide whether its overload can retain builder datum inference without making behavior ownership ambiguous. + +### F-141 — Optional tooltip code burdened every chart consumer + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: reducing the representative React line consumer from roughly + 24 KiB to 15 KiB gzip +- Friction: the renderer statically owned native tooltip DOM, formatting, + placement, pinning, portal transport, and observers. React's base entries + also statically owned `react-dom` portal composition and the default rich + body. A chart with no tooltip paid for all of it, and tree shaking could not + cross the host's built-in branches. +- Decision: keep focus and pin policy in the host, but move tooltip rendering + behind the `ChartTooltipExtension` lifecycle and exact + `@tanstack/charts/tooltip` token. Move viewport transport behind the nested + portal extension. Move React rich-body composition to drop-in Chart, + CanvasChart, and RendererChart exports from + `@tanstack/react-charts/tooltip`. Base entries export only erased extension + types and never import those runtime modules. +- Verification: the representative compact React line is 14,225 gzip bytes. + Native tooltip adds 3,382 bytes; portal adds 806 more. Retained-output graph + checks prove base renderer and React entries contain none of the tooltip, + portal, or React rich-body modules. Core, Lit, React, export, declaration, + packed-package, and lifecycle tests cover creation, update, disable, + transport switching, custom bodies, and cleanup. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3187081..9d87153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## Unreleased + +### Breaking changes + +- Tooltips are now explicit extensions. Import `tooltip` from + `@tanstack/charts/tooltip` and replace `tooltip: true` with `tooltip`. +- Configured tooltips now require the extension discriminator: + `{ use: tooltip, format(point) {} }`. This is also the contextual typing + boundary for tooltip options. +- Portaled tooltips now import `portal` from + `@tanstack/charts/tooltip/portal` and use `portal` instead of `portal: true`. +- React consumers that provide `renderTooltipBody` must import `Chart`, + `RendererChart`, or `CanvasChart` from `@tanstack/react-charts/tooltip`. + The base React entries retain native tooltips without including React DOM's + portal runtime. + +```ts +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' + +defineChart({ + // ... + tooltip: { + use: tooltip, + portal, + format(point) { + return String(point.datum.value) + }, + }, +}) +``` + +### Added + +- Added compact callable scales through the exact + `@tanstack/charts-scales/linear`, `/band`, `/point`, and `/ordinal` + subpaths. Full D3 scales remain supported when their broader behavior is + required. + +### Bundle impact + +- The representative compact React line consumer is 14,225 bytes gzip + (13.89 KiB), down from approximately 24 KiB. +- Opting into the tooltip extension adds 3,382 bytes gzip. +- Adding the portal extension adds another 806 bytes gzip. + ## 0.0.1 (2026-07-30) `0.0.1` is the first coordinated update after the public `0.0.0` release. The diff --git a/PLAN.md b/PLAN.md index 93a4ab3..2659f2e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,7 +6,7 @@ Observed difficulty from using the API is tracked separately in [`API-FRICTION.md`](./API-FRICTION.md). Production migrations, examples, and agent evaluations must update that log when they expose a repeatable problem. -Last updated: 2026-07-28 +Last updated: 2026-07-30 ## Working thesis @@ -1870,17 +1870,19 @@ This should allow a chart to update Router search state, Store state, filters, d Tooltips follow the same progressive-disclosure rule: -- `tooltip: true` derives locale-safe plain text from scene point metadata. -- `format(point)` and `formatGroup(points)` own custom text without changing the - mark data. -- `sticky: true` enables click-to-pin behavior. +- The imported `tooltip` token derives structured content from scene point + metadata. +- `{ use: tooltip, format(point) {} }` and `formatGroup(points)` own custom text + without changing the mark data. +- Tooltips are pinnable by default; `sticky: false` disables click-to-pin + behavior. - `onFocusChange` and `onFocusGroupChange` provide complete external-state control for rich application UI. -The remaining advanced target is a framework-neutral structured content model -with labeled rows, ordering, suppression, and color swatches derived from -explicit semantic channels. It must remain optional and must not accept -framework nodes or arbitrary HTML in the core protocol. +The tooltip extension owns framework-neutral labeled rows, ordering, +suppression, and color swatches derived from explicit semantic channels. Rich +framework bodies remain adapter-owned optional entries; the core protocol does +not accept framework nodes or arbitrary HTML. The default DOM presentation owns adaptive placement, light and dark styling, keyboard and pointer behavior, pinning, text selection, and accessible live @@ -2041,6 +2043,39 @@ Track and gate at least: - Raster export - Motion +### Deferred post-15 KiB options — 2026-07-30 + +The compact-scale React line consumer is currently 14,225 gzip bytes. Its +native tooltip adds 3,382 bytes and portal transport adds another 806 bytes. +The following read-only bundle experiments are options for later work, not +commitments for the current tooltip release: + +- Move the remaining tooltip host lifecycle behind the tooltip extension: + approximately 480 bytes from charts without tooltips. +- Replace the retained `d3-array/least` nearest-point call with an equivalent + local loop: approximately 108 bytes with no intended behavior change. +- Make compact scales native chart-scale tokens and move general callable D3 + positional and color-scale adaptation behind exact opt-in entries: + approximately 1,300 bytes from both base and tooltip consumers. +- Move animation and specialized focus modes behind exact tokens: + approximately 692 and 351 bytes respectively. +- Split a lean text tooltip from structured rows, items, swatches, and rich + framework bodies. A virtual lean-tooltip consumer measured 14,689 gzip bytes; + preserve the current structured behavior through an exact optional entry if + this is pursued. +- Keep precise DOM text measurement in the default path unless visual evidence + justifies making it optional. Its measured 709-byte saving does not currently + outweigh label-layout risk. +- Consider a separate fixed-size `StaticChart` entry only for consumers that do + not need updates, resizing, focus, or tooltips. The counterfactual measured + 7,942 gzip bytes and is not a replacement for the normal chart. + +A combined virtual build measured 11,128 gzip bytes for the base, 14,493 with +the current tooltip, and 15,303 with portal transport. These figures establish +an opportunity envelope; every future boundary must retain its own behavioral, +type, packed-consumer, and retained-module gates. Do not simplify tick quality, +nicening, inversion, or clamping merely to chase the remaining bytes. + If the product requires going below Plot’s supported floor, the correct paths are an upstream contribution, a deliberately maintained compatible fork, or a native engine that passes the decision gates above. Unsupported deep imports, diff --git a/README.md b/README.md index 764830a..7336039 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ or dropping down to a separate API. ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/react-charts' const revenue = [ @@ -94,7 +95,7 @@ const revenueChart = defineChart({ label: 'Revenue', grid: true, }, - tooltip: true, + tooltip, }) export function RevenueChart() { diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index ae720be..bed6150 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -6,7 +6,8 @@ features must not affect. Their minified and gzip bytes must match [`universal-baseline.json`](./universal-baseline.json) exactly. - **Budgeted** entries isolate an optional chart feature or D3 capability and - enforce a feature-specific gzip ceiling. + enforce a feature-specific gzip ceiling. An incremental budget compares two + complete consumers so shared compression is represented honestly. - **Measured** entries are comparison libraries, applications, or exploratory D3 kernels. They are reported without defining a TanStack product ceiling. @@ -15,6 +16,20 @@ Every package entry in New marks and optional capabilities need an isolated budgeted entry. They must leave the locked entries byte-identical. +The retained-input gate reads esbuild's output contributions rather than its +parsed input list. This distinguishes code that contributes bytes from a +zero-byte re-export. The compact-scale fixtures require their exact subpaths +and reject sibling scale implementations. The React base rejects tooltip and +portal code, the tooltip consumer rejects portal code, and the portal consumer +may add only its transport module over the tooltip consumer. + +The compact linear scene and React consumer are both locked and budgeted. The +scene has a 7 KiB gzip ceiling. The React line consumer has a 15 KiB ceiling +with React and React DOM external. `d3-array` tick helpers are allowed only in +the compact linear path; categorical compact-scale kernels reject every D3 +runtime input. All compact fixtures reject `d3-scale`, `d3-format`, +`d3-interpolate`, `d3-color`, and `internmap`. + Non-cartesian capability subpaths follow the same policy. Polar and geographic entries measure complete scenes through static SVG: a minimal arc, D3 pie, full scale-backed gauge composition, a scale-backed polar line and scatter diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 12c0ac0..277f9cb 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,36 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 36311, - "gzip": 14118 + "bytes": 35102, + "gzip": 13764 }, "D3-scale line + static SVG": { - "bytes": 39363, - "gzip": 15237 + "bytes": 38154, + "gzip": 14884 }, "Representative marks": { - "bytes": 49125, - "gzip": 17885 + "bytes": 49059, + "gzip": 17977 }, "TanStack DOM host": { - "bytes": 42894, - "gzip": 15700 + "bytes": 31263, + "gzip": 11614 }, "React adapter": { - "bytes": 46334, - "gzip": 16905 + "bytes": 33433, + "gzip": 12312 }, "React line consumer": { - "bytes": 68655, - "gzip": 25708 + "bytes": 55803, + "gzip": 21281 + }, + "Compact-scale line scene": { + "bytes": 17532, + "gzip": 6711 + }, + "React compact-scale line consumer": { + "bytes": 38366, + "gzip": 14225 }, "Custom-scale line scene": { - "bytes": 17087, - "gzip": 6431 + "bytes": 15746, + "gzip": 5959 }, "D3 linear-scale line scene": { - "bytes": 36243, - "gzip": 14083 + "bytes": 35034, + "gzip": 13726 } } } diff --git a/benchmarks/comparison/libraries/tanstack/base.ts b/benchmarks/comparison/libraries/tanstack/base.ts index ced40db..319f113 100644 --- a/benchmarks/comparison/libraries/tanstack/base.ts +++ b/benchmarks/comparison/libraries/tanstack/base.ts @@ -9,6 +9,7 @@ import { type SceneNode, } from '@tanstack/charts' import { focusX } from '@tanstack/charts/focus' +import { tooltip } from '@tanstack/charts/tooltip' import type { BenchmarkHandle, BenchmarkInput } from '../../types' import { createFrameOperation } from '../../stress/operation' import { @@ -36,6 +37,7 @@ export function mountDefinition( keyboard: interactive, tooltip: BENCHMARK_GROUPED_X_FOCUS ? { + use: tooltip, formatGroup(points: readonly ChartPoint[]) { const x = points[0]?.xValue return [ @@ -46,7 +48,9 @@ export function mountDefinition( ].join('\n') }, } - : interactive, + : interactive + ? tooltip + : false, ...(BENCHMARK_GROUPED_X_FOCUS ? { focus: focusX, diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts index 765c3af..3ccb530 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts @@ -1,5 +1,6 @@ import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' import type { ChartHostOptions } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { scaleLinear, scaleUtc } from 'd3-scale' import { aapl } from '@charts-poc/demo-data/aapl' import type { AaplRow } from '@charts-poc/demo-data/aapl' @@ -40,6 +41,7 @@ const configuredDefinition = (input: ConformanceInput) => animate: false, keyboard: true, tooltip: { + use: tooltip, anchor: 'point', placement: ['top', 'right', 'left', 'bottom'], items: [ diff --git a/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts b/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts index 51216e4..f029087 100644 --- a/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts @@ -1,5 +1,7 @@ import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' import type { ChartHostOptions } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' import { scaleLinear, scaleUtc } from 'd3-scale' import { industries } from '@charts-poc/demo-data/industries' import type { @@ -47,7 +49,8 @@ const configuredDefinition = (input: ConformanceInput) => keyboard: true, focus: 'group-x', tooltip: { - portal: true, + use: tooltip, + portal, anchor: 'group-center', placement: ['top', 'right', 'left', 'bottom'], sort: 'color-domain', diff --git a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts index a3e47ae..8d7c201 100644 --- a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts @@ -1,5 +1,6 @@ import { cars } from '@charts-poc/demo-data/cars' import { createMark, defineChart, dot, mountChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Delaunay } from 'd3-delaunay' import { scaleLinear } from 'd3-scale' import type { CarsRow } from '@charts-poc/demo-data/cars' @@ -117,6 +118,7 @@ const configuredDefinition = (rows: readonly CompleteCar[]) => animate: false, keyboard: true, tooltip: { + use: tooltip, anchor: 'pointer', items: [ { diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts index f6e6ccd..ecd77f0 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts @@ -1,4 +1,5 @@ import { defineChart, mountChart, rect } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { scaleBand, scaleUtc } from 'd3-scale' import { timelineStatusColors } from './colors' import { @@ -114,6 +115,7 @@ export const mount: ConformanceMount = (container, input) => { animate: false, keyboard: true, tooltip: { + use: tooltip, format: (point) => `${point.datum.resource} · ${point.datum.label} · ${ point.datum.status diff --git a/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts b/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts index 48a1d7f..4134e96 100644 --- a/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts +++ b/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts @@ -1,4 +1,5 @@ import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { downloads } from '@charts-poc/demo-data/downloads' import { scaleLinear, scaleUtc } from 'd3-scale' import { streamingData } from './selection' @@ -158,6 +159,7 @@ export const mount: ConformanceMount = (container, input) => { animate: false, keyboard: true, tooltip: { + use: tooltip, format: (point) => `${formatStreamingDate(point.datum.date)} · ${point.datum.downloads.toLocaleString()} downloads`, }, diff --git a/benchmarks/conformance/shared/mount.ts b/benchmarks/conformance/shared/mount.ts index 74a9571..9678bd4 100644 --- a/benchmarks/conformance/shared/mount.ts +++ b/benchmarks/conformance/shared/mount.ts @@ -3,6 +3,7 @@ import { isDynamicChartDefinition, mountChart, } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import type { ChartDefinition, ChartDefinitionOptions, @@ -91,7 +92,12 @@ function withConformanceBehavior< const behavior: ChartDefinitionOptions = { animate: false, keyboard: input.interactive === true, - tooltip: input.interactive === true ? interactiveTooltip : false, + tooltip: + input.interactive !== true + ? false + : interactiveTooltip === true + ? tooltip + : { use: tooltip, ...interactiveTooltip }, } if (isDynamicChartDefinition(definition)) { diff --git a/benchmarks/entries/charts-compact-linear-scene.ts b/benchmarks/entries/charts-compact-linear-scene.ts new file mode 100644 index 0000000..9336251 --- /dev/null +++ b/benchmarks/entries/charts-compact-linear-scene.ts @@ -0,0 +1,13 @@ +import { createChartScene, defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts-scales/linear' + +const definition = defineChart({ + marks: [lineY([4, 9, 7])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, +}) + +export const scene = createChartScene(definition, { + width: 640, + height: 320, +}) diff --git a/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts b/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts new file mode 100644 index 0000000..077aa8d --- /dev/null +++ b/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts @@ -0,0 +1,16 @@ +import { defineChart, lineY } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' +import { scaleLinear } from '@tanstack/charts-scales/linear' + +export { Chart } from '@tanstack/react-charts' + +export const definition = defineChart({ + marks: [lineY([4, 9, 7])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + tooltip: { + use: tooltip, + portal, + }, +}) diff --git a/benchmarks/entries/charts-react-compact-line-tooltip.ts b/benchmarks/entries/charts-react-compact-line-tooltip.ts new file mode 100644 index 0000000..e12e084 --- /dev/null +++ b/benchmarks/entries/charts-react-compact-line-tooltip.ts @@ -0,0 +1,12 @@ +import { defineChart, lineY } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { scaleLinear } from '@tanstack/charts-scales/linear' + +export { Chart } from '@tanstack/react-charts' + +export const definition = defineChart({ + marks: [lineY([4, 9, 7])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + tooltip, +}) diff --git a/benchmarks/entries/charts-react-compact-line.ts b/benchmarks/entries/charts-react-compact-line.ts new file mode 100644 index 0000000..855ed5f --- /dev/null +++ b/benchmarks/entries/charts-react-compact-line.ts @@ -0,0 +1,10 @@ +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts-scales/linear' + +export { Chart } from '@tanstack/react-charts' + +export const definition = defineChart({ + marks: [lineY([4, 9, 7])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, +}) diff --git a/benchmarks/entries/charts-react-stats-parity.tsx b/benchmarks/entries/charts-react-stats-parity.tsx index e974763..28d32a7 100644 --- a/benchmarks/entries/charts-react-stats-parity.tsx +++ b/benchmarks/entries/charts-react-stats-parity.tsx @@ -2,6 +2,7 @@ import { Chart } from '@tanstack/react-charts' import { useMemo } from 'react' import { defineChart } from '@tanstack/charts' import { focusX, focusY } from '@tanstack/charts/focus' +import { tooltip } from '@tanstack/charts/tooltip' import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' import { createStatsHistoryInput, @@ -15,7 +16,7 @@ export function HistoryChart() { () => defineChart(createStatsHistoryChart(createStatsHistoryInput('stream')), { focus: focusX, - tooltip: true, + tooltip, }), [], ) @@ -33,7 +34,7 @@ export function RankingChart() { () => defineChart( createStatsLatestChart(createStatsLatestInput('horizontal', true)), - { focus: focusY, tooltip: true }, + { focus: focusY, tooltip }, ), [], ) diff --git a/benchmarks/entries/charts-scale-band-kernel.ts b/benchmarks/entries/charts-scale-band-kernel.ts new file mode 100644 index 0000000..77a2dae --- /dev/null +++ b/benchmarks/entries/charts-scale-band-kernel.ts @@ -0,0 +1,16 @@ +import { scaleBand } from '@tanstack/charts-scales/band' + +const scale = scaleBand(['a', 'b', 'c'], [0, 100]) + .paddingInner(0.1) + .paddingOuter(0.2) + .align(0.4) + .round(true) + +export function inspectBand(value: 'a' | 'b' | 'c') { + return { + value: scale(value), + bandwidth: scale.bandwidth(), + step: scale.step(), + copied: scale.copy()(value), + } +} diff --git a/benchmarks/entries/charts-scale-family-kernel.ts b/benchmarks/entries/charts-scale-family-kernel.ts new file mode 100644 index 0000000..ef57806 --- /dev/null +++ b/benchmarks/entries/charts-scale-family-kernel.ts @@ -0,0 +1,4 @@ +export { scaleBand } from '@tanstack/charts-scales/band' +export { scaleLinear } from '@tanstack/charts-scales/linear' +export { scaleOrdinal } from '@tanstack/charts-scales/ordinal' +export { scalePoint } from '@tanstack/charts-scales/point' diff --git a/benchmarks/entries/charts-scale-linear-kernel.ts b/benchmarks/entries/charts-scale-linear-kernel.ts new file mode 100644 index 0000000..0465832 --- /dev/null +++ b/benchmarks/entries/charts-scale-linear-kernel.ts @@ -0,0 +1,13 @@ +import { scaleLinear } from '@tanstack/charts-scales/linear' + +const scale = scaleLinear([0.2, 9.8], [0, 100]).clamp(true).nice(5) + +export function inspectLinear(value: number) { + return { + value: scale(value), + inverted: scale.invert(value), + ticks: scale.ticks(5), + label: scale.tickFormat(5)(value), + copied: scale.copy()(value), + } +} diff --git a/benchmarks/entries/charts-scale-ordinal-kernel.ts b/benchmarks/entries/charts-scale-ordinal-kernel.ts new file mode 100644 index 0000000..b8d4621 --- /dev/null +++ b/benchmarks/entries/charts-scale-ordinal-kernel.ts @@ -0,0 +1,14 @@ +import { scaleOrdinal } from '@tanstack/charts-scales/ordinal' + +const scale = scaleOrdinal(['a', 'b'], ['red', 'blue']).unknown( + 'gray', +) + +export function inspectOrdinal(value: string) { + return { + value: scale(value), + domain: scale.domain(), + range: scale.range(), + copied: scale.copy()(value), + } +} diff --git a/benchmarks/entries/charts-scale-point-kernel.ts b/benchmarks/entries/charts-scale-point-kernel.ts new file mode 100644 index 0000000..be45de0 --- /dev/null +++ b/benchmarks/entries/charts-scale-point-kernel.ts @@ -0,0 +1,15 @@ +import { scalePoint } from '@tanstack/charts-scales/point' + +const scale = scalePoint(['a', 'b', 'c'], [0, 100]) + .padding(0.2) + .align(0.4) + .round(true) + +export function inspectPoint(value: 'a' | 'b' | 'c') { + return { + value: scale(value), + bandwidth: scale.bandwidth(), + step: scale.step(), + copied: scale.copy()(value), + } +} diff --git a/benchmarks/entries/charts-stats-parity.ts b/benchmarks/entries/charts-stats-parity.ts index 1a2cc22..a18c8ff 100644 --- a/benchmarks/entries/charts-stats-parity.ts +++ b/benchmarks/entries/charts-stats-parity.ts @@ -1,5 +1,6 @@ import { defineChart, mountChart } from '@tanstack/charts' import { focusX, focusY } from '@tanstack/charts/focus' +import { tooltip } from '@tanstack/charts/tooltip' import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' import { createStatsHistoryInput, @@ -12,7 +13,7 @@ export function mountHistory(element: HTMLElement) { return mountChart(element, { definition: defineChart( createStatsHistoryChart(createStatsHistoryInput('stream')), - { focus: focusX, tooltip: true }, + { focus: focusX, tooltip }, ), ariaLabel: 'Package downloads', renderSvg: renderChartSvgWithResources, @@ -23,7 +24,7 @@ export function mountRanking(element: HTMLElement) { return mountChart(element, { definition: defineChart( createStatsLatestChart(createStatsLatestInput('horizontal', true)), - { focus: focusY, tooltip: true }, + { focus: focusY, tooltip }, ), ariaLabel: 'Package ranking', renderSvg: renderChartSvgWithResources, diff --git a/benchmarks/entries/charts-tooltip-kernel.ts b/benchmarks/entries/charts-tooltip-kernel.ts new file mode 100644 index 0000000..846e7cb --- /dev/null +++ b/benchmarks/entries/charts-tooltip-kernel.ts @@ -0,0 +1 @@ +export { tooltip } from '@tanstack/charts/tooltip' diff --git a/benchmarks/entries/charts-tooltip-portal-kernel.ts b/benchmarks/entries/charts-tooltip-portal-kernel.ts new file mode 100644 index 0000000..d6c9b7c --- /dev/null +++ b/benchmarks/entries/charts-tooltip-portal-kernel.ts @@ -0,0 +1 @@ +export { portal } from '@tanstack/charts/tooltip/portal' diff --git a/docs/concepts/chart-definitions.md b/docs/concepts/chart-definitions.md index 462a1a1..9d582a5 100644 --- a/docs/concepts/chart-definitions.md +++ b/docs/concepts/chart-definitions.md @@ -50,8 +50,10 @@ Pass a builder when tick density, annotations, or mark composition depends on the chart surface: ```ts +import { tooltip } from '@tanstack/charts/tooltip' + const productRanking = defineChart({ - tooltip: true, + tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], x: { @@ -95,12 +97,14 @@ concerns. Definitions capture the values they use. In React: ```tsx +import { tooltip } from '@tanstack/charts/tooltip' + function ProductRanking({ rows, metric }: Props) { const definition = useMemo(() => { const ranked = rankProducts(rows, metric) return defineChart({ - tooltip: true, + tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], x: { diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index 9230802..cd7803d 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -3,13 +3,15 @@ title: Scales and D3 description: Understand the explicit boundary between TanStack Charts and granular D3 scales, transforms, shapes, and interaction algorithms. --- -TanStack Charts uses D3 as an explicit algorithm layer: +TanStack Charts uses an explicit algorithm layer: - **Your application** imports and configures the D3 modules required by a chart. - **D3** supplies battle-tested scales, array transforms, shape interpolation, time utilities, and spatial algorithms. +- **TanStack compact scales** cover the common numeric and categorical scale subset when bundle size matters more than D3's complete semantics. - **TanStack Charts** supplies the typed grammar, responsive ranges, guide layout, scene compilation, rendering, reconciliation, and lifecycle. -There is no second scale or transform language to learn. There is also no hidden D3 umbrella import. +Both scale implementations use callable, copyable scale objects. There is no +hidden D3 umbrella import. ## Direct dependency ownership @@ -26,6 +28,37 @@ This rule also applies when definitions live in framework component source. The adapter mounts a definition; it does not own the D3 imports used to author it. +## Compact scales + +For numeric linear, band, point, and ordinal mappings, install the optional +compact package: + +```sh +pnpm add @tanstack/charts-scales +``` + +Import one exact family: + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' +import { scaleBand } from '@tanstack/charts-scales/band' +import { scalePoint } from '@tanstack/charts-scales/point' +import { scaleOrdinal } from '@tanstack/charts-scales/ordinal' +``` + +There is intentionally no `@tanstack/charts-scales` root export. Each factory +fits the same callable, `domain`, `range`, and `copy` contract consumed by +TanStack Charts. + +The compact linear scale is numeric and two-stop. It supports mapping, +`invert`, `clamp`, `nice`, ticks, basic numeric tick formatting, and copying. +The categorical families support D3-compatible domain interning, padding, +alignment, rounding, bandwidth, unknown values, and copying. + +Use `d3-scale` for time, UTC, log, power, symlog, radial, sequential, +diverging, quantile, quantize, and threshold scales; piecewise or nonnumeric +interpolation; locale-aware format specifiers; and other full D3 behavior. + ## Capability map Use the official D3 pages as the API reference for each algorithm. TanStack Charts documentation only describes how its output crosses the chart boundary. diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index 5852347..81cd3ea 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -48,9 +48,10 @@ in both transient and pinned states. The transient body is inert; gate controls on `pinned`. The nested chart receives its own accessible label, definition, runtime, and framework cleanup. -Set definition `tooltip.portal: true` to escape clipped ancestors and use -viewport collision handling. Move focus intentionally when the body contains -controls, preserve Escape, and wire a close button to `dismiss`. +Add the `portal` extension to the definition's tooltip options to escape +clipped ancestors and use viewport collision handling. Move focus intentionally +when the body contains controls, preserve Escape, and wire a close button to +`dismiss`. ## Scroll a wide schedule diff --git a/docs/framework/angular/adapter.md b/docs/framework/angular/adapter.md index cc45591..57071e6 100644 --- a/docs/framework/angular/adapter.md +++ b/docs/framework/angular/adapter.md @@ -11,6 +11,7 @@ pnpm add @tanstack/charts @tanstack/angular-charts @angular/common @angular/core import { Component } from '@angular/core' import { Chart } from '@tanstack/angular-charts' import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' @Component({ imports: [Chart], @@ -18,7 +19,7 @@ import { defineChart } from '@tanstack/charts' }) export class RevenueChart { chartOptions = { - definition: defineChart(createRevenueChart(rows), { tooltip: true }), + definition: defineChart(createRevenueChart(rows), { tooltip }), ariaLabel: 'Revenue by month', aspectRatio: 16 / 9, } diff --git a/docs/framework/lit/adapter.md b/docs/framework/lit/adapter.md index 9921ef8..affb7ca 100644 --- a/docs/framework/lit/adapter.md +++ b/docs/framework/lit/adapter.md @@ -19,10 +19,11 @@ Pass chart options as a property: ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' html`` diff --git a/docs/framework/octane/quick-start.md b/docs/framework/octane/quick-start.md index 410df5e..ea954b1 100644 --- a/docs/framework/octane/quick-start.md +++ b/docs/framework/octane/quick-start.md @@ -23,6 +23,7 @@ Definitions are framework-independent and can be shared with any adapter: ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/octane-charts' interface AlphabetRow { @@ -60,7 +61,7 @@ const letterFrequencyChart = defineChart({ format: (value) => percent.format(value), grid: true, }, - tooltip: true, + tooltip, }) export function LetterFrequencyChart() { @@ -132,7 +133,7 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { nice: true, }, animate: true, - tooltip: true, + tooltip, }) }) diff --git a/docs/framework/preact/adapter.md b/docs/framework/preact/adapter.md index 9be5de6..1f150c9 100644 --- a/docs/framework/preact/adapter.md +++ b/docs/framework/preact/adapter.md @@ -11,11 +11,12 @@ pnpm add @tanstack/charts @tanstack/preact-charts preact d3-scale ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/preact-charts' export function RevenueChart() { const definition = useMemo( - () => defineChart(createRevenueChart(rows), { tooltip: true }), + () => defineChart(createRevenueChart(rows), { tooltip }), [rows], ) diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 7e932a5..a34a074 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -17,7 +17,6 @@ export type { ChartProps, ChartDefinition, ChartPoint, - ChartTooltipBodyRenderContext, } from '@tanstack/react-charts' ``` @@ -33,6 +32,24 @@ import { Chart as RendererChart } from '@tanstack/react-charts/core' a `renderer` prop. The default `Chart` remains SVG-based, so importing the default adapter does not pull Canvas into its module graph. +The base entries render the native tooltip without the React tooltip-body +bridge. Import from the optional tooltip entry when passing +`renderTooltipBody`: + +```tsx +import { + Chart, + CanvasChart, + RendererChart, + type ChartTooltipBodyRenderContext, +} from '@tanstack/react-charts/tooltip' +``` + +Existing `renderTooltipBody` users should move the default component import +from `@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. For Canvas +or an application renderer, replace the aliased `Chart` import from `/canvas` +or `/core` with `CanvasChart` or `RendererChart` from `/tooltip`. + Use the re-exported definition and point types only when an application API needs an explicit annotation. Ordinary component use is inferred. @@ -139,17 +156,19 @@ The React adapter's `className` intentionally owns the outer element instead. ## Tooltip body composition -`renderTooltipBody` mounts React content into the native tooltip surface. Its -context provides `points`, `content`, `defaultBody`, `pinned`, and `dismiss`. -Composing `defaultBody` keeps the core title, rows, formatting, and swatches; -arbitrary React content can sit beside it. +The components from `@tanstack/react-charts/tooltip` accept +`renderTooltipBody`, which mounts React content into the native tooltip +surface. Its context provides `points`, `content`, `defaultBody`, `pinned`, +and `dismiss`. Composing `defaultBody` keeps the core title, rows, formatting, +and swatches; arbitrary React content can sit beside it. The shared host owns a stable body mount target and releases it when the tooltip is hidden, custom rendering is disabled, or the chart is destroyed. React owns the rendered component lifecycle. A custom body is inert while 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. +render only while `pinned` is true. Adding the `portal` extension to the +tooltip options promotes the whole surface above clipped ancestors without +changing this React API. ## Definition identity diff --git a/docs/framework/react/quick-start.md b/docs/framework/react/quick-start.md index 0d88e12..2b505b3 100644 --- a/docs/framework/react/quick-start.md +++ b/docs/framework/react/quick-start.md @@ -23,6 +23,7 @@ Definitions are ordinary framework-independent TypeScript: ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/react-charts' interface AlphabetRow { @@ -60,7 +61,7 @@ const letterFrequencyChart = defineChart({ format: (value) => percent.format(value), grid: true, }, - tooltip: true, + tooltip, }) export function LetterFrequencyChart() { @@ -135,7 +136,7 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { nice: true, }, animate: true, - tooltip: true, + tooltip, }) }, [rows, accent]) @@ -177,6 +178,32 @@ The native tooltip is optional. Grouped focus, formatting, keyboard behavior, and application-owned interaction are documented in [Focus and interaction](../../reference/focus-and-interaction.md). +## Render React tooltip content + +Keep `Chart` from `@tanstack/react-charts` when the native tooltip is enough. +To pass `renderTooltipBody`, switch the component import to the optional React +tooltip entry: + +```tsx +import { Chart } from '@tanstack/react-charts/tooltip' + +; ( + <> + {defaultBody} + {pinned ? : null} + + )} +/> +``` + +Existing `renderTooltipBody` users should migrate the component import from +`@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. The definition +still uses `tooltip` from `@tanstack/charts/tooltip`. + ## Example This catalog example uses multiple line layers and endpoint labels through the diff --git a/docs/framework/react/reference/chart.md b/docs/framework/react/reference/chart.md index 92faffd..0c19c43 100644 --- a/docs/framework/react/reference/chart.md +++ b/docs/framework/react/reference/chart.md @@ -44,6 +44,9 @@ The Canvas `Chart` accepts the same adapter props except `renderSvg`. Its surfaces. Both entries export `ChartCommonProps`, `ChartProps`, `ChartDefinition`, and `ChartPoint`. +These base entries render the native tooltip without React tooltip-body +composition. + ## Definition props | Prop | Default | Meaning | @@ -55,8 +58,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). The definition owns `focus`, `maxFocusDistance`, `spatialIndex`, `animate`, `keyboard`, and `tooltip`. Adapters do not override them. -Set definition `tooltip.portal: true` to escape clipping and local stacking -contexts. The adapter still receives no portal override. +Add the `portal` extension to the definition's tooltip options to escape +clipping and local stacking contexts. The adapter still receives no portal +override. ## Accessibility and sizing @@ -88,6 +92,17 @@ the behavior and complete callback values. ## Tooltip body +Import the drop-in component from the optional tooltip entry to use +`renderTooltipBody`: + +```tsx +import { + Chart, + CanvasChart, + RendererChart, +} from '@tanstack/react-charts/tooltip' +``` + | Prop | Type | Default | Meaning | | ------------------- | ------------------------------------------------------- | ------- | ------------------------------------------------ | | `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ReactNode` | None | Composes React content inside the native surface | @@ -112,9 +127,11 @@ interactive content only when `pinned` is true; transient tooltips do not accept pointer input. `dismiss()` clears the tooltip and restores chart focus when focus was inside its body. -The prop is available from the default, `/canvas`, and `/core` entries. It is -adapter presentation. Ordering, anchoring, placement, portaling, and sticky -behavior remain in the chart definition. +Existing users of this prop should move `Chart` from +`@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. Replace +`Chart as CanvasChart` from `/canvas` or `Chart as RendererChart` from `/core` +with the corresponding named component from `/tooltip`. Ordering, anchoring, +placement, portaling, and sticky behavior remain in the chart definition. ## Rendering and layout extensions @@ -129,7 +146,10 @@ See [Rendering and export](../../../reference/rendering-and-export.md) and ## Exported prop types -The adapter exports `ChartCommonProps`, `ChartProps`, and +The base entries export `ChartCommonProps` and `ChartProps`. The `/tooltip` +entry exports its extended `ChartCommonProps`, `ChartProps`, +`RendererChartCommonProps`, `RendererChartProps`, `CanvasChartCommonProps`, +`CanvasChartProps`, `ChartTooltipBodyRenderProps`, and `ChartTooltipBodyRenderContext`. ```ts diff --git a/docs/framework/solid/adapter.md b/docs/framework/solid/adapter.md index 937c7c2..dbf0ef8 100644 --- a/docs/framework/solid/adapter.md +++ b/docs/framework/solid/adapter.md @@ -9,10 +9,11 @@ pnpm add @tanstack/charts @tanstack/solid-charts solid-js d3-scale ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/solid-charts' const definition = createMemo(() => - defineChart(createRevenueChart(rows()), { tooltip: true }), + defineChart(createRevenueChart(rows()), { tooltip }), ) ; import { defineChart } from '@tanstack/charts' + import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/svelte-charts' const definition = $derived( - defineChart(createRevenueChart(rows), { tooltip: true }), + defineChart(createRevenueChart(rows), { tooltip }), ) diff --git a/docs/framework/vue/adapter.md b/docs/framework/vue/adapter.md index c914317..a00cf89 100644 --- a/docs/framework/vue/adapter.md +++ b/docs/framework/vue/adapter.md @@ -10,11 +10,12 @@ pnpm add @tanstack/charts @tanstack/vue-charts vue d3-scale ```vue diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 4673386..6096b60 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -24,6 +24,11 @@ import { mountChartRenderer } from '@tanstack/charts/renderer' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' import { d3Curve } from '@tanstack/charts/d3/shape' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' +import { scaleLinear } from '@tanstack/charts-scales/linear' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -49,6 +54,23 @@ boundary data and `topojson-client` remain application dependencies; importing Your bundler must honor ESM exports and tree shaking. Avoid namespace imports when a named or subpath import communicates the real dependency. +Tooltip rendering is also opt-in. A definition imports `tooltip`; viewport +layering additionally imports `portal` and nests it under the tooltip options: + +```ts +const interactive = defineChart(definition, { + tooltip: { + use: tooltip, + portal, + }, +}) +``` + +The locked compact React line consumer must remain at or below 15 kB gzip. +Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, +`d3-interpolate`, `d3-color`, and sibling compact-scale entries. Separate +incremental gates limit tooltip and portal growth. + ## Import D3 by capability TanStack Charts does not bundle a hidden analytical runtime. Import only the D3 diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index a916c59..3822336 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -155,16 +155,19 @@ Give it a class through `tooltip.className` and style that class in application CSS: ```ts -const tooltip = { - className: 'revenue-tooltip', -} +import { tooltip } from '@tanstack/charts/tooltip' + +const definition = defineChart(baseDefinition, { + tooltip: { use: tooltip, className: 'revenue-tooltip' }, +}) ``` -With `portal: true`, the preferred manual-Popover path keeps the element under -the chart in the DOM, so inheritance and scoped selectors continue to work. If -Popover is unavailable or fails, the fixed fallback moves the element under -the chart's `ownerDocument` body. Use a document-level selector for its class -and put required fallback tokens on that class or a shared document ancestor. +With the `portal` extension, the preferred manual-Popover path keeps the +element under the chart in the DOM, so inheritance and scoped selectors +continue to work. If Popover is unavailable or fails, the fixed fallback moves +the element under the chart's `ownerDocument` body. Use a document-level +selector for its class and put required fallback tokens on that class or a +shared document ancestor. Every framework adapter can compose native application content with the default rows. See [Tooltips and Focus](./tooltips-and-focus.md). diff --git a/docs/guides/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index 30b7a98..ef7a3c4 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -15,10 +15,12 @@ Use that path until the product needs richer interaction. ## Default nearest point -Enable the native tooltip with `tooltip: true`: +Import the native tooltip extension and add it to the definition: ```ts -const interactiveDefinition = defineChart(definition, { tooltip: true }) +import { tooltip } from '@tanstack/charts/tooltip' + +const interactiveDefinition = defineChart(definition, { tooltip }) const host = mountChart(element, { definition: interactiveDefinition, @@ -55,10 +57,10 @@ empty space should mean no focus. ## Automatic tooltip mapping -`tooltip: true` renders labeled rows for the focused point. Grouped focus uses -the shared axis value as a heading and renders one row and color swatch per -series. Visible axis labels carry into the tooltip. Numbers use the user's -locale and dates use stable UTC ISO formatting. +The default `tooltip` extension renders labeled rows for the focused point. +Grouped focus uses the shared axis value as a heading and renders one row and +color swatch per series. Visible axis labels carry into the tooltip. Numbers +use the user's locale and dates use stable UTC ISO formatting. Rect and link endpoints display as ranges. Bars and areas with an explicit baseline display the interval length, so a stacked segment reports its own @@ -68,32 +70,31 @@ Order built-in channels, datum fields, and derived text for a single focused point: ```ts -const tooltip = { - items: [ - { - channel: 'y', - label: 'Revenue', - text: (point) => currency(point.yValue), - }, - { - field: 'status', - label: 'Status', - }, - { - id: 'change', - label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), - }, - 'x', - ], -} - const definition = defineChart({ marks, x, y, - tooltip, + tooltip: { + use: tooltip, + items: [ + { + channel: 'y', + label: 'Revenue', + text: (point) => currency(point.yValue), + }, + { + field: 'status', + label: 'Status', + }, + { + id: 'change', + label: 'Change', + text: (point) => + point.datum.change == null ? null : percent(point.datum.change), + }, + 'x', + ], + }, }) ``` @@ -106,30 +107,37 @@ when a grouped tooltip needs additional columns or nested sections. Customize plaintext content with typed formatters: ```ts -const tooltip = { - format(point) { - return `${point.datum.label}: ${point.datum.value.toLocaleString()}` +const formattedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + format(point) { + return `${point.datum.label}: ${point.datum.value.toLocaleString()}` + }, }, -} +}) ``` For grouped focus: ```ts -const tooltip = { - formatGroup(points) { - const date = points[0]?.xValue - const heading = - date instanceof Date ? date.toLocaleDateString() : String(date ?? '') - - return [ - heading, - ...points.map( - (point) => `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, - ), - ].join('\n') +const groupedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + formatGroup(points) { + const date = points[0]?.xValue + const heading = + date instanceof Date ? date.toLocaleDateString() : String(date ?? '') + + return [ + heading, + ...points.map( + (point) => + `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, + ), + ].join('\n') + }, }, -} +}) ``` Formatting precedence is `content`, `formatGroup`, `format`, then the automatic @@ -146,21 +154,23 @@ Point anchoring is the stable default for scatterplots, bars, and keyboard navigation: ```ts -const tooltip = { - anchor: 'point', - placement: 'top', -} +const pointDefinition = defineChart(definition, { + tooltip: { use: tooltip, anchor: 'point', placement: 'top' }, +}) ``` Pointer anchoring is useful when a dense mark has a large hit region. Keyboard focus falls back to the primary point: ```ts -const tooltip = { - anchor: 'pointer', - placement: ['right', 'left', 'bottom', 'top'], - offset: 12, -} +const pointerDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: 'pointer', + placement: ['right', 'left', 'bottom', 'top'], + offset: 12, + }, +}) ``` Grouped charts can avoid jumping between series by anchoring to the focused @@ -173,6 +183,7 @@ const definition = defineChart({ y, focus: 'group-x', tooltip: { + use: tooltip, anchor: 'group-center', placement: ['top', 'right', 'left', 'bottom'], sort: 'color-domain', @@ -184,13 +195,16 @@ A custom resolver covers event ranges, maps, and application-specific reference positions: ```ts -const tooltip = { - anchor: (_points, { chart }) => ({ - x: chart.x + chart.width, - y: chart.y, - }), - placement: 'bottom-left', -} +const customAnchorDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: (_points, { chart }) => ({ + x: chart.x + chart.width, + y: chart.y, + }), + placement: 'bottom-left', + }, +}) ``` Resolvers and placement use scene pixels. A nullish or non-finite custom @@ -203,27 +217,30 @@ surface. Keep tooltip layering with the chart definition: ```ts +import { portal } from '@tanstack/charts/tooltip/portal' + const definition = defineChart({ marks, x, y, focus: 'group-x', tooltip: { - portal: true, + use: tooltip, + portal, anchor: 'group-center', placement: ['right', 'left', 'bottom', 'top'], }, }) ``` -`portal: true` opens the tooltip as a manual Popover in the browser top layer -where supported. It remains a DOM descendant of the chart, so inherited styles, -ancestor selectors, and chart-scoped CSS custom properties continue to work. -If Popover is unavailable or fails, the host moves the tooltip directly under -the chart's `ownerDocument` body with fixed high-stack positioning. Both paths -escape `overflow: hidden` and local stacking contexts, use viewport collision -bounds, and reposition after scroll, viewport resize, or content resize. The -default `false` keeps ordinary absolute positioning inside the chart. +The `portal` extension opens the tooltip as a manual Popover in the browser top +layer where supported. It remains a DOM descendant of the chart, so inherited +styles, ancestor selectors, and chart-scoped CSS custom properties continue to +work. If Popover is unavailable or fails, the host moves the tooltip directly +under the chart's `ownerDocument` body with fixed high-stack positioning. Both +paths escape `overflow: hidden` and local stacking contexts, use viewport +collision bounds, and reposition after scroll, viewport resize, or content +resize. Omitting `portal` keeps ordinary absolute positioning inside the chart. For consistent fallback styling, target `tooltip.className` from a document-level stylesheet and define required CSS custom properties on that @@ -414,8 +431,8 @@ search over every raw point. - Choose two-dimensional, nearest-axis, or grouped-axis semantics explicitly. - Keep a finite distance unless continuous snapping is intended. - Use native plaintext formatting for the 90% case. -- Use `portal: true` where clipped ancestors or stacking contexts can hide the - tooltip. +- Use the `portal` extension where clipped ancestors or stacking contexts can + hide the tooltip. - Use the adapter's tooltip-body composition surface for framework content; use focus callbacks for separate application-owned surfaces. - Give keyboard and pointer users equivalent state and selection. diff --git a/docs/installation.md b/docs/installation.md index 0d062c3..83c6189 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -93,6 +93,21 @@ pnpm add -D @types/d3-geo @types/d3-quadtree @types/d3-delaunay @types/d3-select Do not install the `d3` umbrella package just because a chart uses one D3 capability. Named modules keep ownership visible and make the measured consumer bundle reflect the chart that was actually authored. [Scales and D3](./concepts/scales-and-d3.md) is the single guide to this boundary and links to the corresponding official D3 documentation. +For the common numeric and categorical subset, the smaller scale package is an +alternative to `d3-scale`: + +```sh +pnpm add @tanstack/charts-scales +``` + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' +``` + +Use its exact `/linear`, `/band`, `/point`, or `/ordinal` entry. The package +has no root export. Install `d3-scale` instead when the chart needs temporal, +logarithmic, piecewise, color-interpolating, or full D3 formatting semantics. + ## Package-manager examples The core plus a common scale-and-array setup: @@ -143,6 +158,8 @@ Optional capabilities have explicit entries: import { d3Curve } from '@tanstack/charts/d3/shape' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' import { mountCanvasChart } from '@tanstack/charts/canvas' import { mountChartRenderer } from '@tanstack/charts/renderer' import { polar, radialArc } from '@tanstack/charts/polar' diff --git a/docs/overview.md b/docs/overview.md index 2b6c0b4..78dcb48 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -142,9 +142,9 @@ The normal path is intentionally short: - Omit `width` to follow the chart container. - 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. +- Add the `tooltip` extension when a native value tooltip is enough. - Let built-in marks infer stable identity from IDs or unique positions; supply - `key` when that identity is unavailable or can change. + a stable `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 05b1613..4cd9dfc 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -22,6 +22,7 @@ The host follows the container width when `width` is omitted. ```ts import { scaleLinear, scaleUtc } from 'd3-scale' import { defineChart, lineY, mountChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' interface ClosingPrice { Date: Date @@ -58,7 +59,7 @@ const closingPriceChart = defineChart({ label: 'Close (USD)', grid: true, }, - tooltip: true, + tooltip, }) ``` diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index 0a16263..f535a0b 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -7,6 +7,8 @@ description: Reference static and responsive chart definitions, build context, a ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' ``` `defineChart` accepts a complete chart spec, a responsive configuration, or an @@ -42,6 +44,8 @@ const definition = defineChart({ y: { scale: scaleLinear, nice: true, grid: true }, focus: 'group-x', tooltip: { + use: tooltip, + portal, anchor: 'group-center', placement: ['top', 'right', 'left', 'bottom'], }, @@ -87,7 +91,7 @@ does not return or own them. These options belong to both static and responsive definitions. Hosts and framework adapters do not override them. -Tooltip placement policy stays with the definition, including `portal: true` +Tooltip placement policy stays with the definition. Add the `portal` extension when the surface must escape clipped chart ancestors. Framework-only content composition remains an adapter prop, slot, snippet, or template. diff --git a/docs/reference/dom-host.md b/docs/reference/dom-host.md index 0edf825..c8beb75 100644 --- a/docs/reference/dom-host.md +++ b/docs/reference/dom-host.md @@ -10,6 +10,7 @@ pointer and keyboard interaction, native tooltips, font relayout, and cleanup. ```ts import { defineChart } from '@tanstack/charts' import { mountChart } from '@tanstack/charts/dom' +import { tooltip } from '@tanstack/charts/tooltip' ``` ## Signature @@ -34,7 +35,7 @@ also destroys that runtime. In ordinary vanilla use, omit it. ```ts const options = { - definition: defineChart(definition, { tooltip: true }), + definition: defineChart(definition, { tooltip }), height: 320, ariaLabel: 'Weekly revenue', } @@ -70,7 +71,7 @@ import { canvasChartRenderer } from '@tanstack/charts/canvas' import { mountChartRenderer } from '@tanstack/charts/renderer' const host = mountChartRenderer(container, { - definition: defineChart(definition, { tooltip: true }), + definition: defineChart(definition, { tooltip }), renderer: canvasChartRenderer, ariaLabel: 'Weekly revenue', }) @@ -156,11 +157,11 @@ immediately by default; set `animate.resize` to `true` to animate it. The host temporarily assigns `position: relative` when the container's computed position is static, because local native tooltips are absolutely -positioned inside it. Definition `tooltip.portal: true` instead places the -tooltip in the browser top layer through a manual Popover, or directly under -the `ownerDocument` body as a fixed fallback, and positions it against the -viewport. `destroy` restores the previous inline position when the host owned -that change and removes its tooltip. +positioned inside it. Adding the `portal` extension to the tooltip options +instead places the tooltip in the browser top layer through a manual Popover, +or directly under the `ownerDocument` body as a fixed fallback, and positions +it against the viewport. `destroy` restores the previous inline position when +the host owned that change and removes its tooltip. ## Font measurement diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 221c283..87e03b8 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -32,9 +32,11 @@ normal tab-order participation while keeping keyboard handling enabled. Set Use a preset for built-in focus behavior: ```ts +import { tooltip } from '@tanstack/charts/tooltip' + const groupedDownloads = defineChart(definition, { focus: 'group-x', - tooltip: true, + tooltip, }) ``` @@ -100,10 +102,11 @@ keyboard navigation. `navigation` returns the ordered keyboard task set. ## Tooltips -Set `tooltip: true` for the native accessible tooltip. The default is a -structured label-value table. Grouped focus adds a shared-axis heading and one -color swatch and value row per series. Visible axis labels are reused. Numbers -use browser locale formatting; dates use stable UTC ISO formatting. +Import `tooltip` from `@tanstack/charts/tooltip` and place it on the definition +for the native accessible tooltip. The default is a structured label-value +table. Grouped focus adds a shared-axis heading and one color swatch and value +row per series. Visible axis labels are reused. Numbers use browser locale +formatting; dates use stable UTC ISO formatting. ```ts interface ChartTooltipOptions< @@ -112,7 +115,7 @@ interface ChartTooltipOptions< TYValue extends ChartValue = ChartValue, > { className?: string - portal?: boolean + portal?: ChartTooltipPortalInput items?: readonly ChartTooltipItem[] sort?: ChartTooltipSort anchor?: ChartTooltipAnchor @@ -130,19 +133,19 @@ interface ChartTooltipOptions< } ``` -| Option | Default | Meaning | -| ------------- | -------------- | ------------------------------------------------------- | -| `className` | None | Class appended after `ts-chart-tooltip` | -| `portal` | `false` | Escapes clipping through top-layer or fixed positioning | -| `items` | Automatic x/y | Ordered rows for a single focused point | -| `sort` | `color-domain` | Grouped row order | -| `anchor` | `point` | Point, pointer, group center, or coordinate resolver | -| `placement` | `auto` | Fixed or ordered fallback box placements | -| `offset` | `10` | Scene-pixel gap between anchor and box | -| `content` | Automatic rows | Returns a safe title and structured rows | -| `format` | None | Replaces content with primary-point text | -| `formatGroup` | None | Replaces content with focused-group text | -| `sticky` | `true` | Enables activation-to-pin and text selection | +| Option | Default | Meaning | +| ------------- | -------------- | ---------------------------------------------------- | +| `className` | None | Class appended after `ts-chart-tooltip` | +| `portal` | None | Optional top-layer or fixed-position transport | +| `items` | Automatic x/y | Ordered rows for a single focused point | +| `sort` | `color-domain` | Grouped row order | +| `anchor` | `point` | Point, pointer, group center, or coordinate resolver | +| `placement` | `auto` | Fixed or ordered fallback box placements | +| `offset` | `10` | Scene-pixel gap between anchor and box | +| `content` | Automatic rows | Returns a safe title and structured rows | +| `format` | None | Replaces content with primary-point text | +| `formatGroup` | None | Replaces content with focused-group text | +| `sticky` | `true` | Enables activation-to-pin and text selection | Formatting precedence is `content`, `formatGroup`, `format`, then the default. The text formatters do not parse HTML, and newlines are preserved. `className` @@ -154,28 +157,31 @@ is appended to `ts-chart-tooltip`. shorthands, a configured channel, a scalar datum field, or derived text: ```ts -const tooltip = { - items: [ - { - channel: 'y', - label: 'Revenue', - text: (point) => currency(point.yValue), - }, - { - field: 'volume', - label: 'Volume', - text: (point) => compact(point.datum.volume), - }, - { - id: 'change', - label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), - }, - 'x', - 'group', - ], -} +const detailedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + items: [ + { + channel: 'y', + label: 'Revenue', + text: (point) => currency(point.yValue), + }, + { + field: 'volume', + label: 'Volume', + text: (point) => compact(point.datum.volume), + }, + { + id: 'change', + label: 'Change', + text: (point) => + point.datum.change == null ? null : percent(point.datum.change), + }, + 'x', + 'group', + ], + }, +}) ``` Array order is row order. A nullish datum field or nullish `text` result omits @@ -207,20 +213,25 @@ it uses the least-overflowing candidate and shifts it inside. `auto` uses `top`, `bottom`, `right`, then `left`. ```ts -const tooltip = { - anchor: 'group-center', - placement: ['top', 'right', 'left', 'bottom'], - offset: 12, -} +const groupedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: 'group-center', + placement: ['top', 'right', 'left', 'bottom'], + offset: 12, + }, +}) ``` -Set `portal: true` when an ancestor clips overflow or creates an incompatible -stacking context. The host opens the tooltip as a manual Popover in the browser -top layer where supported, while retaining its chart DOM ancestry. If Popover -is unavailable or fails, it moves the tooltip directly under the chart's -`ownerDocument` body with fixed high-stack positioning. Both paths map the -scene anchor to viewport coordinates, reposition during scroll, resize, and -content resize, and collide against the viewport instead of the chart box. +Import `portal` from `@tanstack/charts/tooltip/portal` and assign it to the +tooltip's `portal` option when an ancestor clips overflow or creates an +incompatible stacking context. The host opens the tooltip as a manual Popover +in the browser top layer where supported, while retaining its chart DOM +ancestry. If Popover is unavailable or fails, it moves the tooltip directly +under the chart's `ownerDocument` body with fixed high-stack positioning. Both +paths map the scene anchor to viewport coordinates, reposition during scroll, +resize, and content resize, and collide against the viewport instead of the +chart box. Clicking, Enter, or Space pins the tooltip. The next activation unpins it. `Escape` unpins and clears focus. Set `sticky: false` to disable pinning. A diff --git a/docs/reference/rendering-and-export.md b/docs/reference/rendering-and-export.md index b664eb8..843a66a 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -86,8 +86,9 @@ Scene keys become `data-ts-key` attributes for reconciliation. ```ts import { mountCanvasChart } from '@tanstack/charts/canvas' +import { tooltip } from '@tanstack/charts/tooltip' -const interactiveDefinition = defineChart(definition, { tooltip: true }) +const interactiveDefinition = defineChart(definition, { tooltip }) const host = mountCanvasChart(container, { definition: interactiveDefinition, diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index f0461fc..1621520 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -12,6 +12,37 @@ For the architectural boundary and guidance on selecting granular D3 modules, read [Scales and D3](../concepts/scales-and-d3.md). This page documents only the TanStack Charts contract around those primitives. +## Compact scale package + +`@tanstack/charts-scales` supplies four exact, tree-shakeable entries: + +| Entry | Runtime export | Type contract | +| --------------------------------- | -------------- | -------------- | +| `@tanstack/charts-scales/linear` | `scaleLinear` | `LinearScale` | +| `@tanstack/charts-scales/band` | `scaleBand` | `BandScale` | +| `@tanstack/charts-scales/point` | `scalePoint` | `PointScale` | +| `@tanstack/charts-scales/ordinal` | `scaleOrdinal` | `OrdinalScale` | + +`LinearScale` supports numeric two-stop domains and ranges, `invert`, `clamp`, +`ticks`, basic `tickFormat`, `nice`, and `copy`. `BandScale` supports +`padding`, `paddingInner`, `paddingOuter`, `align`, `round`, `rangeRound`, +`bandwidth`, `step`, and `copy`. `PointScale` exposes the corresponding point +operations with zero bandwidth. `OrdinalScale` supports explicit or implicit +domains, cyclic ranges, `unknown`, and `copy`. + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' + +const y = { + scale: scaleLinear().domain([0, 100]), +} +``` + +These factories satisfy `ConfiguredScaleLike` and `ChartScaleInput` directly. +They are a documented subset, not a complete `d3-scale` compatibility claim. +Use D3 for temporal or transformed domains, piecewise and nonnumeric +interpolation, and full formatting semantics. + ## Positional scale factories The common path passes the D3 factory directly: diff --git a/docs/reference/types.md b/docs/reference/types.md index ae2b33a..fdc7ccc 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -200,31 +200,43 @@ See [DOM host](./dom-host.md) and ## Focus and tooltip types -| Type | Purpose | -| ---------------------------- | -------------------------------------------------------- | -| `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | -| `ChartFocusPreset` | Built-in nearest and grouped axis focus names | -| `ChartFocusMode` | Focus preset or custom strategy | -| `ChartSpatialIndex` | Nearest-point query | -| `ChartSpatialIndexFactory` | Builds an index from current scene points | -| `ChartTooltipOptions` | Native tooltip content, ordering, anchoring, and pinning | -| `ChartTooltipItem` | Ordered channel, datum-field, or derived point row | -| `ChartTooltipItemBase` | Shared label and point-text contract for object items | -| `ChartTooltipChannelItem` | Configured x, y, or group row | -| `ChartTooltipDatumItem` | Scalar datum-field row | -| `ChartTooltipDerivedItem` | Row derived from the complete focused point | -| `ChartTooltipSort` | Group row ordering | -| `ChartTooltipAnchor` | Point, pointer, group-center, or custom scene anchor | -| `ChartTooltipAnchorContext` | Pointer position, chart bounds, and surface size | -| `ChartTooltipPlacement` | Tooltip box placement around its anchor | -| `ChartTooltipPosition` | Scene-pixel x/y coordinate | -| `ChartDefinitionOptions` | Focus, tooltip, animation, keyboard, and spatial policy | -| `DynamicChartConfig` | Responsive builder plus definition-owned behavior | -| `ChartTooltipContent` | Safe title and row model for a native tooltip | -| `ChartTooltipRow` | Label, formatted value, and optional color swatch | -| `ChartTooltipContentContext` | Axis labels and value formatters for content callbacks | -| `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | -| `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | +| Type | Purpose | +| ------------------------------------- | ---------------------------------------------------------------------- | +| `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | +| `ChartFocusPreset` | Built-in nearest and grouped axis focus names | +| `ChartFocusMode` | Focus preset or custom strategy | +| `ChartSpatialIndex` | Nearest-point query | +| `ChartSpatialIndexFactory` | Builds an index from current scene points | +| `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | +| `ChartTooltipInput` | Tooltip extension token or configured extension options | +| `ChartTooltipExtension` | Tooltip lifecycle implementation | +| `ChartTooltipExtensionContext` | Container, dismissal, and adapter-body bridge given to a tooltip | +| `ChartTooltipExtensionInstance` | Tooltip update, paint, hide, containment, and destroy lifecycle | +| `ChartTooltipPaintContext` | Focused points, scene, surface, pointer, and pinned state | +| `ChartTooltipOptions` | Native tooltip content, ordering, anchoring, and pinning | +| `ChartTooltipPortalInput` | Portal extension token or configured transport options | +| `ChartTooltipPortalExtension` | Tooltip transport lifecycle implementation | +| `ChartTooltipPortalExtensionContext` | Container, tooltip element, and reposition callback given to a portal | +| `ChartTooltipPortalExtensionInstance` | Portal update, position, hide, and destroy lifecycle | +| `ChartTooltipPortalOptions` | Reserved configuration object for portal extensions | +| `ChartTooltipPortalPositionContext` | Scene, surface, anchor, placement, and offset for viewport positioning | +| `ChartTooltipItem` | Ordered channel, datum-field, or derived point row | +| `ChartTooltipItemBase` | Shared label and point-text contract for object items | +| `ChartTooltipChannelItem` | Configured x, y, or group row | +| `ChartTooltipDatumItem` | Scalar datum-field row | +| `ChartTooltipDerivedItem` | Row derived from the complete focused point | +| `ChartTooltipSort` | Group row ordering | +| `ChartTooltipAnchor` | Point, pointer, group-center, or custom scene anchor | +| `ChartTooltipAnchorContext` | Pointer position, chart bounds, and surface size | +| `ChartTooltipPlacement` | Tooltip box placement around its anchor | +| `ChartTooltipPosition` | Scene-pixel x/y coordinate | +| `ChartDefinitionOptions` | Focus, tooltip, animation, keyboard, and spatial policy | +| `DynamicChartConfig` | Responsive builder plus definition-owned behavior | +| `ChartTooltipContent` | Safe title and row model for a native tooltip | +| `ChartTooltipRow` | Label, formatted value, and optional color swatch | +| `ChartTooltipContentContext` | Axis labels and value formatters for content callbacks | +| `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | +| `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | See [Focus and interaction](./focus-and-interaction.md). diff --git a/examples/charts-octane/src/App.tsrx b/examples/charts-octane/src/App.tsrx index e36f83a..6c0835a 100644 --- a/examples/charts-octane/src/App.tsrx +++ b/examples/charts-octane/src/App.tsrx @@ -7,6 +7,7 @@ import { } from './charts' import type { IndustriesRow, IndustryHistoryMode } from './charts' import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/octane-charts' import type { ChartPoint } from '@tanstack/octane-charts' @@ -31,7 +32,7 @@ export function App() { { animate: { duration: 500, easing: 'ease-out' }, focus: 'group-x', - tooltip: { formatGroup: formatHistoryGroup }, + tooltip: { use: tooltip, formatGroup: formatHistoryGroup }, }, ), ) @@ -41,6 +42,7 @@ export function App() { { animate: { duration: 500, easing: 'ease-in-out' }, tooltip: { + use: tooltip, format: (point) => `${point.yValue}: ${point.xValue.toLocaleString()}`, }, diff --git a/examples/charts-react/src/App.tsx b/examples/charts-react/src/App.tsx index a157512..1caa83f 100644 --- a/examples/charts-react/src/App.tsx +++ b/examples/charts-react/src/App.tsx @@ -16,10 +16,12 @@ import { type PenguinCount, } from './charts' import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart, type ChartPoint } from '@tanstack/react-charts' const interactiveDownloadsChart = defineChart(downloadsChart, { tooltip: { + use: tooltip, placement: ['top', 'right', 'left', 'bottom'], items: [ { @@ -33,12 +35,13 @@ const interactiveDownloadsChart = defineChart(downloadsChart, { }) const interactiveHorsepowerChart = defineChart(horsepowerChart, { tooltip: { + use: tooltip, format: (point) => `${point.datum.x0?.toFixed(0)}–${point.datum.x1?.toFixed(0)} hp · ${point.datum.length} cars`, }, }) const interactiveDownloadAreaChart = defineChart(downloadAreaChart, { - tooltip: true, + tooltip, }) export function App() { @@ -66,6 +69,7 @@ export function App() { { animate: { duration: 420, easing: 'ease-in-out' }, tooltip: { + use: tooltip, format: (point) => `${point.yValue}: ${point.xValue.toLocaleString()}`, }, @@ -80,7 +84,7 @@ export function App() { { animate: { duration: 500, easing: 'ease-out' }, focus: 'group-x', - tooltip: { formatGroup: formatIndustryGroup }, + tooltip: { use: tooltip, formatGroup: formatIndustryGroup }, }, ), [historyMode, historyZoomed, industryWindow], @@ -90,7 +94,7 @@ export function App() { defineChart(createPenguinChart(barOrientation, barsStacked), { animate: { duration: 500, easing: 'ease-out' }, focus: barOrientation === 'vertical' ? 'group-x' : 'group-y', - tooltip: { format: formatPenguinPoint }, + tooltip: { use: tooltip, format: formatPenguinPoint }, }), [barOrientation, barsStacked], ) diff --git a/examples/sandbox/src/App.tsx b/examples/sandbox/src/App.tsx index 06174a4..9786f91 100644 --- a/examples/sandbox/src/App.tsx +++ b/examples/sandbox/src/App.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' import { defineChart, type ChartPoint } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/react-charts' import { createDashboardData, @@ -53,6 +54,7 @@ export function App() { { focus: 'group-x', tooltip: { + use: tooltip, className: 'obsidian-tooltip', sticky: true, formatGroup: formatIndustryGroup, @@ -62,6 +64,7 @@ export function App() { ), ratings: defineChart(createRatingsHeatmap({ rows: data.simpsons }), { tooltip: { + use: tooltip, className: 'obsidian-tooltip', format: (point) => `S${point.datum.season} E${point.datum.number_in_season} · ${point.datum.title}\nIMDb ${point.datum.imdb_rating?.toFixed(1)}`, @@ -70,6 +73,7 @@ export function App() { }), cars: defineChart(createCarEconomyChart({ rows: data.carEconomy }), { tooltip: { + use: tooltip, className: 'obsidian-tooltip', format: (point) => `${point.datum.cylinders} cylinders\n${point.datum.economy.toFixed(1)} mpg mean`, @@ -80,6 +84,7 @@ export function App() { createSurveyStackChart({ rows: data.surveyStack }), { tooltip: { + use: tooltip, className: 'obsidian-tooltip', format: (point) => `${point.datum.Question} · ${point.datum.Response}\n${point.datum.count} responses`, @@ -91,6 +96,7 @@ export function App() { createSurveyWaffleChart({ rows: data.surveyCells }), { tooltip: { + use: tooltip, className: 'obsidian-tooltip', format: (point) => `${point.datum.Question} · ${point.datum.Response}`, @@ -160,6 +166,7 @@ export function App() { }), { tooltip: { + use: tooltip, className: 'obsidian-tooltip', sticky: true, format: (point) => diff --git a/llms.txt b/llms.txt index 9761b94..4c432c4 100644 --- a/llms.txt +++ b/llms.txt @@ -88,8 +88,8 @@ 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. D3 factories infer domains from mark channels; configured instances preserve application-owned domains. +- Use exact @tanstack/charts-scales/* entries for compact linear, band, point, or ordinal scales. Use direct, granular d3-* imports when full D3 semantics are required; never import the d3 umbrella. +- Let TanStack Charts own responsive pixel ranges. Scale 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. - Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. diff --git a/package.json b/package.json index 7db1474..39392f0 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tanstack/charts": "workspace:*", "@tanstack/charts-d3": "workspace:*", + "@tanstack/charts-scales": "workspace:*", "@tanstack/octane-charts": "workspace:*", "@tanstack/react-charts": "workspace:*", "@types/d3-array": "^3.2.2", diff --git a/packages/alpine-charts/src/index.test.ts b/packages/alpine-charts/src/index.test.ts index 2faac56..f8a4f3a 100644 --- a/packages/alpine-charts/src/index.test.ts +++ b/packages/alpine-charts/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { defineChart, lineY } from '@tanstack/charts' import type { ChartTooltipContent } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { scaleLinear } from 'd3-scale' import { charts } from './index' import type { ChartOptions } from './index' @@ -101,6 +102,7 @@ describe('Alpine adapter', () => { const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { + use: tooltip, content: () => ({ title: 'January', rows: [{ label: 'Revenue', value: '$8', color: '#2563eb' }], diff --git a/packages/angular-charts/README.md b/packages/angular-charts/README.md index c15eb30..d061202 100644 --- a/packages/angular-charts/README.md +++ b/packages/angular-charts/README.md @@ -8,6 +8,7 @@ pnpm add @tanstack/charts @tanstack/angular-charts @angular/core @angular/platfo ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/angular-charts' @Component({ @@ -16,7 +17,7 @@ import { Chart } from '@tanstack/angular-charts' }) export class RevenueChart { chartOptions = { - definition: defineChart(definition, { tooltip: true }), + definition: defineChart(definition, { tooltip }), ariaLabel: 'Revenue by month', } } diff --git a/packages/angular-charts/src/Chart.test.ts b/packages/angular-charts/src/Chart.test.ts index e3b3312..d5ea738 100644 --- a/packages/angular-charts/src/Chart.test.ts +++ b/packages/angular-charts/src/Chart.test.ts @@ -15,6 +15,8 @@ import { import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { defineChart, lineY } from '@tanstack/charts' import type { ChartTooltipContent } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from 'd3-scale' import { Chart, ChartTooltipBodyDirective } from './index' import type { @@ -36,7 +38,8 @@ const definition = defineChart({ const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltip, + portal, content: () => ({ title: 'First', color: '#2563eb', diff --git a/packages/charts-core/README.md b/packages/charts-core/README.md index 4fbe663..660df73 100644 --- a/packages/charts-core/README.md +++ b/packages/charts-core/README.md @@ -27,6 +27,7 @@ Omit any D3 module and matching type package that your chart does not use. import { scaleLinear, scaleOrdinal, scaleUtc } from 'd3-scale' import { curveMonotoneX } from 'd3-shape' import { colorLegend, d3Curve, defineChart, lineY } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' interface DownloadRow { id: string @@ -78,7 +79,7 @@ const downloads = defineChart({ legend: colorLegend({ label: 'Package' }), }, animate: true, - tooltip: true, + tooltip, }) ``` diff --git a/packages/charts-core/docs/concepts/chart-definitions.md b/packages/charts-core/docs/concepts/chart-definitions.md index 462a1a1..9d582a5 100644 --- a/packages/charts-core/docs/concepts/chart-definitions.md +++ b/packages/charts-core/docs/concepts/chart-definitions.md @@ -50,8 +50,10 @@ Pass a builder when tick density, annotations, or mark composition depends on the chart surface: ```ts +import { tooltip } from '@tanstack/charts/tooltip' + const productRanking = defineChart({ - tooltip: true, + tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], x: { @@ -95,12 +97,14 @@ concerns. Definitions capture the values they use. In React: ```tsx +import { tooltip } from '@tanstack/charts/tooltip' + function ProductRanking({ rows, metric }: Props) { const definition = useMemo(() => { const ranked = rankProducts(rows, metric) return defineChart({ - tooltip: true, + tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], x: { diff --git a/packages/charts-core/docs/concepts/scales-and-d3.md b/packages/charts-core/docs/concepts/scales-and-d3.md index 9230802..cd7803d 100644 --- a/packages/charts-core/docs/concepts/scales-and-d3.md +++ b/packages/charts-core/docs/concepts/scales-and-d3.md @@ -3,13 +3,15 @@ title: Scales and D3 description: Understand the explicit boundary between TanStack Charts and granular D3 scales, transforms, shapes, and interaction algorithms. --- -TanStack Charts uses D3 as an explicit algorithm layer: +TanStack Charts uses an explicit algorithm layer: - **Your application** imports and configures the D3 modules required by a chart. - **D3** supplies battle-tested scales, array transforms, shape interpolation, time utilities, and spatial algorithms. +- **TanStack compact scales** cover the common numeric and categorical scale subset when bundle size matters more than D3's complete semantics. - **TanStack Charts** supplies the typed grammar, responsive ranges, guide layout, scene compilation, rendering, reconciliation, and lifecycle. -There is no second scale or transform language to learn. There is also no hidden D3 umbrella import. +Both scale implementations use callable, copyable scale objects. There is no +hidden D3 umbrella import. ## Direct dependency ownership @@ -26,6 +28,37 @@ This rule also applies when definitions live in framework component source. The adapter mounts a definition; it does not own the D3 imports used to author it. +## Compact scales + +For numeric linear, band, point, and ordinal mappings, install the optional +compact package: + +```sh +pnpm add @tanstack/charts-scales +``` + +Import one exact family: + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' +import { scaleBand } from '@tanstack/charts-scales/band' +import { scalePoint } from '@tanstack/charts-scales/point' +import { scaleOrdinal } from '@tanstack/charts-scales/ordinal' +``` + +There is intentionally no `@tanstack/charts-scales` root export. Each factory +fits the same callable, `domain`, `range`, and `copy` contract consumed by +TanStack Charts. + +The compact linear scale is numeric and two-stop. It supports mapping, +`invert`, `clamp`, `nice`, ticks, basic numeric tick formatting, and copying. +The categorical families support D3-compatible domain interning, padding, +alignment, rounding, bandwidth, unknown values, and copying. + +Use `d3-scale` for time, UTC, log, power, symlog, radial, sequential, +diverging, quantile, quantize, and threshold scales; piecewise or nonnumeric +interpolation; locale-aware format specifiers; and other full D3 behavior. + ## Capability map Use the official D3 pages as the API reference for each algorithm. TanStack Charts documentation only describes how its output crosses the chart boundary. diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index 5852347..81cd3ea 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -48,9 +48,10 @@ in both transient and pinned states. The transient body is inert; gate controls on `pinned`. The nested chart receives its own accessible label, definition, runtime, and framework cleanup. -Set definition `tooltip.portal: true` to escape clipped ancestors and use -viewport collision handling. Move focus intentionally when the body contains -controls, preserve Escape, and wire a close button to `dismiss`. +Add the `portal` extension to the definition's tooltip options to escape +clipped ancestors and use viewport collision handling. Move focus intentionally +when the body contains controls, preserve Escape, and wire a close button to +`dismiss`. ## Scroll a wide schedule diff --git a/packages/charts-core/docs/framework/angular/adapter.md b/packages/charts-core/docs/framework/angular/adapter.md index cc45591..57071e6 100644 --- a/packages/charts-core/docs/framework/angular/adapter.md +++ b/packages/charts-core/docs/framework/angular/adapter.md @@ -11,6 +11,7 @@ pnpm add @tanstack/charts @tanstack/angular-charts @angular/common @angular/core import { Component } from '@angular/core' import { Chart } from '@tanstack/angular-charts' import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' @Component({ imports: [Chart], @@ -18,7 +19,7 @@ import { defineChart } from '@tanstack/charts' }) export class RevenueChart { chartOptions = { - definition: defineChart(createRevenueChart(rows), { tooltip: true }), + definition: defineChart(createRevenueChart(rows), { tooltip }), ariaLabel: 'Revenue by month', aspectRatio: 16 / 9, } diff --git a/packages/charts-core/docs/framework/lit/adapter.md b/packages/charts-core/docs/framework/lit/adapter.md index 9921ef8..affb7ca 100644 --- a/packages/charts-core/docs/framework/lit/adapter.md +++ b/packages/charts-core/docs/framework/lit/adapter.md @@ -19,10 +19,11 @@ Pass chart options as a property: ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' html`` diff --git a/packages/charts-core/docs/framework/octane/quick-start.md b/packages/charts-core/docs/framework/octane/quick-start.md index 410df5e..ea954b1 100644 --- a/packages/charts-core/docs/framework/octane/quick-start.md +++ b/packages/charts-core/docs/framework/octane/quick-start.md @@ -23,6 +23,7 @@ Definitions are framework-independent and can be shared with any adapter: ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/octane-charts' interface AlphabetRow { @@ -60,7 +61,7 @@ const letterFrequencyChart = defineChart({ format: (value) => percent.format(value), grid: true, }, - tooltip: true, + tooltip, }) export function LetterFrequencyChart() { @@ -132,7 +133,7 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { nice: true, }, animate: true, - tooltip: true, + tooltip, }) }) diff --git a/packages/charts-core/docs/framework/preact/adapter.md b/packages/charts-core/docs/framework/preact/adapter.md index 9be5de6..1f150c9 100644 --- a/packages/charts-core/docs/framework/preact/adapter.md +++ b/packages/charts-core/docs/framework/preact/adapter.md @@ -11,11 +11,12 @@ pnpm add @tanstack/charts @tanstack/preact-charts preact d3-scale ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/preact-charts' export function RevenueChart() { const definition = useMemo( - () => defineChart(createRevenueChart(rows), { tooltip: true }), + () => defineChart(createRevenueChart(rows), { tooltip }), [rows], ) diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 7e932a5..a34a074 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -17,7 +17,6 @@ export type { ChartProps, ChartDefinition, ChartPoint, - ChartTooltipBodyRenderContext, } from '@tanstack/react-charts' ``` @@ -33,6 +32,24 @@ import { Chart as RendererChart } from '@tanstack/react-charts/core' a `renderer` prop. The default `Chart` remains SVG-based, so importing the default adapter does not pull Canvas into its module graph. +The base entries render the native tooltip without the React tooltip-body +bridge. Import from the optional tooltip entry when passing +`renderTooltipBody`: + +```tsx +import { + Chart, + CanvasChart, + RendererChart, + type ChartTooltipBodyRenderContext, +} from '@tanstack/react-charts/tooltip' +``` + +Existing `renderTooltipBody` users should move the default component import +from `@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. For Canvas +or an application renderer, replace the aliased `Chart` import from `/canvas` +or `/core` with `CanvasChart` or `RendererChart` from `/tooltip`. + Use the re-exported definition and point types only when an application API needs an explicit annotation. Ordinary component use is inferred. @@ -139,17 +156,19 @@ The React adapter's `className` intentionally owns the outer element instead. ## Tooltip body composition -`renderTooltipBody` mounts React content into the native tooltip surface. Its -context provides `points`, `content`, `defaultBody`, `pinned`, and `dismiss`. -Composing `defaultBody` keeps the core title, rows, formatting, and swatches; -arbitrary React content can sit beside it. +The components from `@tanstack/react-charts/tooltip` accept +`renderTooltipBody`, which mounts React content into the native tooltip +surface. Its context provides `points`, `content`, `defaultBody`, `pinned`, +and `dismiss`. Composing `defaultBody` keeps the core title, rows, formatting, +and swatches; arbitrary React content can sit beside it. The shared host owns a stable body mount target and releases it when the tooltip is hidden, custom rendering is disabled, or the chart is destroyed. React owns the rendered component lifecycle. A custom body is inert while 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. +render only while `pinned` is true. Adding the `portal` extension to the +tooltip options promotes the whole surface above clipped ancestors without +changing this React API. ## Definition identity diff --git a/packages/charts-core/docs/framework/react/quick-start.md b/packages/charts-core/docs/framework/react/quick-start.md index 0d88e12..2b505b3 100644 --- a/packages/charts-core/docs/framework/react/quick-start.md +++ b/packages/charts-core/docs/framework/react/quick-start.md @@ -23,6 +23,7 @@ Definitions are ordinary framework-independent TypeScript: ```tsx import { scaleBand, scaleLinear } from 'd3-scale' import { barY, defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/react-charts' interface AlphabetRow { @@ -60,7 +61,7 @@ const letterFrequencyChart = defineChart({ format: (value) => percent.format(value), grid: true, }, - tooltip: true, + tooltip, }) export function LetterFrequencyChart() { @@ -135,7 +136,7 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { nice: true, }, animate: true, - tooltip: true, + tooltip, }) }, [rows, accent]) @@ -177,6 +178,32 @@ The native tooltip is optional. Grouped focus, formatting, keyboard behavior, and application-owned interaction are documented in [Focus and interaction](../../reference/focus-and-interaction.md). +## Render React tooltip content + +Keep `Chart` from `@tanstack/react-charts` when the native tooltip is enough. +To pass `renderTooltipBody`, switch the component import to the optional React +tooltip entry: + +```tsx +import { Chart } from '@tanstack/react-charts/tooltip' + +; ( + <> + {defaultBody} + {pinned ? : null} + + )} +/> +``` + +Existing `renderTooltipBody` users should migrate the component import from +`@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. The definition +still uses `tooltip` from `@tanstack/charts/tooltip`. + ## Example This catalog example uses multiple line layers and endpoint labels through the diff --git a/packages/charts-core/docs/framework/react/reference/chart.md b/packages/charts-core/docs/framework/react/reference/chart.md index 92faffd..0c19c43 100644 --- a/packages/charts-core/docs/framework/react/reference/chart.md +++ b/packages/charts-core/docs/framework/react/reference/chart.md @@ -44,6 +44,9 @@ The Canvas `Chart` accepts the same adapter props except `renderSvg`. Its surfaces. Both entries export `ChartCommonProps`, `ChartProps`, `ChartDefinition`, and `ChartPoint`. +These base entries render the native tooltip without React tooltip-body +composition. + ## Definition props | Prop | Default | Meaning | @@ -55,8 +58,9 @@ See [Chart Definition API](../../../reference/chart-definitions.md). The definition owns `focus`, `maxFocusDistance`, `spatialIndex`, `animate`, `keyboard`, and `tooltip`. Adapters do not override them. -Set definition `tooltip.portal: true` to escape clipping and local stacking -contexts. The adapter still receives no portal override. +Add the `portal` extension to the definition's tooltip options to escape +clipping and local stacking contexts. The adapter still receives no portal +override. ## Accessibility and sizing @@ -88,6 +92,17 @@ the behavior and complete callback values. ## Tooltip body +Import the drop-in component from the optional tooltip entry to use +`renderTooltipBody`: + +```tsx +import { + Chart, + CanvasChart, + RendererChart, +} from '@tanstack/react-charts/tooltip' +``` + | Prop | Type | Default | Meaning | | ------------------- | ------------------------------------------------------- | ------- | ------------------------------------------------ | | `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ReactNode` | None | Composes React content inside the native surface | @@ -112,9 +127,11 @@ interactive content only when `pinned` is true; transient tooltips do not accept pointer input. `dismiss()` clears the tooltip and restores chart focus when focus was inside its body. -The prop is available from the default, `/canvas`, and `/core` entries. It is -adapter presentation. Ordering, anchoring, placement, portaling, and sticky -behavior remain in the chart definition. +Existing users of this prop should move `Chart` from +`@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. Replace +`Chart as CanvasChart` from `/canvas` or `Chart as RendererChart` from `/core` +with the corresponding named component from `/tooltip`. Ordering, anchoring, +placement, portaling, and sticky behavior remain in the chart definition. ## Rendering and layout extensions @@ -129,7 +146,10 @@ See [Rendering and export](../../../reference/rendering-and-export.md) and ## Exported prop types -The adapter exports `ChartCommonProps`, `ChartProps`, and +The base entries export `ChartCommonProps` and `ChartProps`. The `/tooltip` +entry exports its extended `ChartCommonProps`, `ChartProps`, +`RendererChartCommonProps`, `RendererChartProps`, `CanvasChartCommonProps`, +`CanvasChartProps`, `ChartTooltipBodyRenderProps`, and `ChartTooltipBodyRenderContext`. ```ts diff --git a/packages/charts-core/docs/framework/solid/adapter.md b/packages/charts-core/docs/framework/solid/adapter.md index 937c7c2..dbf0ef8 100644 --- a/packages/charts-core/docs/framework/solid/adapter.md +++ b/packages/charts-core/docs/framework/solid/adapter.md @@ -9,10 +9,11 @@ pnpm add @tanstack/charts @tanstack/solid-charts solid-js d3-scale ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/solid-charts' const definition = createMemo(() => - defineChart(createRevenueChart(rows()), { tooltip: true }), + defineChart(createRevenueChart(rows()), { tooltip }), ) ; import { defineChart } from '@tanstack/charts' + import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/svelte-charts' const definition = $derived( - defineChart(createRevenueChart(rows), { tooltip: true }), + defineChart(createRevenueChart(rows), { tooltip }), ) diff --git a/packages/charts-core/docs/framework/vue/adapter.md b/packages/charts-core/docs/framework/vue/adapter.md index c914317..a00cf89 100644 --- a/packages/charts-core/docs/framework/vue/adapter.md +++ b/packages/charts-core/docs/framework/vue/adapter.md @@ -10,11 +10,12 @@ pnpm add @tanstack/charts @tanstack/vue-charts vue d3-scale ```vue 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 4673386..6096b60 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -24,6 +24,11 @@ import { mountChartRenderer } from '@tanstack/charts/renderer' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' import { d3Curve } from '@tanstack/charts/d3/shape' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' +import { scaleLinear } from '@tanstack/charts-scales/linear' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -49,6 +54,23 @@ boundary data and `topojson-client` remain application dependencies; importing Your bundler must honor ESM exports and tree shaking. Avoid namespace imports when a named or subpath import communicates the real dependency. +Tooltip rendering is also opt-in. A definition imports `tooltip`; viewport +layering additionally imports `portal` and nests it under the tooltip options: + +```ts +const interactive = defineChart(definition, { + tooltip: { + use: tooltip, + portal, + }, +}) +``` + +The locked compact React line consumer must remain at or below 15 kB gzip. +Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, +`d3-interpolate`, `d3-color`, and sibling compact-scale entries. Separate +incremental gates limit tooltip and portal growth. + ## Import D3 by capability TanStack Charts does not bundle a hidden analytical runtime. Import only the D3 diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index a916c59..3822336 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -155,16 +155,19 @@ Give it a class through `tooltip.className` and style that class in application CSS: ```ts -const tooltip = { - className: 'revenue-tooltip', -} +import { tooltip } from '@tanstack/charts/tooltip' + +const definition = defineChart(baseDefinition, { + tooltip: { use: tooltip, className: 'revenue-tooltip' }, +}) ``` -With `portal: true`, the preferred manual-Popover path keeps the element under -the chart in the DOM, so inheritance and scoped selectors continue to work. If -Popover is unavailable or fails, the fixed fallback moves the element under -the chart's `ownerDocument` body. Use a document-level selector for its class -and put required fallback tokens on that class or a shared document ancestor. +With the `portal` extension, the preferred manual-Popover path keeps the +element under the chart in the DOM, so inheritance and scoped selectors +continue to work. If Popover is unavailable or fails, the fixed fallback moves +the element under the chart's `ownerDocument` body. Use a document-level +selector for its class and put required fallback tokens on that class or a +shared document ancestor. Every framework adapter can compose native application content with the default rows. See [Tooltips and Focus](./tooltips-and-focus.md). diff --git a/packages/charts-core/docs/guides/tooltips-and-focus.md b/packages/charts-core/docs/guides/tooltips-and-focus.md index 30b7a98..ef7a3c4 100644 --- a/packages/charts-core/docs/guides/tooltips-and-focus.md +++ b/packages/charts-core/docs/guides/tooltips-and-focus.md @@ -15,10 +15,12 @@ Use that path until the product needs richer interaction. ## Default nearest point -Enable the native tooltip with `tooltip: true`: +Import the native tooltip extension and add it to the definition: ```ts -const interactiveDefinition = defineChart(definition, { tooltip: true }) +import { tooltip } from '@tanstack/charts/tooltip' + +const interactiveDefinition = defineChart(definition, { tooltip }) const host = mountChart(element, { definition: interactiveDefinition, @@ -55,10 +57,10 @@ empty space should mean no focus. ## Automatic tooltip mapping -`tooltip: true` renders labeled rows for the focused point. Grouped focus uses -the shared axis value as a heading and renders one row and color swatch per -series. Visible axis labels carry into the tooltip. Numbers use the user's -locale and dates use stable UTC ISO formatting. +The default `tooltip` extension renders labeled rows for the focused point. +Grouped focus uses the shared axis value as a heading and renders one row and +color swatch per series. Visible axis labels carry into the tooltip. Numbers +use the user's locale and dates use stable UTC ISO formatting. Rect and link endpoints display as ranges. Bars and areas with an explicit baseline display the interval length, so a stacked segment reports its own @@ -68,32 +70,31 @@ Order built-in channels, datum fields, and derived text for a single focused point: ```ts -const tooltip = { - items: [ - { - channel: 'y', - label: 'Revenue', - text: (point) => currency(point.yValue), - }, - { - field: 'status', - label: 'Status', - }, - { - id: 'change', - label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), - }, - 'x', - ], -} - const definition = defineChart({ marks, x, y, - tooltip, + tooltip: { + use: tooltip, + items: [ + { + channel: 'y', + label: 'Revenue', + text: (point) => currency(point.yValue), + }, + { + field: 'status', + label: 'Status', + }, + { + id: 'change', + label: 'Change', + text: (point) => + point.datum.change == null ? null : percent(point.datum.change), + }, + 'x', + ], + }, }) ``` @@ -106,30 +107,37 @@ when a grouped tooltip needs additional columns or nested sections. Customize plaintext content with typed formatters: ```ts -const tooltip = { - format(point) { - return `${point.datum.label}: ${point.datum.value.toLocaleString()}` +const formattedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + format(point) { + return `${point.datum.label}: ${point.datum.value.toLocaleString()}` + }, }, -} +}) ``` For grouped focus: ```ts -const tooltip = { - formatGroup(points) { - const date = points[0]?.xValue - const heading = - date instanceof Date ? date.toLocaleDateString() : String(date ?? '') - - return [ - heading, - ...points.map( - (point) => `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, - ), - ].join('\n') +const groupedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + formatGroup(points) { + const date = points[0]?.xValue + const heading = + date instanceof Date ? date.toLocaleDateString() : String(date ?? '') + + return [ + heading, + ...points.map( + (point) => + `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, + ), + ].join('\n') + }, }, -} +}) ``` Formatting precedence is `content`, `formatGroup`, `format`, then the automatic @@ -146,21 +154,23 @@ Point anchoring is the stable default for scatterplots, bars, and keyboard navigation: ```ts -const tooltip = { - anchor: 'point', - placement: 'top', -} +const pointDefinition = defineChart(definition, { + tooltip: { use: tooltip, anchor: 'point', placement: 'top' }, +}) ``` Pointer anchoring is useful when a dense mark has a large hit region. Keyboard focus falls back to the primary point: ```ts -const tooltip = { - anchor: 'pointer', - placement: ['right', 'left', 'bottom', 'top'], - offset: 12, -} +const pointerDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: 'pointer', + placement: ['right', 'left', 'bottom', 'top'], + offset: 12, + }, +}) ``` Grouped charts can avoid jumping between series by anchoring to the focused @@ -173,6 +183,7 @@ const definition = defineChart({ y, focus: 'group-x', tooltip: { + use: tooltip, anchor: 'group-center', placement: ['top', 'right', 'left', 'bottom'], sort: 'color-domain', @@ -184,13 +195,16 @@ A custom resolver covers event ranges, maps, and application-specific reference positions: ```ts -const tooltip = { - anchor: (_points, { chart }) => ({ - x: chart.x + chart.width, - y: chart.y, - }), - placement: 'bottom-left', -} +const customAnchorDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: (_points, { chart }) => ({ + x: chart.x + chart.width, + y: chart.y, + }), + placement: 'bottom-left', + }, +}) ``` Resolvers and placement use scene pixels. A nullish or non-finite custom @@ -203,27 +217,30 @@ surface. Keep tooltip layering with the chart definition: ```ts +import { portal } from '@tanstack/charts/tooltip/portal' + const definition = defineChart({ marks, x, y, focus: 'group-x', tooltip: { - portal: true, + use: tooltip, + portal, anchor: 'group-center', placement: ['right', 'left', 'bottom', 'top'], }, }) ``` -`portal: true` opens the tooltip as a manual Popover in the browser top layer -where supported. It remains a DOM descendant of the chart, so inherited styles, -ancestor selectors, and chart-scoped CSS custom properties continue to work. -If Popover is unavailable or fails, the host moves the tooltip directly under -the chart's `ownerDocument` body with fixed high-stack positioning. Both paths -escape `overflow: hidden` and local stacking contexts, use viewport collision -bounds, and reposition after scroll, viewport resize, or content resize. The -default `false` keeps ordinary absolute positioning inside the chart. +The `portal` extension opens the tooltip as a manual Popover in the browser top +layer where supported. It remains a DOM descendant of the chart, so inherited +styles, ancestor selectors, and chart-scoped CSS custom properties continue to +work. If Popover is unavailable or fails, the host moves the tooltip directly +under the chart's `ownerDocument` body with fixed high-stack positioning. Both +paths escape `overflow: hidden` and local stacking contexts, use viewport +collision bounds, and reposition after scroll, viewport resize, or content +resize. Omitting `portal` keeps ordinary absolute positioning inside the chart. For consistent fallback styling, target `tooltip.className` from a document-level stylesheet and define required CSS custom properties on that @@ -414,8 +431,8 @@ search over every raw point. - Choose two-dimensional, nearest-axis, or grouped-axis semantics explicitly. - Keep a finite distance unless continuous snapping is intended. - Use native plaintext formatting for the 90% case. -- Use `portal: true` where clipped ancestors or stacking contexts can hide the - tooltip. +- Use the `portal` extension where clipped ancestors or stacking contexts can + hide the tooltip. - Use the adapter's tooltip-body composition surface for framework content; use focus callbacks for separate application-owned surfaces. - Give keyboard and pointer users equivalent state and selection. diff --git a/packages/charts-core/docs/installation.md b/packages/charts-core/docs/installation.md index 0d062c3..83c6189 100644 --- a/packages/charts-core/docs/installation.md +++ b/packages/charts-core/docs/installation.md @@ -93,6 +93,21 @@ pnpm add -D @types/d3-geo @types/d3-quadtree @types/d3-delaunay @types/d3-select Do not install the `d3` umbrella package just because a chart uses one D3 capability. Named modules keep ownership visible and make the measured consumer bundle reflect the chart that was actually authored. [Scales and D3](./concepts/scales-and-d3.md) is the single guide to this boundary and links to the corresponding official D3 documentation. +For the common numeric and categorical subset, the smaller scale package is an +alternative to `d3-scale`: + +```sh +pnpm add @tanstack/charts-scales +``` + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' +``` + +Use its exact `/linear`, `/band`, `/point`, or `/ordinal` entry. The package +has no root export. Install `d3-scale` instead when the chart needs temporal, +logarithmic, piecewise, color-interpolating, or full D3 formatting semantics. + ## Package-manager examples The core plus a common scale-and-array setup: @@ -143,6 +158,8 @@ Optional capabilities have explicit entries: import { d3Curve } from '@tanstack/charts/d3/shape' import { renderChartImage } from '@tanstack/charts/export' import { focusX } from '@tanstack/charts/focus' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' import { mountCanvasChart } from '@tanstack/charts/canvas' import { mountChartRenderer } from '@tanstack/charts/renderer' import { polar, radialArc } from '@tanstack/charts/polar' diff --git a/packages/charts-core/docs/overview.md b/packages/charts-core/docs/overview.md index 2b6c0b4..78dcb48 100644 --- a/packages/charts-core/docs/overview.md +++ b/packages/charts-core/docs/overview.md @@ -142,9 +142,9 @@ The normal path is intentionally short: - Omit `width` to follow the chart container. - 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. +- Add the `tooltip` extension when a native value tooltip is enough. - Let built-in marks infer stable identity from IDs or unique positions; supply - `key` when that identity is unavailable or can change. + a stable `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 05b1613..4cd9dfc 100644 --- a/packages/charts-core/docs/quick-start.md +++ b/packages/charts-core/docs/quick-start.md @@ -22,6 +22,7 @@ The host follows the container width when `width` is omitted. ```ts import { scaleLinear, scaleUtc } from 'd3-scale' import { defineChart, lineY, mountChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' interface ClosingPrice { Date: Date @@ -58,7 +59,7 @@ const closingPriceChart = defineChart({ label: 'Close (USD)', grid: true, }, - tooltip: true, + tooltip, }) ``` diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index 0a16263..f535a0b 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -7,6 +7,8 @@ description: Reference static and responsive chart definitions, build context, a ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' ``` `defineChart` accepts a complete chart spec, a responsive configuration, or an @@ -42,6 +44,8 @@ const definition = defineChart({ y: { scale: scaleLinear, nice: true, grid: true }, focus: 'group-x', tooltip: { + use: tooltip, + portal, anchor: 'group-center', placement: ['top', 'right', 'left', 'bottom'], }, @@ -87,7 +91,7 @@ does not return or own them. These options belong to both static and responsive definitions. Hosts and framework adapters do not override them. -Tooltip placement policy stays with the definition, including `portal: true` +Tooltip placement policy stays with the definition. Add the `portal` extension when the surface must escape clipped chart ancestors. Framework-only content composition remains an adapter prop, slot, snippet, or template. diff --git a/packages/charts-core/docs/reference/dom-host.md b/packages/charts-core/docs/reference/dom-host.md index 0edf825..c8beb75 100644 --- a/packages/charts-core/docs/reference/dom-host.md +++ b/packages/charts-core/docs/reference/dom-host.md @@ -10,6 +10,7 @@ pointer and keyboard interaction, native tooltips, font relayout, and cleanup. ```ts import { defineChart } from '@tanstack/charts' import { mountChart } from '@tanstack/charts/dom' +import { tooltip } from '@tanstack/charts/tooltip' ``` ## Signature @@ -34,7 +35,7 @@ also destroys that runtime. In ordinary vanilla use, omit it. ```ts const options = { - definition: defineChart(definition, { tooltip: true }), + definition: defineChart(definition, { tooltip }), height: 320, ariaLabel: 'Weekly revenue', } @@ -70,7 +71,7 @@ import { canvasChartRenderer } from '@tanstack/charts/canvas' import { mountChartRenderer } from '@tanstack/charts/renderer' const host = mountChartRenderer(container, { - definition: defineChart(definition, { tooltip: true }), + definition: defineChart(definition, { tooltip }), renderer: canvasChartRenderer, ariaLabel: 'Weekly revenue', }) @@ -156,11 +157,11 @@ immediately by default; set `animate.resize` to `true` to animate it. The host temporarily assigns `position: relative` when the container's computed position is static, because local native tooltips are absolutely -positioned inside it. Definition `tooltip.portal: true` instead places the -tooltip in the browser top layer through a manual Popover, or directly under -the `ownerDocument` body as a fixed fallback, and positions it against the -viewport. `destroy` restores the previous inline position when the host owned -that change and removes its tooltip. +positioned inside it. Adding the `portal` extension to the tooltip options +instead places the tooltip in the browser top layer through a manual Popover, +or directly under the `ownerDocument` body as a fixed fallback, and positions +it against the viewport. `destroy` restores the previous inline position when +the host owned that change and removes its tooltip. ## Font measurement diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 221c283..87e03b8 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -32,9 +32,11 @@ normal tab-order participation while keeping keyboard handling enabled. Set Use a preset for built-in focus behavior: ```ts +import { tooltip } from '@tanstack/charts/tooltip' + const groupedDownloads = defineChart(definition, { focus: 'group-x', - tooltip: true, + tooltip, }) ``` @@ -100,10 +102,11 @@ keyboard navigation. `navigation` returns the ordered keyboard task set. ## Tooltips -Set `tooltip: true` for the native accessible tooltip. The default is a -structured label-value table. Grouped focus adds a shared-axis heading and one -color swatch and value row per series. Visible axis labels are reused. Numbers -use browser locale formatting; dates use stable UTC ISO formatting. +Import `tooltip` from `@tanstack/charts/tooltip` and place it on the definition +for the native accessible tooltip. The default is a structured label-value +table. Grouped focus adds a shared-axis heading and one color swatch and value +row per series. Visible axis labels are reused. Numbers use browser locale +formatting; dates use stable UTC ISO formatting. ```ts interface ChartTooltipOptions< @@ -112,7 +115,7 @@ interface ChartTooltipOptions< TYValue extends ChartValue = ChartValue, > { className?: string - portal?: boolean + portal?: ChartTooltipPortalInput items?: readonly ChartTooltipItem[] sort?: ChartTooltipSort anchor?: ChartTooltipAnchor @@ -130,19 +133,19 @@ interface ChartTooltipOptions< } ``` -| Option | Default | Meaning | -| ------------- | -------------- | ------------------------------------------------------- | -| `className` | None | Class appended after `ts-chart-tooltip` | -| `portal` | `false` | Escapes clipping through top-layer or fixed positioning | -| `items` | Automatic x/y | Ordered rows for a single focused point | -| `sort` | `color-domain` | Grouped row order | -| `anchor` | `point` | Point, pointer, group center, or coordinate resolver | -| `placement` | `auto` | Fixed or ordered fallback box placements | -| `offset` | `10` | Scene-pixel gap between anchor and box | -| `content` | Automatic rows | Returns a safe title and structured rows | -| `format` | None | Replaces content with primary-point text | -| `formatGroup` | None | Replaces content with focused-group text | -| `sticky` | `true` | Enables activation-to-pin and text selection | +| Option | Default | Meaning | +| ------------- | -------------- | ---------------------------------------------------- | +| `className` | None | Class appended after `ts-chart-tooltip` | +| `portal` | None | Optional top-layer or fixed-position transport | +| `items` | Automatic x/y | Ordered rows for a single focused point | +| `sort` | `color-domain` | Grouped row order | +| `anchor` | `point` | Point, pointer, group center, or coordinate resolver | +| `placement` | `auto` | Fixed or ordered fallback box placements | +| `offset` | `10` | Scene-pixel gap between anchor and box | +| `content` | Automatic rows | Returns a safe title and structured rows | +| `format` | None | Replaces content with primary-point text | +| `formatGroup` | None | Replaces content with focused-group text | +| `sticky` | `true` | Enables activation-to-pin and text selection | Formatting precedence is `content`, `formatGroup`, `format`, then the default. The text formatters do not parse HTML, and newlines are preserved. `className` @@ -154,28 +157,31 @@ is appended to `ts-chart-tooltip`. shorthands, a configured channel, a scalar datum field, or derived text: ```ts -const tooltip = { - items: [ - { - channel: 'y', - label: 'Revenue', - text: (point) => currency(point.yValue), - }, - { - field: 'volume', - label: 'Volume', - text: (point) => compact(point.datum.volume), - }, - { - id: 'change', - label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), - }, - 'x', - 'group', - ], -} +const detailedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + items: [ + { + channel: 'y', + label: 'Revenue', + text: (point) => currency(point.yValue), + }, + { + field: 'volume', + label: 'Volume', + text: (point) => compact(point.datum.volume), + }, + { + id: 'change', + label: 'Change', + text: (point) => + point.datum.change == null ? null : percent(point.datum.change), + }, + 'x', + 'group', + ], + }, +}) ``` Array order is row order. A nullish datum field or nullish `text` result omits @@ -207,20 +213,25 @@ it uses the least-overflowing candidate and shifts it inside. `auto` uses `top`, `bottom`, `right`, then `left`. ```ts -const tooltip = { - anchor: 'group-center', - placement: ['top', 'right', 'left', 'bottom'], - offset: 12, -} +const groupedDefinition = defineChart(definition, { + tooltip: { + use: tooltip, + anchor: 'group-center', + placement: ['top', 'right', 'left', 'bottom'], + offset: 12, + }, +}) ``` -Set `portal: true` when an ancestor clips overflow or creates an incompatible -stacking context. The host opens the tooltip as a manual Popover in the browser -top layer where supported, while retaining its chart DOM ancestry. If Popover -is unavailable or fails, it moves the tooltip directly under the chart's -`ownerDocument` body with fixed high-stack positioning. Both paths map the -scene anchor to viewport coordinates, reposition during scroll, resize, and -content resize, and collide against the viewport instead of the chart box. +Import `portal` from `@tanstack/charts/tooltip/portal` and assign it to the +tooltip's `portal` option when an ancestor clips overflow or creates an +incompatible stacking context. The host opens the tooltip as a manual Popover +in the browser top layer where supported, while retaining its chart DOM +ancestry. If Popover is unavailable or fails, it moves the tooltip directly +under the chart's `ownerDocument` body with fixed high-stack positioning. Both +paths map the scene anchor to viewport coordinates, reposition during scroll, +resize, and content resize, and collide against the viewport instead of the +chart box. Clicking, Enter, or Space pins the tooltip. The next activation unpins it. `Escape` unpins and clears focus. Set `sticky: false` to disable pinning. A diff --git a/packages/charts-core/docs/reference/rendering-and-export.md b/packages/charts-core/docs/reference/rendering-and-export.md index b664eb8..843a66a 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -86,8 +86,9 @@ Scene keys become `data-ts-key` attributes for reconciliation. ```ts import { mountCanvasChart } from '@tanstack/charts/canvas' +import { tooltip } from '@tanstack/charts/tooltip' -const interactiveDefinition = defineChart(definition, { tooltip: true }) +const interactiveDefinition = defineChart(definition, { tooltip }) const host = mountCanvasChart(container, { definition: interactiveDefinition, diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index f0461fc..1621520 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -12,6 +12,37 @@ For the architectural boundary and guidance on selecting granular D3 modules, read [Scales and D3](../concepts/scales-and-d3.md). This page documents only the TanStack Charts contract around those primitives. +## Compact scale package + +`@tanstack/charts-scales` supplies four exact, tree-shakeable entries: + +| Entry | Runtime export | Type contract | +| --------------------------------- | -------------- | -------------- | +| `@tanstack/charts-scales/linear` | `scaleLinear` | `LinearScale` | +| `@tanstack/charts-scales/band` | `scaleBand` | `BandScale` | +| `@tanstack/charts-scales/point` | `scalePoint` | `PointScale` | +| `@tanstack/charts-scales/ordinal` | `scaleOrdinal` | `OrdinalScale` | + +`LinearScale` supports numeric two-stop domains and ranges, `invert`, `clamp`, +`ticks`, basic `tickFormat`, `nice`, and `copy`. `BandScale` supports +`padding`, `paddingInner`, `paddingOuter`, `align`, `round`, `rangeRound`, +`bandwidth`, `step`, and `copy`. `PointScale` exposes the corresponding point +operations with zero bandwidth. `OrdinalScale` supports explicit or implicit +domains, cyclic ranges, `unknown`, and `copy`. + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' + +const y = { + scale: scaleLinear().domain([0, 100]), +} +``` + +These factories satisfy `ConfiguredScaleLike` and `ChartScaleInput` directly. +They are a documented subset, not a complete `d3-scale` compatibility claim. +Use D3 for temporal or transformed domains, piecewise and nonnumeric +interpolation, and full formatting semantics. + ## Positional scale factories The common path passes the D3 factory directly: diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index ae2b33a..fdc7ccc 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -200,31 +200,43 @@ See [DOM host](./dom-host.md) and ## Focus and tooltip types -| Type | Purpose | -| ---------------------------- | -------------------------------------------------------- | -| `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | -| `ChartFocusPreset` | Built-in nearest and grouped axis focus names | -| `ChartFocusMode` | Focus preset or custom strategy | -| `ChartSpatialIndex` | Nearest-point query | -| `ChartSpatialIndexFactory` | Builds an index from current scene points | -| `ChartTooltipOptions` | Native tooltip content, ordering, anchoring, and pinning | -| `ChartTooltipItem` | Ordered channel, datum-field, or derived point row | -| `ChartTooltipItemBase` | Shared label and point-text contract for object items | -| `ChartTooltipChannelItem` | Configured x, y, or group row | -| `ChartTooltipDatumItem` | Scalar datum-field row | -| `ChartTooltipDerivedItem` | Row derived from the complete focused point | -| `ChartTooltipSort` | Group row ordering | -| `ChartTooltipAnchor` | Point, pointer, group-center, or custom scene anchor | -| `ChartTooltipAnchorContext` | Pointer position, chart bounds, and surface size | -| `ChartTooltipPlacement` | Tooltip box placement around its anchor | -| `ChartTooltipPosition` | Scene-pixel x/y coordinate | -| `ChartDefinitionOptions` | Focus, tooltip, animation, keyboard, and spatial policy | -| `DynamicChartConfig` | Responsive builder plus definition-owned behavior | -| `ChartTooltipContent` | Safe title and row model for a native tooltip | -| `ChartTooltipRow` | Label, formatted value, and optional color swatch | -| `ChartTooltipContentContext` | Axis labels and value formatters for content callbacks | -| `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | -| `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | +| Type | Purpose | +| ------------------------------------- | ---------------------------------------------------------------------- | +| `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | +| `ChartFocusPreset` | Built-in nearest and grouped axis focus names | +| `ChartFocusMode` | Focus preset or custom strategy | +| `ChartSpatialIndex` | Nearest-point query | +| `ChartSpatialIndexFactory` | Builds an index from current scene points | +| `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | +| `ChartTooltipInput` | Tooltip extension token or configured extension options | +| `ChartTooltipExtension` | Tooltip lifecycle implementation | +| `ChartTooltipExtensionContext` | Container, dismissal, and adapter-body bridge given to a tooltip | +| `ChartTooltipExtensionInstance` | Tooltip update, paint, hide, containment, and destroy lifecycle | +| `ChartTooltipPaintContext` | Focused points, scene, surface, pointer, and pinned state | +| `ChartTooltipOptions` | Native tooltip content, ordering, anchoring, and pinning | +| `ChartTooltipPortalInput` | Portal extension token or configured transport options | +| `ChartTooltipPortalExtension` | Tooltip transport lifecycle implementation | +| `ChartTooltipPortalExtensionContext` | Container, tooltip element, and reposition callback given to a portal | +| `ChartTooltipPortalExtensionInstance` | Portal update, position, hide, and destroy lifecycle | +| `ChartTooltipPortalOptions` | Reserved configuration object for portal extensions | +| `ChartTooltipPortalPositionContext` | Scene, surface, anchor, placement, and offset for viewport positioning | +| `ChartTooltipItem` | Ordered channel, datum-field, or derived point row | +| `ChartTooltipItemBase` | Shared label and point-text contract for object items | +| `ChartTooltipChannelItem` | Configured x, y, or group row | +| `ChartTooltipDatumItem` | Scalar datum-field row | +| `ChartTooltipDerivedItem` | Row derived from the complete focused point | +| `ChartTooltipSort` | Group row ordering | +| `ChartTooltipAnchor` | Point, pointer, group-center, or custom scene anchor | +| `ChartTooltipAnchorContext` | Pointer position, chart bounds, and surface size | +| `ChartTooltipPlacement` | Tooltip box placement around its anchor | +| `ChartTooltipPosition` | Scene-pixel x/y coordinate | +| `ChartDefinitionOptions` | Focus, tooltip, animation, keyboard, and spatial policy | +| `DynamicChartConfig` | Responsive builder plus definition-owned behavior | +| `ChartTooltipContent` | Safe title and row model for a native tooltip | +| `ChartTooltipRow` | Label, formatted value, and optional color swatch | +| `ChartTooltipContentContext` | Axis labels and value formatters for content callbacks | +| `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | +| `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | See [Focus and interaction](./focus-and-interaction.md). diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index 9761b94..4c432c4 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -88,8 +88,8 @@ 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. D3 factories infer domains from mark channels; configured instances preserve application-owned domains. +- Use exact @tanstack/charts-scales/* entries for compact linear, band, point, or ordinal scales. Use direct, granular d3-* imports when full D3 semantics are required; never import the d3 umbrella. +- Let TanStack Charts own responsive pixel ranges. Scale 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. - Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index 4fcb201..87809ae 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -22,12 +22,12 @@ "@types/d3-shape": "^3.1.8", "d3-array": "3.2.4", "d3-geo": "3.1.1", - "d3-scale": "4.0.2", "d3-shape": "3.2.0" }, "devDependencies": { "@types/d3-array": "^3.2.2", - "@types/d3-scale": "^4.0.9" + "@types/d3-scale": "^4.0.9", + "d3-scale": "4.0.2" }, "exports": { ".": "./src/index.ts", @@ -65,6 +65,8 @@ "./svg/resources": "./src/svg-resources.ts", "./text": "./src/text.ts", "./tick": "./src/tick.ts", + "./tooltip": "./src/tooltip.ts", + "./tooltip/portal": "./src/tooltip-portal.ts", "./vector": "./src/vector.ts" }, "publishConfig": { @@ -211,6 +213,14 @@ "types": "./dist/tick.d.ts", "import": "./dist/tick.js" }, + "./tooltip": { + "types": "./dist/tooltip.d.ts", + "import": "./dist/tooltip.js" + }, + "./tooltip/portal": { + "types": "./dist/tooltip-portal.d.ts", + "import": "./dist/tooltip-portal.js" + }, "./vector": { "types": "./dist/vector.d.ts", "import": "./dist/vector.js" diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 721694f..5a23e62 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -7,6 +7,7 @@ import { } from './canvas' import { lineY } from './line' import { defineChart } from './scene' +import { tooltip } from './tooltip' import type { ChartScene, ChartSurfaceRenderOptions, SceneNode } from './types' interface FakeCanvasContext { @@ -361,7 +362,7 @@ describe('Canvas renderer', () => { definition: { ...definition, maxFocusDistance: 1_000, - tooltip: true, + tooltip, }, width: 480, height: 260, diff --git a/packages/charts-core/src/color-scale.test.ts b/packages/charts-core/src/color-scale.test.ts index d53ac8f..6a78284 100644 --- a/packages/charts-core/src/color-scale.test.ts +++ b/packages/charts-core/src/color-scale.test.ts @@ -14,6 +14,21 @@ import { defaultChartTheme } from './scene' import { colorGradientLegend, colorLegend } from './legend' describe('continuous color', () => { + it('keeps the default ordinal mapping stable without D3 scale runtime', () => { + const scale = createColorScale( + ['1', 1, Number.NaN, Number.NaN], + { range: ['red', 'blue'] }, + defaultChartTheme, + ) + + expect(scale.domain).toEqual(['1', 1, Number.NaN]) + expect(scale.map('1')).toBe('red') + expect(scale.map(1)).toBe('blue') + expect(scale.map(Number.NaN)).toBe('red') + expect(scale.map('later')).toBe('blue') + expect(scale.map('later')).toBe('blue') + }) + it('accepts configured D3 color scales directly', () => { const configured = scaleSequential((ratio) => `value:${ratio}`).domain([ 0, 100, diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 61c3408..d504a53 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -15,4 +15,17 @@ describe('public package exports', () => { expect(modules).toHaveLength(specifiers.length) expect(modules.every((module) => Object.keys(module).length > 0)).toBe(true) }) + + it('keeps tooltip capabilities on exact subpaths', async () => { + const [root, tooltipModule, portalModule] = await Promise.all([ + import('@tanstack/charts'), + import('@tanstack/charts/tooltip'), + import('@tanstack/charts/tooltip/portal'), + ]) + + expect(root).not.toHaveProperty('tooltip') + expect(root).not.toHaveProperty('portal') + expect(tooltipModule.tooltip.id).toBe('tooltip') + expect(portalModule.portal.id).toBe('portal') + }) }) diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 609d1e3..0ffa16e 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -75,6 +75,7 @@ export type { ChartCurve, ChartDefinition, ChartDefinitionOptions, + ChartExtensionInput, ChartFocusMode, ChartFocusPreset, ChartFocusStrategy, @@ -133,10 +134,21 @@ export type { ChartTooltipChannelItem, ChartTooltipDatumItem, ChartTooltipDerivedItem, + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipInput, ChartTooltipItem, ChartTooltipItemBase, ChartTooltipOptions, + ChartTooltipPaintContext, ChartTooltipPlacement, + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalInput, + ChartTooltipPortalOptions, + ChartTooltipPortalPositionContext, ChartTooltipPosition, ChartTooltipRow, ChartTooltipSort, diff --git a/packages/charts-core/src/mark.ts b/packages/charts-core/src/mark.ts index 44141a3..99a0605 100644 --- a/packages/charts-core/src/mark.ts +++ b/packages/charts-core/src/mark.ts @@ -1,4 +1,4 @@ -import { valueKey } from './scales' +import { isChartKey, valueKey } from './scales' import type { Channel, ChannelAccessor, @@ -14,9 +14,7 @@ declare const process: { env: { NODE_ENV?: string } } | undefined const warnedKeyFallbacks = new WeakSet() -export function isChartKey(value: unknown): value is ChartKey { - return typeof value === 'string' || typeof value === 'number' -} +export { isChartKey } export function isChartValue(value: unknown): value is ChartValue { return ( diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index 869fff8..a92810f 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -3,6 +3,8 @@ import { scaleBand, scaleLinear } from 'd3-scale' import { lineY } from './line' import { mountChartRenderer } from './renderer' import { defineChart } from './scene' +import { tooltip as tooltipExtension } from './tooltip' +import { portal as portalExtension } from './tooltip-portal' import type { ChartRenderer, ChartScene, @@ -47,7 +49,7 @@ describe('renderer-neutral chart host', () => { definition: { ...definition, maxFocusDistance: 1_000, - tooltip: true, + tooltip: tooltipExtension, }, renderer: fake.renderer, width: 480, @@ -136,6 +138,7 @@ describe('renderer-neutral chart host', () => { const pointerDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { + use: tooltipExtension, anchor: 'pointer', placement: ['top', 'bottom-right'], offset: 12, @@ -179,6 +182,7 @@ describe('renderer-neutral chart host', () => { const customDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { + use: tooltipExtension, anchor(_points, context) { pointers.push(context.pointer) return { x: 300, y: 120 } @@ -217,6 +221,7 @@ describe('renderer-neutral chart host', () => { navigation: (points) => points, }, tooltip: { + use: tooltipExtension, sort: (left, right) => right.yValue - left.yValue, anchor(points) { anchorGroups.push(points.map((point) => point.datum.id)) @@ -282,7 +287,8 @@ describe('renderer-neutral chart host', () => { definition: defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltipExtension, + portal: portalExtension, anchor: () => ({ x: 240, y: 130 }), placement: ['right', 'left'], offset: 10, @@ -388,7 +394,7 @@ describe('renderer-neutral chart host', () => { vi.spyOn(second.element, 'getBoundingClientRect').mockReturnValue(bounds) const portalDefinition = defineChart(definition, { maxFocusDistance: 1_000, - tooltip: { portal: true }, + tooltip: { use: tooltipExtension, portal: portalExtension }, }) const firstHost = mountChartRenderer(firstContainer, { definition: portalDefinition, @@ -472,11 +478,11 @@ describe('renderer-neutral chart host', () => { } const portalDefinition = defineChart(definition, { maxFocusDistance: 1_000, - tooltip: { portal: true }, + tooltip: { use: tooltipExtension, portal: portalExtension }, }) const localDefinition = defineChart(definition, { maxFocusDistance: 1_000, - tooltip: true, + tooltip: tooltipExtension, }) const host = mountChartRenderer(container, { ...common, @@ -541,7 +547,7 @@ describe('renderer-neutral chart host', () => { const options = { definition: defineChart(definition, { maxFocusDistance: 1_000, - tooltip: { portal: true }, + tooltip: { use: tooltipExtension, portal: portalExtension }, }), renderer: first.renderer, width: 480, @@ -621,7 +627,8 @@ describe('renderer-neutral chart host', () => { definition: defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltipExtension, + portal: portalExtension, anchor: () => ({ x: 240, y: 130 }), placement: 'top', offset: 10, @@ -687,7 +694,7 @@ describe('renderer-neutral chart host', () => { definition: { ...definition, maxFocusDistance: 1_000, - tooltip: true, + tooltip: tooltipExtension, animate: true, }, renderer: first.renderer, diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index c17956b..9eb9ad0 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -13,26 +13,16 @@ import type { ChartScene, ChartSpatialIndex, ChartSurface, - ChartTooltipBodyTarget, - ChartTooltipContent, - ChartTooltipContentContext, - ChartTooltipChannelItem, - ChartTooltipItem, + ChartTooltipExtension, + ChartTooltipExtensionInstance, + ChartTooltipInput, ChartTooltipOptions, - ChartTooltipPlacement, ChartTooltipPosition, ChartValue, } from './types' type HostRenderReason = 'update' | 'resize' | 'layout' -interface TooltipBounds { - left: number - top: number - right: number - bottom: number -} - /** * Mounts a chart and owns the runtime until the returned host is destroyed. * Pass a runtime that already rendered initial markup to preserve renderer @@ -63,26 +53,9 @@ export function mountChartRenderer< let destroyed = false let hasRendered = false let surface: ChartSurface | undefined - let tooltipElement: HTMLDivElement | undefined - let tooltipBodyElement: HTMLDivElement | undefined - let activeTooltipBodyChange: - | (( - target: ChartTooltipBodyTarget | null, - ) => void) - | undefined - let tooltipBodyVisible = false - let tooltipBodyDefinition = options.definition - let tooltipBodyScene: ChartScene | undefined - let tooltipBodyPoints: readonly ChartPoint[] = [] - let tooltipBodyPinned = false - let tooltipAnchor: ChartTooltipPosition | null = null - let tooltipActive = false - let tooltipUsesNativePopover = false - let tooltipPopoverOpen = false - let tooltipPopoverFailed = false - let tooltipPortalListening = false - let tooltipPositionFrame: number | undefined - let tooltipResizeObserver: ResizeObserver | undefined + let tooltipExtension: ChartTooltipExtension | undefined + let tooltipInstance: + ChartTooltipExtensionInstance | undefined let suppressNextSurfaceFocus = false let spatialIndex: ChartSpatialIndex | undefined const previousPosition = container.style.position @@ -198,20 +171,6 @@ export function mountChartRenderer< scheduleRender(true) } - const scheduleTooltipPosition = () => { - if (destroyed || !tooltipActive || tooltipPositionFrame !== undefined) { - return - } - if (!view?.requestAnimationFrame) { - positionTooltip() - return - } - tooltipPositionFrame = view.requestAnimationFrame(() => { - tooltipPositionFrame = undefined - positionTooltip() - }) - } - const updateFocus = ( points: readonly ChartPoint[], forcePaint = false, @@ -230,7 +189,7 @@ export function mountChartRenderer< const dismissTooltip = () => { if (!focusedPoint && !pinnedKey) return const restoreFocus = Boolean( - tooltipElement?.contains(container.ownerDocument.activeElement), + tooltipInstance?.contains(container.ownerDocument.activeElement), ) pinnedKey = null pointerPosition = null @@ -266,8 +225,7 @@ export function mountChartRenderer< if (pinnedKey) return updateFocus( pointsAtPointer(event.clientX, event.clientY), - tooltipTracksPointer() || - (tooltipUsesNativePopover && !tooltipPopoverIsOpen()), + tooltipTracksPointer(), ) } const clearTransientFocus = ({ relatedTarget }: MouseEvent | FocusEvent) => { @@ -284,7 +242,7 @@ export function mountChartRenderer< } } const handleClick = (event: MouseEvent) => { - if (event.target && tooltipElement?.contains(event.target as Node)) { + if (tooltipInstance?.contains(event.target)) { return } const points = pointsAtPointer(event.clientX, event.clientY) @@ -350,12 +308,6 @@ export function mountChartRenderer< updateFocus(point ? focusPointsForPoint(point) : []) } } - const handleTooltipKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || !pinnedKey) return - event.preventDefault() - event.stopPropagation() - dismissTooltip() - } container.addEventListener('pointermove', handlePointerMove) container.addEventListener('pointercancel', clearTransientFocus) container.addEventListener('mouseleave', clearTransientFocus) @@ -396,7 +348,7 @@ export function mountChartRenderer< fontChanged const observerChanged = options.width !== nextOptions.width options = nextOptions - if (!tooltipUsesPortal()) moveTooltipToContainer() + syncTooltip() if (!tooltipIsSticky()) pinnedKey = null if (needsRender) { render( @@ -482,377 +434,86 @@ export function mountChartRenderer< point: ChartPoint | null, points: readonly ChartPoint[], ) { - if (!options.definition.tooltip || !point) { - tooltipActive = false - tooltipAnchor = null - hideTooltipElement() - hideTooltipBody() + const input = resolveTooltipInput(options.definition.tooltip) + if (!input || !point || !surface) { + tooltipInstance?.hide() return } - const tooltipOptions = - typeof options.definition.tooltip === 'object' - ? options.definition.tooltip - : undefined - tooltipActive = true - if (!tooltipElement) { - tooltipElement = createTooltip(container.ownerDocument) - tooltipElement.addEventListener('keydown', handleTooltipKeyDown) - tooltipResizeObserver = view?.ResizeObserver - ? new view.ResizeObserver(scheduleTooltipPosition) - : undefined - tooltipResizeObserver?.observe(tooltipElement) - } - configureTooltipParent(tooltipOptions) - tooltipElement.className = tooltipOptions?.className - ? `ts-chart-tooltip ${tooltipOptions.className}` - : 'ts-chart-tooltip' - const tooltipPoints = orderTooltipPoints( - points, - scene, - tooltipOptions?.sort, - ) - const contentContext = createTooltipContentContext(scene, tooltipOptions) - const content = tooltipOptions?.content?.(tooltipPoints, contentContext) - const text = - content === undefined - ? (tooltipOptions?.formatGroup?.(tooltipPoints) ?? - tooltipOptions?.format?.(point)) - : undefined - const resolvedContent = - content ?? - text ?? - defaultTooltipContent(tooltipPoints, scene, tooltipOptions) - const pinned = pinnedKey !== null - const hasCustomBody = renderTooltipBody( - tooltipElement, - tooltipPoints, - resolvedContent, - pinned, - ) - if (!hasCustomBody) { - if (typeof resolvedContent === 'string') { - paintPlainTooltip(tooltipElement, resolvedContent) - } else { - paintStructuredTooltip(tooltipElement, resolvedContent) - } - } - configureTooltipSemantics( - tooltipElement, - resolvedContent, - hasCustomBody, - pinned, - ) - tooltipElement.style.pointerEvents = pinned ? 'auto' : 'none' - tooltipElement.style.userSelect = pinned ? 'text' : 'none' - tooltipElement.dataset.sticky = String(pinned) - tooltipElement.style.visibility = 'hidden' - tooltipElement.removeAttribute('hidden') - tooltipAnchor = resolveTooltipAnchor( - point, - tooltipPoints, - scene, - pointerPosition, - tooltipOptions, - ) - positionTooltip() - tooltipElement.style.removeProperty('visibility') - } - - function renderTooltipBody( - tooltip: HTMLDivElement, - points: readonly ChartPoint[], - content: ChartTooltipContent | string, - pinned: boolean, - ) { - const callback = options.onTooltipBodyChange - if (!callback) { - deactivateTooltipBody() - return false - } - if (activeTooltipBodyChange !== callback) { - activeTooltipBodyChange?.(null) - activeTooltipBodyChange = callback - tooltipBodyVisible = false - tooltipBodyElement = undefined - } - if (!tooltipBodyElement) { - tooltipBodyElement = tooltip.ownerDocument.createElement('div') - tooltipBodyElement.className = 'ts-chart-tooltip__body' - tooltip.replaceChildren(tooltipBodyElement) - } - tooltipBodyElement.toggleAttribute('inert', !pinned) - setTooltipContentAccessibility(tooltip, content) - const changed = - !tooltipBodyVisible || - tooltipBodyDefinition !== options.definition || - tooltipBodyScene !== scene || - tooltipBodyPinned !== pinned || - !samePointList(points, tooltipBodyPoints) - tooltipBodyVisible = true - tooltipBodyDefinition = options.definition - tooltipBodyScene = scene - tooltipBodyPoints = points - tooltipBodyPinned = pinned - if (changed) { - callback({ - element: tooltipBodyElement, - points, - content, - pinned, + if (tooltipExtension !== input.extension || !tooltipInstance) { + destroyTooltip() + tooltipExtension = input.extension + tooltipInstance = input.extension.create({ + container, dismiss: dismissTooltip, + bodyChange: () => options.onTooltipBodyChange, }) } - return true - } - - function hideTooltipBody() { - if (!tooltipBodyVisible) return - tooltipBodyVisible = false - activeTooltipBodyChange?.(null) - } - - function deactivateTooltipBody() { - hideTooltipBody() - activeTooltipBodyChange = undefined - tooltipBodyElement = undefined - tooltipBodyScene = undefined - tooltipBodyPoints = [] - } - - function positionTooltip() { - if (!tooltipActive || !tooltipElement || !tooltipAnchor) return - if (tooltipUsesPortal()) { - positionPortalTooltip() - return - } - const tooltip = options.definition.tooltip - const tooltipOptions = - tooltip && typeof tooltip === 'object' ? tooltip : undefined - placeTooltip( - tooltipElement, - tooltipAnchor.x, - tooltipAnchor.y, - { - left: 0, - top: 0, - right: scene.width, - bottom: scene.height, - }, - tooltipOptions?.placement, - tooltipOptions?.offset, - ) - } - - function configureTooltipParent( - tooltipOptions: ChartTooltipOptions | undefined, - ) { - if (!tooltipElement) return - if (!tooltipOptions?.portal) { - moveTooltipToContainer() - return - } - startTooltipPortalPositioning() - const showPopover = ( - tooltipElement as HTMLDivElement & { showPopover?: () => void } - ).showPopover - if (typeof showPopover === 'function' && !tooltipPopoverFailed) { - if (tooltipElement.parentNode !== container) { - container.append(tooltipElement) - } - tooltipElement.setAttribute('popover', 'manual') - tooltipElement.dataset.tsChartTooltipPortal = 'popover' - tooltipElement.style.zIndex = '1' - tooltipUsesNativePopover = true - } else { - moveTooltipToPortalFallback() - } - Object.assign(tooltipElement.style, { - position: 'fixed', - right: 'auto', - bottom: 'auto', - margin: '0', - }) - } - - function positionPortalTooltip() { - if ( - !tooltipActive || - !tooltipElement || - !tooltipAnchor || - !surface || - !tooltipUsesPortal() - ) { - return - } - const anchor = sceneToClient(surface.element, scene, tooltipAnchor) - const boundary = viewportBounds(container.ownerDocument) - if ( - !anchor || - boundary.right <= boundary.left || - boundary.bottom <= boundary.top || - !pointInBounds(anchor, boundary) - ) { - hideTooltipElement(false) - return - } - tooltipElement.removeAttribute('hidden') - if (tooltipUsesNativePopover && !showTooltipPopover()) { - tooltipPopoverFailed = true - moveTooltipToPortalFallback() - } - const tooltipOptions = options.definition.tooltip as ChartTooltipOptions< - any, - any, - any - > - placeTooltip( - tooltipElement, - anchor.x, - anchor.y, - boundary, - tooltipOptions.placement, - tooltipOptions.offset, - ) - } - - function moveTooltipToContainer() { - stopTooltipPortalPositioning() - if (tooltipElement) { - hideTooltipPopover() - tooltipUsesNativePopover = false - tooltipPopoverFailed = false - tooltipElement.removeAttribute('popover') - delete tooltipElement.dataset.tsChartTooltipPortal - container.append(tooltipElement) - tooltipElement.style.position = 'absolute' - tooltipElement.style.zIndex = '1' - } - } - - function moveTooltipToPortalFallback() { - if (!tooltipElement) return - hideTooltipPopover() - tooltipUsesNativePopover = false - tooltipElement.removeAttribute('popover') - tooltipElement.dataset.tsChartTooltipPortal = 'fallback' - tooltipElement.style.zIndex = '2147483647' - const parent = - container.ownerDocument.body ?? container.ownerDocument.documentElement - if (tooltipElement.parentNode !== parent) parent.append(tooltipElement) - } - - function startTooltipPortalPositioning() { - if (tooltipPortalListening) return - tooltipPortalListening = true - view?.addEventListener('scroll', scheduleTooltipPosition, { - capture: true, - passive: true, - }) - view?.addEventListener('resize', scheduleTooltipPosition, { passive: true }) - view?.visualViewport?.addEventListener('scroll', scheduleTooltipPosition, { - passive: true, - }) - view?.visualViewport?.addEventListener('resize', scheduleTooltipPosition, { - passive: true, + const instance = tooltipInstance + instance.update(input.options) + instance.paint({ + point, + points, + scene, + surface, + pointer: pointerPosition, + pinned: pinnedKey !== null, }) - tooltipResizeObserver?.observe(container) - } - - function stopTooltipPortalPositioning() { - if (!tooltipPortalListening) return - tooltipPortalListening = false - view?.removeEventListener('scroll', scheduleTooltipPosition, true) - view?.removeEventListener('resize', scheduleTooltipPosition) - view?.visualViewport?.removeEventListener('scroll', scheduleTooltipPosition) - view?.visualViewport?.removeEventListener('resize', scheduleTooltipPosition) - tooltipResizeObserver?.unobserve(container) - if (tooltipPositionFrame !== undefined) { - view?.cancelAnimationFrame?.(tooltipPositionFrame) - tooltipPositionFrame = undefined - } - } - - function showTooltipPopover() { - if (!tooltipElement || !tooltipUsesNativePopover) return false - if (tooltipPopoverIsOpen()) return true - try { - ;( - tooltipElement as HTMLDivElement & { showPopover: () => void } - ).showPopover() - tooltipPopoverOpen = true - return true - } catch { - tooltipPopoverOpen = false - return false - } } - function hideTooltipPopover() { - if (!tooltipElement || !tooltipUsesNativePopover) return - try { - if (tooltipPopoverIsOpen()) { - ;( - tooltipElement as HTMLDivElement & { hidePopover?: () => void } - ).hidePopover?.() - } - } catch { - // Removing or hiding the tooltip below still closes its top-layer box. - } - tooltipPopoverOpen = false - } - - function tooltipPopoverIsOpen() { - if (!tooltipElement || !tooltipUsesNativePopover) return false - try { - return tooltipElement.matches(':popover-open') - } catch { - return tooltipPopoverOpen + function syncTooltip() { + const input = resolveTooltipInput(options.definition.tooltip) + if (!input) { + tooltipInstance?.update(emptyTooltipOptions) + tooltipInstance?.hide() + } else if (input.extension !== tooltipExtension) { + destroyTooltip() + } else { + tooltipInstance?.update(input.options) } } - function hideTooltipElement(stopPortal = true) { - hideTooltipPopover() - tooltipElement?.setAttribute('hidden', '') - if (stopPortal) stopTooltipPortalPositioning() - } - function destroyTooltip() { - tooltipActive = false - tooltipAnchor = null - deactivateTooltipBody() - stopTooltipPortalPositioning() - hideTooltipPopover() - tooltipResizeObserver?.disconnect() - tooltipResizeObserver = undefined - tooltipElement?.remove() - tooltipElement = undefined - tooltipUsesNativePopover = false - tooltipPopoverOpen = false - tooltipPopoverFailed = false - } - - function tooltipUsesPortal() { - const tooltip = options.definition.tooltip - return Boolean(tooltip && typeof tooltip === 'object' && tooltip.portal) + tooltipInstance?.destroy() + tooltipInstance = undefined + tooltipExtension = undefined } function tooltipIsSticky() { - return ( - Boolean(options.definition.tooltip) && - !( - typeof options.definition.tooltip === 'object' && - options.definition.tooltip.sticky === false - ) - ) + const input = resolveTooltipInput(options.definition.tooltip) + return Boolean(input && input.options.sticky !== false) } function tooltipTracksPointer() { - const tooltip = options.definition.tooltip - if (!tooltip || typeof tooltip !== 'object') return false - return tooltip.anchor === 'pointer' || typeof tooltip.anchor === 'function' + const input = resolveTooltipInput(options.definition.tooltip) + const anchor = input?.options.anchor + return anchor === 'pointer' || typeof anchor === 'function' } } +const emptyTooltipOptions: ChartTooltipOptions = {} + +function resolveTooltipInput< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + input: false | ChartTooltipInput | undefined, +): { + extension: ChartTooltipExtension + options: ChartTooltipOptions +} | null { + if (!input) return null + return 'create' in input + ? { + extension: input, + options: emptyTooltipOptions, + } + : { + extension: input.use, + options: input, + } +} + function samePointIdentity< TDatum, TXValue extends ChartValue, @@ -871,20 +532,6 @@ function samePointIdentity< ) } -function samePointList< - TDatum, - TXValue extends ChartValue, - TYValue extends ChartValue, ->( - left: readonly ChartPoint[], - right: readonly ChartPoint[], -) { - return ( - left.length === right.length && - left.every((point, index) => samePointIdentity(point, right[index] ?? null)) - ) -} - function restoreFocusedPoint< TDatum, TXValue extends ChartValue, @@ -1102,560 +749,3 @@ function compareNavigationPoints< ) { return left.x - right.x || left.y - right.y || leftIndex - rightIndex } - -function createTooltip(document: Document) { - const tooltip = document.createElement('div') - tooltip.className = 'ts-chart-tooltip' - tooltip.setAttribute('role', 'status') - tooltip.setAttribute('aria-live', 'polite') - Object.assign(tooltip.style, { - position: 'absolute', - zIndex: '1', - maxWidth: 'min(24rem, 80%)', - padding: '0.4rem 0.55rem', - border: '1px solid color-mix(in srgb, CanvasText 18%, transparent)', - borderRadius: '0.45rem', - background: 'Canvas', - color: 'CanvasText', - boxShadow: '0 6px 24px rgb(0 0 0 / 0.14)', - font: '500 0.75rem/1.3 system-ui, sans-serif', - pointerEvents: 'none', - overflowWrap: 'anywhere', - }) - tooltip.hidden = true - return tooltip -} - -function createTooltipContentContext( - scene: ChartScene, - options?: ChartTooltipOptions, -): ChartTooltipContentContext { - const x = findTooltipChannelItem(options?.items, 'x') - const y = findTooltipChannelItem(options?.items, 'y') - return { - xLabel: x?.label ?? findSceneLabel(scene, 'x-label') ?? 'x', - yLabel: y?.label ?? findSceneLabel(scene, 'y-label') ?? 'y', - formatX: formatValue, - formatY: formatValue, - } -} - -function defaultTooltipContent( - points: readonly ChartPoint[], - scene: ChartScene, - options?: ChartTooltipOptions, -): ChartTooltipContent { - const point = points[0] - if (!point) return { rows: [] } - const context = createTooltipContentContext(scene, options) - const x = findTooltipChannelItem(options?.items, 'x') - const y = findTooltipChannelItem(options?.items, 'y') - const group = findTooltipChannelItem(options?.items, 'group') - const sharedX = - points.length > 1 && - points.every((candidate) => chartValueEqual(candidate.xValue, point.xValue)) - const sharedY = - points.length > 1 && - points.every((candidate) => chartValueEqual(candidate.yValue, point.yValue)) - - if (sharedX || sharedY) { - const axis = sharedX ? 'x' : 'y' - const axisItem = sharedX ? x : y - const label = axisItem?.label ?? findSceneLabel(scene, `${axis}-label`) - const value = formatPointAxis(point, axis, axisItem, context) - return { - title: label ? `${label}: ${value}` : value, - rows: points.map((candidate) => ({ - label: formatTooltipGroup(candidate, group, context), - value: formatPointAxis( - candidate, - sharedX ? 'y' : 'x', - sharedX ? y : x, - context, - ), - color: candidate.color, - })), - } - } - - if (points.length > 1) { - return { - rows: points.map((candidate) => ({ - label: formatTooltipGroup(candidate, group, context), - value: `${formatPointAxis(candidate, 'x', x, context)} · ${formatPointAxis(candidate, 'y', y, context)}`, - color: candidate.color, - })), - } - } - - const items = options?.items - return { - title: - point.group == null || items?.some(isTooltipGroupItem) - ? undefined - : formatTooltipGroup(point, group, context), - color: - point.group == null || items?.some(isTooltipGroupItem) - ? undefined - : point.color, - rows: items - ? tooltipItemRows(point, items, context) - : [ - { - label: context.xLabel, - value: formatPointAxis(point, 'x', x, context), - }, - { - label: context.yLabel, - value: formatPointAxis(point, 'y', y, context), - }, - ], - } -} - -function orderTooltipPoints< - TDatum, - TXValue extends ChartValue, - TYValue extends ChartValue, ->( - points: readonly ChartPoint[], - scene: ChartScene, - sort: ChartTooltipOptions['sort'], -) { - if (sort === 'focus') return [...points] - if (typeof sort === 'function') return [...points].sort(sort) - return [...points].sort( - (left, right) => - colorOrder(scene, left.group) - colorOrder(scene, right.group), - ) -} - -function colorOrder(scene: ChartScene, group: ChartPoint['group']) { - const index = group == null ? -1 : scene.colors.domain.indexOf(group) - return index < 0 ? Number.MAX_SAFE_INTEGER : index -} - -function tooltipItemRows( - point: ChartPoint, - items: readonly ChartTooltipItem[], - context: ChartTooltipContentContext, -) { - return items.flatMap((item) => { - if (typeof item === 'string') { - if (item === 'group') { - return [ - { - label: 'Group', - value: point.groupLabel, - color: point.color, - }, - ] - } - return [ - { - label: item === 'x' ? context.xLabel : context.yLabel, - value: formatPointAxis(point, item, undefined, context), - }, - ] - } - if ('channel' in item) { - const text = item.text?.(point, context) - if (item.text && text == null) return [] - if (item.channel === 'group') { - return [ - { - label: item.label ?? 'Group', - value: text ?? point.groupLabel, - color: point.color, - }, - ] - } - return [ - { - label: - item.label ?? - (item.channel === 'x' ? context.xLabel : context.yLabel), - value: - text ?? formatPointAxis(point, item.channel, undefined, context), - }, - ] - } - if ('field' in item) { - const value = (point.datum as Record)[ - item.field - ] - if (value == null) return [] - const text = item.text?.(point, context) - if (item.text && text == null) return [] - return [ - { - label: item.label ?? item.field, - value: text ?? formatValue(value), - }, - ] - } - const value = item.text(point, context) - return value == null ? [] : [{ label: item.label ?? item.id, value }] - }) -} - -function findTooltipChannelItem( - items: readonly ChartTooltipItem[] | undefined, - channel: 'x' | 'y' | 'group', -): ChartTooltipChannelItem | undefined { - const item = items?.find( - (candidate) => tooltipItemChannel(candidate) === channel, - ) - return typeof item === 'object' && 'channel' in item - ? (item as ChartTooltipChannelItem) - : undefined -} - -function tooltipItemChannel(item: ChartTooltipItem) { - return typeof item === 'string' - ? item - : 'channel' in item - ? item.channel - : undefined -} - -function isTooltipGroupItem(item: ChartTooltipItem) { - return tooltipItemChannel(item) === 'group' -} - -function formatTooltipGroup( - point: ChartPoint, - item: ChartTooltipChannelItem | undefined, - context: ChartTooltipContentContext, -) { - return item?.text?.(point, context) ?? point.groupLabel -} - -function formatPointAxis( - point: ChartPoint, - axis: 'x' | 'y', - item: ChartTooltipChannelItem | undefined, - context: ChartTooltipContentContext, -) { - const itemText = item?.text?.(point, context) - if (itemText != null) return itemText - const start = axis === 'x' ? point.x1Value : point.y1Value - const end = axis === 'x' ? point.x2Value : point.y2Value - const interval = axis === 'x' ? point.xInterval : point.yInterval - if ( - interval === 'difference' && - typeof start === 'number' && - typeof end === 'number' - ) { - return formatValue(end - start) - } - if ( - interval === 'range' && - start !== undefined && - end !== undefined && - !chartValueEqual(start, end) - ) { - return `${formatValue(start)}–${formatValue(end)}` - } - return formatValue(axis === 'x' ? point.xValue : point.yValue) -} - -function findSceneLabel(scene: ChartScene, key: string) { - const axes = scene.nodes.find( - (node) => node.kind === 'group' && node.key === 'axes', - ) - if (axes?.kind !== 'group') return undefined - const label = axes.children.find((node) => node.key === key) - return label?.kind === 'label' ? label.text : undefined -} - -function paintPlainTooltip(tooltip: HTMLElement, text: string) { - setTooltipContentAccessibility(tooltip, text) - tooltip.textContent = text -} - -function paintStructuredTooltip( - tooltip: HTMLElement, - content: ChartTooltipContent, -) { - const document = tooltip.ownerDocument - const children: HTMLElement[] = [] - if (content.title) { - const title = document.createElement('div') - title.className = 'ts-chart-tooltip__title' - title.style.cssText = `display:flex;align-items:center;gap:.4rem;font-weight:650;margin-bottom:${content.rows.length ? '.3rem' : '0'}` - if (content.color) - title.append(createTooltipSwatch(document, content.color)) - title.append(content.title) - children.push(title) - } - if (content.rows.length) { - const rows = document.createElement('div') - rows.className = 'ts-chart-tooltip__rows' - rows.setAttribute('aria-hidden', 'true') - for (const row of content.rows) { - const line = document.createElement('div') - line.className = 'ts-chart-tooltip__row' - line.style.cssText = - 'display:grid;grid-template-columns:.55rem minmax(0,1fr) auto;align-items:center;column-gap:.4rem' - const swatch = row.color - ? createTooltipSwatch(document, row.color) - : document.createElement('span') - const label = document.createElement('span') - label.textContent = row.label - const value = document.createElement('span') - value.textContent = row.value - value.style.cssText = - 'text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap' - line.append(swatch, label, value) - rows.append(line) - } - children.push(rows) - } - tooltip.replaceChildren(...children) - setTooltipContentAccessibility(tooltip, content) -} - -function setTooltipContentAccessibility( - tooltip: HTMLElement, - content: ChartTooltipContent | string, -) { - if (typeof content === 'string') { - tooltip.removeAttribute('aria-label') - tooltip.style.whiteSpace = 'pre-wrap' - return - } - tooltip.style.whiteSpace = 'normal' - tooltip.setAttribute( - 'aria-label', - [content.title, ...content.rows.map((row) => `${row.label}: ${row.value}`)] - .filter(Boolean) - .join('\n'), - ) -} - -function configureTooltipSemantics( - tooltip: HTMLElement, - content: ChartTooltipContent | string, - custom: boolean, - pinned: boolean, -) { - if (custom && typeof content === 'string') { - tooltip.setAttribute('aria-label', content) - } - if (custom && pinned) { - tooltip.setAttribute('role', 'dialog') - tooltip.setAttribute('aria-modal', 'false') - tooltip.removeAttribute('aria-live') - return - } - tooltip.setAttribute('role', 'status') - tooltip.setAttribute('aria-live', 'polite') - tooltip.removeAttribute('aria-modal') - if (!custom && typeof content === 'string') { - tooltip.removeAttribute('aria-label') - } -} - -function createTooltipSwatch(document: Document, color: string) { - const swatch = document.createElement('span') - swatch.className = 'ts-chart-tooltip__swatch' - swatch.setAttribute('aria-hidden', 'true') - swatch.style.cssText = - 'display:block;width:.55rem;height:.55rem;border-radius:.15rem;box-shadow:inset 0 0 0 1px rgb(0 0 0/.12)' - swatch.style.background = color - return swatch -} - -function formatValue(value: ChartValue) { - return value instanceof Date - ? Number.isNaN(+value) - ? 'Invalid Date' - : value.toISOString().replace('T00:00:00.000Z', '') - : typeof value === 'number' - ? value.toLocaleString() - : String(value) -} - -function resolveTooltipAnchor( - point: ChartPoint, - points: readonly ChartPoint[], - scene: ChartScene, - pointer: ChartTooltipPosition | null, - options?: ChartTooltipOptions, -): ChartTooltipPosition { - const fallback = { x: point.x, y: point.y } - const anchor = options?.anchor ?? 'point' - if (anchor === 'point') return fallback - if (anchor === 'pointer') return pointer ?? fallback - if (anchor === 'group-center') { - let x1 = point.x - let x2 = point.x - let y1 = point.y - let y2 = point.y - for (const candidate of points) { - x1 = Math.min(x1, candidate.x) - x2 = Math.max(x2, candidate.x) - y1 = Math.min(y1, candidate.y) - y2 = Math.max(y2, candidate.y) - } - return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 } - } - const resolved = anchor(points, { - pointer, - chart: scene.chart, - width: scene.width, - height: scene.height, - }) - return resolved && Number.isFinite(resolved.x) && Number.isFinite(resolved.y) - ? resolved - : fallback -} - -function placeTooltip( - tooltip: HTMLElement, - anchorX: number, - anchorY: number, - boundary: TooltipBounds, - placement: - | 'auto' - | ChartTooltipPlacement - | readonly ChartTooltipPlacement[] - | undefined, - offset: number | undefined, -) { - const edge = 8 - const gap = - offset !== undefined && Number.isFinite(offset) ? Math.max(0, offset) : 10 - const width = tooltip.offsetWidth - const height = tooltip.offsetHeight - const minimumLeft = boundary.left + edge - const minimumTop = boundary.top + edge - const maxLeft = Math.max(minimumLeft, boundary.right - edge - width) - const maxTop = Math.max(minimumTop, boundary.bottom - edge - height) - const placements = - placement === undefined || placement === 'auto' - ? defaultTooltipPlacements - : Array.isArray(placement) - ? placement.length - ? placement - : defaultTooltipPlacements - : [placement as ChartTooltipPlacement] - const candidates = placements.map((candidate) => - tooltipPlacement(candidate, anchorX, anchorY, width, height, gap), - ) - let selected = candidates[0]! - let selectedOverflow = overflow(selected, width, height, boundary, edge) - for (const candidate of candidates) { - const candidateOverflow = overflow(candidate, width, height, boundary, edge) - if (candidateOverflow === 0) { - selected = candidate - break - } - if (candidateOverflow < selectedOverflow) { - selected = candidate - selectedOverflow = candidateOverflow - } - } - const left = clamp(selected.left, minimumLeft, maxLeft) - const top = clamp(selected.top, minimumTop, maxTop) - - tooltip.style.left = `${left}px` - tooltip.style.top = `${top}px` - tooltip.dataset.placement = selected.placement -} - -const defaultTooltipPlacements: readonly ChartTooltipPlacement[] = [ - 'top', - 'bottom', - 'right', - 'left', -] - -function tooltipPlacement( - placement: ChartTooltipPlacement, - anchorX: number, - anchorY: number, - width: number, - height: number, - gap: number, -) { - const xDirection = - placement.endsWith('right') || placement === 'right' - ? 1 - : placement.endsWith('left') || placement === 'left' - ? -1 - : 0 - const yDirection = - placement.startsWith('bottom') || placement === 'bottom' - ? 1 - : placement.startsWith('top') || placement === 'top' - ? -1 - : 0 - return { - placement, - left: anchorX + ((xDirection - 1) * width) / 2 + xDirection * gap, - top: anchorY + ((yDirection - 1) * height) / 2 + yDirection * gap, - } -} - -function overflow( - position: { left: number; top: number }, - width: number, - height: number, - boundary: TooltipBounds, - edge: number, -) { - return ( - Math.max(0, boundary.left + edge - position.left) + - Math.max(0, position.left + width + edge - boundary.right) + - Math.max(0, boundary.top + edge - position.top) + - Math.max(0, position.top + height + edge - boundary.bottom) - ) -} - -function sceneToClient( - element: Element, - scene: ChartScene, - position: ChartTooltipPosition, -): ChartTooltipPosition | null { - const bounds = element.getBoundingClientRect() - if (!bounds.width || !bounds.height || !scene.width || !scene.height) { - return null - } - return { - x: bounds.left + (position.x / scene.width) * bounds.width, - y: bounds.top + (position.y / scene.height) * bounds.height, - } -} - -function viewportBounds(document: Document): TooltipBounds { - const view = document.defaultView - const visualViewport = view?.visualViewport - const left = visualViewport?.offsetLeft ?? 0 - const top = visualViewport?.offsetTop ?? 0 - const width = - visualViewport?.width || - document.documentElement.clientWidth || - view?.innerWidth || - 0 - const height = - visualViewport?.height || - document.documentElement.clientHeight || - view?.innerHeight || - 0 - return { left, top, right: left + width, bottom: top + height } -} - -function pointInBounds(point: ChartTooltipPosition, bounds: TooltipBounds) { - return ( - point.x >= bounds.left && - point.x <= bounds.right && - point.y >= bounds.top && - point.y <= bounds.bottom - ) -} - -function clamp(value: number, minimum: number, maximum: number) { - return Math.max(minimum, Math.min(maximum, value)) -} diff --git a/packages/charts-core/src/runtime.test.ts b/packages/charts-core/src/runtime.test.ts index 1aeac17..c8dab34 100644 --- a/packages/charts-core/src/runtime.test.ts +++ b/packages/charts-core/src/runtime.test.ts @@ -8,6 +8,8 @@ import { defineChart } from './scene' import { renderChartSvgWithResources } from './svg-resources' import { focusX } from './focus' import { bandXAxes, linearAxes, utcXAxes } from './test-scales' +import { tooltip as tooltipExtension } from './tooltip' +import { portal as portalExtension } from './tooltip-portal' import type { ChartDefinition, ChartDefinitionOptions, @@ -207,7 +209,7 @@ describe('dynamic chart runtime', () => { const container = document.createElement('div') const options = { definition: withChartOptions(definition, { - tooltip: true, + tooltip: tooltipExtension, spatialIndex: firstIndex, }), width: 480, @@ -255,7 +257,9 @@ describe('dynamic chart runtime', () => { const onFocusChange = vi.fn() const format = (point: ChartPoint<(typeof data)[number]>) => point.datum.id const options = { - definition: withChartOptions(definition, { tooltip: { format } }), + definition: withChartOptions(definition, { + tooltip: { use: tooltipExtension, format }, + }), width: 480, height: 260, ariaLabel: 'Duplicate point key chart', @@ -293,7 +297,9 @@ describe('dynamic chart runtime', () => { host.update({ ...options, - definition: withChartOptions(definition, { tooltip: { format } }), + definition: withChartOptions(definition, { + tooltip: { use: tooltipExtension, format }, + }), }) expect(container.querySelector('.ts-chart-tooltip')?.textContent).toBe('c') @@ -346,7 +352,9 @@ describe('dynamic chart runtime', () => { const onFocusChange = vi.fn() const onSelect = vi.fn() const host = mountChart(container, { - definition: withChartOptions(definition, { tooltip: true }), + definition: withChartOptions(definition, { + tooltip: tooltipExtension, + }), width: 480, height: 260, ariaLabel: 'Keyboard chart', @@ -406,7 +414,7 @@ describe('dynamic chart runtime', () => { definition: defineChart({ marks: [lineY([{ x: 0, y: 4 }], { x: 'x', y: 'y' })], ...linearAxes([0, 1], [0, 4]), - tooltip: { portal: true }, + tooltip: { use: tooltipExtension, portal: portalExtension }, }), width: 480, height: 260, @@ -460,7 +468,9 @@ describe('dynamic chart runtime', () => { }) const container = document.createElement('div') const host = mountChart(container, { - definition: withChartOptions(definition, { tooltip: true }), + definition: withChartOptions(definition, { + tooltip: tooltipExtension, + }), width: 480, height: 260, ariaLabel: 'Decimal chart', @@ -483,7 +493,7 @@ describe('dynamic chart runtime', () => { definition: defineChart({ marks: [lineY([{ date, value: 4 }], { x: 'date', y: 'value' })], ...utcXAxes([new Date('2024-05-01T00:00:00.000Z'), date], [0, 4]), - tooltip: true, + tooltip: tooltipExtension, }), width: 480, height: 260, @@ -529,6 +539,7 @@ describe('dynamic chart runtime', () => { }, focus: 'group-x', tooltip: { + use: tooltipExtension, anchor: 'group-center', placement: 'right', offset: 0, @@ -581,7 +592,7 @@ describe('dynamic chart runtime', () => { }), ], ...linearAxes([0, 4], [0, 3]), - tooltip: true, + tooltip: tooltipExtension, }), width: 480, height: 260, @@ -614,7 +625,7 @@ describe('dynamic chart runtime', () => { label: 'Change', format: (value) => `${value} units`, }, - tooltip: true, + tooltip: tooltipExtension, }), width: 480, height: 260, @@ -647,6 +658,7 @@ describe('dynamic chart runtime', () => { const host = mountChart(container, { definition: withChartOptions(definition, { tooltip: { + use: tooltipExtension, content: ([point], context) => ({ title: point ? context.formatX(point.xValue) : undefined, rows: point @@ -705,6 +717,7 @@ describe('dynamic chart runtime', () => { ...bandXAxes(['A'], [0, 4]), focus: 'nearest', tooltip: { + use: tooltipExtension, items: [ { channel: 'y', @@ -765,7 +778,7 @@ describe('dynamic chart runtime', () => { definition: defineChart({ marks: [lineY(data, { x: 'x', y: 'y', key: 'id' })], ...linearAxes([0, 1], [0, 8]), - tooltip: true, + tooltip: tooltipExtension, }), width: 480, height: 260, @@ -870,7 +883,7 @@ describe('dynamic chart runtime', () => { definition: defineChart({ marks: [lineY([{ x: 0, y: 4 }], { x: 'x', y: 'y' })], ...linearAxes([0, 1], [0, 4]), - tooltip: { sticky: false }, + tooltip: { use: tooltipExtension, sticky: false }, }), width: 480, height: 260, @@ -1021,6 +1034,7 @@ describe('dynamic chart runtime', () => { focus: focusX, maxFocusDistance: 1_000, tooltip: { + use: tooltipExtension, formatGroup: (points) => points.map((point) => point.groupLabel).join(', '), }, diff --git a/packages/charts-core/src/scales.ts b/packages/charts-core/src/scales.ts index dca56cc..9e7035c 100644 --- a/packages/charts-core/src/scales.ts +++ b/packages/charts-core/src/scales.ts @@ -1,4 +1,3 @@ -import { scaleOrdinal } from 'd3-scale' import { validateInferredLogDomain } from './scale-input' import type { ChartColorScaleFactory, @@ -78,22 +77,25 @@ export function createColorScale( }) } const range = options?.range?.length ? options.range : theme.palette - const ordinal = scaleOrdinal() - .domain( - (options?.domain ?? values).filter( - (value): value is ChartKey => - typeof value === 'string' || typeof value === 'number', - ), - ) - .range(range) - const domain = ordinal.domain() + const domain = uniqueChartKeys(options?.domain ?? values) + const mappedKeys = domain.map(valueKey) const map = (value: ChartKey | null | undefined) => { if (value == null) return range[0] ?? 'currentColor' - return ordinal(value) + let index = mappedKeys.indexOf(valueKey(value)) + if (index < 0) index = mappedKeys.push(valueKey(value)) - 1 + return range[index % range.length] ?? 'currentColor' } return { type: 'ordinal', kind: 'categorical', domain, range, map } } +function uniqueChartKeys(values: readonly unknown[]): ChartKey[] { + return [...new Set(values.filter(isChartKey))] +} + +export function isChartKey(value: unknown): value is ChartKey { + return typeof value === 'string' || typeof value === 'number' +} + function isColorScaleFactory( source: ConfiguredColorScaleLike | ChartColorScaleFactory, ): source is ChartColorScaleFactory { @@ -104,10 +106,7 @@ function inferColorDomain( scale: InferableColorScaleLike, values: readonly unknown[], ): ChartKey[] { - const observed = values.filter( - (value): value is ChartKey => - typeof value === 'string' || typeof value === 'number', - ) + const observed = values.filter(isChartKey) const quantiles = scale.quantiles const thresholds = scale.thresholds if (quantiles) { @@ -150,15 +149,7 @@ function inferColorDomain( ) } - const domain: ChartKey[] = [] - const seen = new Set() - for (const value of observed) { - const key = `${typeof value}:${String(value)}` - if (seen.has(key)) continue - seen.add(key) - domain.push(value) - } - return domain + return uniqueChartKeys(observed) } function quantitativeColorValues(values: readonly ChartKey[]): number[] { diff --git a/packages/charts-core/src/tooltip-portal.ts b/packages/charts-core/src/tooltip-portal.ts new file mode 100644 index 0000000..ff05293 --- /dev/null +++ b/packages/charts-core/src/tooltip-portal.ts @@ -0,0 +1,221 @@ +import { + placeTooltip, + pointInBounds, + sceneToClient, + viewportBounds, +} from './tooltip-position' +import type { + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalOptions, + ChartTooltipPortalPositionContext, +} from './types' + +export const portal: ChartTooltipPortalExtension = { + id: 'portal', + create: createPortal, +} + +export type { + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalInput, + ChartTooltipPortalOptions, + ChartTooltipPortalPositionContext, +} from './types' + +function createPortal( + context: ChartTooltipPortalExtensionContext, + initialOptions: ChartTooltipPortalOptions, +): ChartTooltipPortalExtensionInstance { + let options = initialOptions + let listening = false + let usesPopover = false + let popoverOpen = false + let popoverFailed = false + let positionFrame: number | undefined + let positionContext: ChartTooltipPortalPositionContext | undefined + const { container, element } = context + const document = container.ownerDocument + const view = document.defaultView + const resizeObserver = view?.ResizeObserver + ? new view.ResizeObserver(schedulePosition) + : undefined + resizeObserver?.observe(element) + + function schedulePosition() { + if (!positionContext || positionFrame !== undefined) return + if (!view?.requestAnimationFrame) { + position(positionContext) + return + } + positionFrame = view.requestAnimationFrame(() => { + positionFrame = undefined + if (positionContext) position(positionContext) + }) + } + + function startListening() { + if (listening) return + listening = true + view?.addEventListener('scroll', schedulePosition, { + capture: true, + passive: true, + }) + view?.addEventListener('resize', schedulePosition, { passive: true }) + view?.visualViewport?.addEventListener('scroll', schedulePosition, { + passive: true, + }) + view?.visualViewport?.addEventListener('resize', schedulePosition, { + passive: true, + }) + resizeObserver?.observe(container) + } + + function stopListening() { + if (!listening) return + listening = false + view?.removeEventListener('scroll', schedulePosition, true) + view?.removeEventListener('resize', schedulePosition) + view?.visualViewport?.removeEventListener('scroll', schedulePosition) + view?.visualViewport?.removeEventListener('resize', schedulePosition) + resizeObserver?.unobserve(container) + if (positionFrame !== undefined) { + view?.cancelAnimationFrame?.(positionFrame) + positionFrame = undefined + } + } + + function configureParent() { + const showPopover = (element as HTMLElement & { showPopover?: () => void }) + .showPopover + if (typeof showPopover === 'function' && !popoverFailed) { + if (element.parentNode !== container) container.append(element) + element.setAttribute('popover', 'manual') + element.dataset.tsChartTooltipPortal = 'popover' + element.style.zIndex = '1' + usesPopover = true + } else { + moveToFallback() + } + Object.assign(element.style, { + position: 'fixed', + right: 'auto', + bottom: 'auto', + margin: '0', + }) + } + + function position(next: ChartTooltipPortalPositionContext) { + positionContext = next + startListening() + configureParent() + const anchor = sceneToClient( + next.surface.element, + next.scene.width, + next.scene.height, + next.anchor, + ) + const boundary = viewportBounds(document) + if ( + !anchor || + boundary.right <= boundary.left || + boundary.bottom <= boundary.top || + !pointInBounds(anchor, boundary) + ) { + hide(false) + return false + } + element.removeAttribute('hidden') + if (usesPopover && !showPopover()) { + popoverFailed = true + moveToFallback() + } + placeTooltip( + element, + anchor.x, + anchor.y, + boundary, + next.placement, + next.offset, + ) + return true + } + + function moveToFallback() { + hidePopover() + usesPopover = false + element.removeAttribute('popover') + element.dataset.tsChartTooltipPortal = 'fallback' + element.style.zIndex = '2147483647' + const parent = document.body ?? document.documentElement + if (element.parentNode !== parent) parent.append(element) + } + + function showPopover() { + if (!usesPopover) return false + if (popoverIsOpen()) return true + try { + ;(element as HTMLElement & { showPopover: () => void }).showPopover() + popoverOpen = true + return true + } catch { + popoverOpen = false + return false + } + } + + function hidePopover() { + if (!usesPopover) return + try { + if (popoverIsOpen()) { + ;(element as HTMLElement & { hidePopover?: () => void }).hidePopover?.() + } + } catch { + // Hiding or removing the element still closes its top-layer box. + } + popoverOpen = false + } + + function popoverIsOpen() { + if (!usesPopover) return false + try { + return element.matches(':popover-open') + } catch { + return popoverOpen + } + } + + function hide(stop = true) { + hidePopover() + element.setAttribute('hidden', '') + if (stop) { + positionContext = undefined + stopListening() + } + } + + configureParent() + + return { + update(nextOptions) { + options = nextOptions + void options + }, + position, + hide, + destroy() { + positionContext = undefined + stopListening() + hidePopover() + resizeObserver?.disconnect() + usesPopover = false + popoverOpen = false + popoverFailed = false + element.removeAttribute('popover') + delete element.dataset.tsChartTooltipPortal + }, + } +} diff --git a/packages/charts-core/src/tooltip-position.ts b/packages/charts-core/src/tooltip-position.ts new file mode 100644 index 0000000..3da4da2 --- /dev/null +++ b/packages/charts-core/src/tooltip-position.ts @@ -0,0 +1,155 @@ +import type { ChartTooltipPlacement, ChartTooltipPosition } from './types' + +export interface TooltipBounds { + left: number + top: number + right: number + bottom: number +} + +const defaultPlacements: readonly ChartTooltipPlacement[] = [ + 'top', + 'bottom', + 'right', + 'left', +] + +export function placeTooltip( + tooltip: HTMLElement, + anchorX: number, + anchorY: number, + boundary: TooltipBounds, + placement: + | 'auto' + | ChartTooltipPlacement + | readonly ChartTooltipPlacement[] + | undefined, + offset: number | undefined, +) { + const edge = 8 + const gap = + offset !== undefined && Number.isFinite(offset) ? Math.max(0, offset) : 10 + const width = tooltip.offsetWidth + const height = tooltip.offsetHeight + const minimumLeft = boundary.left + edge + const minimumTop = boundary.top + edge + const maxLeft = Math.max(minimumLeft, boundary.right - edge - width) + const maxTop = Math.max(minimumTop, boundary.bottom - edge - height) + const placements = + placement === undefined || placement === 'auto' + ? defaultPlacements + : Array.isArray(placement) + ? placement.length + ? placement + : defaultPlacements + : [placement as ChartTooltipPlacement] + const candidates = placements.map((candidate) => + tooltipPlacement(candidate, anchorX, anchorY, width, height, gap), + ) + let selected = candidates[0]! + let selectedOverflow = overflow(selected, width, height, boundary, edge) + for (const candidate of candidates) { + const candidateOverflow = overflow(candidate, width, height, boundary, edge) + if (candidateOverflow === 0) { + selected = candidate + break + } + if (candidateOverflow < selectedOverflow) { + selected = candidate + selectedOverflow = candidateOverflow + } + } + tooltip.style.left = `${clamp(selected.left, minimumLeft, maxLeft)}px` + tooltip.style.top = `${clamp(selected.top, minimumTop, maxTop)}px` + tooltip.dataset.placement = selected.placement +} + +export function sceneToClient( + element: Element, + width: number, + height: number, + position: ChartTooltipPosition, +): ChartTooltipPosition | null { + const bounds = element.getBoundingClientRect() + if (!bounds.width || !bounds.height || !width || !height) return null + return { + x: bounds.left + (position.x / width) * bounds.width, + y: bounds.top + (position.y / height) * bounds.height, + } +} + +export function viewportBounds(document: Document): TooltipBounds { + const view = document.defaultView + const visualViewport = view?.visualViewport + const left = visualViewport?.offsetLeft ?? 0 + const top = visualViewport?.offsetTop ?? 0 + const width = + visualViewport?.width || + document.documentElement.clientWidth || + view?.innerWidth || + 0 + const height = + visualViewport?.height || + document.documentElement.clientHeight || + view?.innerHeight || + 0 + return { left, top, right: left + width, bottom: top + height } +} + +export function pointInBounds( + point: ChartTooltipPosition, + bounds: TooltipBounds, +) { + return ( + point.x >= bounds.left && + point.x <= bounds.right && + point.y >= bounds.top && + point.y <= bounds.bottom + ) +} + +function tooltipPlacement( + placement: ChartTooltipPlacement, + anchorX: number, + anchorY: number, + width: number, + height: number, + gap: number, +) { + const xDirection = + placement.endsWith('right') || placement === 'right' + ? 1 + : placement.endsWith('left') || placement === 'left' + ? -1 + : 0 + const yDirection = + placement.startsWith('bottom') || placement === 'bottom' + ? 1 + : placement.startsWith('top') || placement === 'top' + ? -1 + : 0 + return { + placement, + left: anchorX + ((xDirection - 1) * width) / 2 + xDirection * gap, + top: anchorY + ((yDirection - 1) * height) / 2 + yDirection * gap, + } +} + +function overflow( + position: { left: number; top: number }, + width: number, + height: number, + boundary: TooltipBounds, + edge: number, +) { + return ( + Math.max(0, boundary.left + edge - position.left) + + Math.max(0, position.left + width + edge - boundary.right) + + Math.max(0, boundary.top + edge - position.top) + + Math.max(0, position.top + height + edge - boundary.bottom) + ) +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.max(minimum, Math.min(maximum, value)) +} diff --git a/packages/charts-core/src/tooltip.ts b/packages/charts-core/src/tooltip.ts new file mode 100644 index 0000000..1abd025 --- /dev/null +++ b/packages/charts-core/src/tooltip.ts @@ -0,0 +1,768 @@ +import { placeTooltip } from './tooltip-position' +import type { + ChartPoint, + ChartScene, + ChartTooltipChannelItem, + ChartTooltipContent, + ChartTooltipContentContext, + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipItem, + ChartTooltipOptions, + ChartTooltipPaintContext, + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalOptions, + ChartValue, +} from './types' + +export const tooltip: ChartTooltipExtension = { + id: 'tooltip', + create: createTooltipExtension, +} + +export type { + ChartTooltipAnchor, + ChartTooltipAnchorContext, + ChartTooltipChannelItem, + ChartTooltipContent, + ChartTooltipContentContext, + ChartTooltipDatumItem, + ChartTooltipDerivedItem, + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipInput, + ChartTooltipItem, + ChartTooltipItemBase, + ChartTooltipOptions, + ChartTooltipPaintContext, + ChartTooltipPlacement, + ChartTooltipPosition, + ChartTooltipRow, + ChartTooltipSort, +} from './types' + +function createTooltipExtension< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + extensionContext: ChartTooltipExtensionContext, +): ChartTooltipExtensionInstance { + let options: ChartTooltipOptions = {} + let element: HTMLDivElement | undefined + let bodyElement: HTMLDivElement | undefined + let activeBodyChange: + ReturnType | undefined + let bodyVisible = false + let bodyScene: ChartScene | undefined + let bodyPoints: readonly ChartPoint[] = [] + let bodyPinned = false + let bodyDirty = false + let paintContext: + ChartTooltipPaintContext | undefined + let anchor: { x: number; y: number } | null = null + let positionFrame: number | undefined + let resizeObserver: ResizeObserver | undefined + let portalExtension: ChartTooltipPortalExtension | undefined + let portalInstance: ChartTooltipPortalExtensionInstance | undefined + const { container } = extensionContext + const view = container.ownerDocument.defaultView + + function update(nextOptions: ChartTooltipOptions) { + if (options !== nextOptions) bodyDirty = true + options = nextOptions + if (element) syncPortal() + } + + function paint( + nextContext: ChartTooltipPaintContext, + ) { + paintContext = nextContext + const tooltipElement = ensureElement() + syncPortal() + tooltipElement.className = options.className + ? `ts-chart-tooltip ${options.className}` + : 'ts-chart-tooltip' + const points = orderTooltipPoints( + nextContext.points, + nextContext.scene, + options.sort, + ) + const contentContext = createTooltipContentContext( + nextContext.scene, + options, + ) + const content = options.content?.(points, contentContext) + const text = + content === undefined + ? (options.formatGroup?.(points) ?? options.format?.(nextContext.point)) + : undefined + const resolvedContent = + content ?? + text ?? + defaultTooltipContent(points, nextContext.scene, options) + const custom = renderTooltipBody( + tooltipElement, + points, + resolvedContent, + nextContext.pinned, + ) + if (!custom) { + if (typeof resolvedContent === 'string') { + paintPlainTooltip(tooltipElement, resolvedContent) + } else { + paintStructuredTooltip(tooltipElement, resolvedContent) + } + } + configureTooltipSemantics( + tooltipElement, + resolvedContent, + custom, + nextContext.pinned, + ) + tooltipElement.style.pointerEvents = nextContext.pinned ? 'auto' : 'none' + tooltipElement.style.userSelect = nextContext.pinned ? 'text' : 'none' + tooltipElement.dataset.sticky = String(nextContext.pinned) + tooltipElement.style.visibility = 'hidden' + tooltipElement.removeAttribute('hidden') + anchor = resolveTooltipAnchor( + nextContext.point, + points, + nextContext.scene, + nextContext.pointer, + options, + ) + position() + tooltipElement.style.removeProperty('visibility') + } + + function ensureElement() { + if (element) return element + element = createTooltip(container.ownerDocument) + element.addEventListener('keydown', handleKeyDown) + resizeObserver = view?.ResizeObserver + ? new view.ResizeObserver(schedulePosition) + : undefined + resizeObserver?.observe(element) + container.append(element) + return element + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== 'Escape' || !paintContext?.pinned) return + event.preventDefault() + event.stopPropagation() + extensionContext.dismiss() + } + + function schedulePosition() { + if (!paintContext || !anchor || positionFrame !== undefined) return + if (!view?.requestAnimationFrame) { + position() + return + } + positionFrame = view.requestAnimationFrame(() => { + positionFrame = undefined + position() + }) + } + + function position() { + if (!paintContext || !element || !anchor) return + if (portalInstance) { + const visible = portalInstance.position({ + scene: paintContext.scene, + surface: paintContext.surface, + anchor, + placement: options.placement, + offset: options.offset, + }) + if (!visible) element.setAttribute('hidden', '') + return + } + placeTooltip( + element, + anchor.x, + anchor.y, + { + left: 0, + top: 0, + right: paintContext.scene.width, + bottom: paintContext.scene.height, + }, + options.placement, + options.offset, + ) + } + + function syncPortal() { + if (!element) return + const input = options.portal + const nextExtension = input + ? 'create' in input + ? input + : input.use + : undefined + const nextOptions: ChartTooltipPortalOptions = + input && 'use' in input ? input : {} + if (nextExtension !== portalExtension) { + portalInstance?.destroy() + portalInstance = undefined + portalExtension = nextExtension + if (nextExtension) { + portalInstance = nextExtension.create( + { + container, + element, + schedulePosition, + }, + nextOptions, + ) + } else { + moveToContainer() + } + } else { + portalInstance?.update(nextOptions) + } + } + + function moveToContainer() { + if (!element) return + if (element.parentNode !== container) container.append(element) + element.removeAttribute('popover') + delete element.dataset.tsChartTooltipPortal + Object.assign(element.style, { + position: 'absolute', + zIndex: '1', + right: 'auto', + bottom: 'auto', + margin: '0', + }) + } + + function renderTooltipBody( + tooltipElement: HTMLDivElement, + points: readonly ChartPoint[], + content: ChartTooltipContent | string, + pinned: boolean, + ) { + const callback = extensionContext.bodyChange() + if (!callback) { + deactivateTooltipBody() + return false + } + if (activeBodyChange !== callback) { + activeBodyChange?.(null) + activeBodyChange = callback + bodyVisible = false + bodyElement = undefined + } + if (!bodyElement) { + bodyElement = tooltipElement.ownerDocument.createElement('div') + bodyElement.className = 'ts-chart-tooltip__body' + tooltipElement.replaceChildren(bodyElement) + } + bodyElement.toggleAttribute('inert', !pinned) + setTooltipContentAccessibility(tooltipElement, content) + const changed = + bodyDirty || + !bodyVisible || + bodyScene !== paintContext?.scene || + bodyPinned !== pinned || + !samePointList(points, bodyPoints) + bodyDirty = false + bodyVisible = true + bodyScene = paintContext?.scene + bodyPoints = points + bodyPinned = pinned + if (changed) { + callback({ + element: bodyElement, + points, + content, + pinned, + dismiss: extensionContext.dismiss, + }) + } + return true + } + + function hideTooltipBody() { + if (!bodyVisible) return + bodyVisible = false + activeBodyChange?.(null) + } + + function deactivateTooltipBody() { + hideTooltipBody() + activeBodyChange = undefined + bodyElement = undefined + bodyScene = undefined + bodyPoints = [] + } + + function hide() { + paintContext = undefined + anchor = null + portalInstance?.hide() + element?.setAttribute('hidden', '') + hideTooltipBody() + } + + function destroy() { + hide() + deactivateTooltipBody() + portalInstance?.destroy() + portalInstance = undefined + portalExtension = undefined + if (positionFrame !== undefined) { + view?.cancelAnimationFrame?.(positionFrame) + positionFrame = undefined + } + resizeObserver?.disconnect() + resizeObserver = undefined + element?.remove() + element = undefined + } + + return { + update, + paint, + hide, + contains: (target) => Boolean(target && element?.contains(target as Node)), + destroy, + } +} + +function samePointList( + left: readonly ChartPoint[], + right: readonly ChartPoint[], +) { + return ( + left.length === right.length && + left.every( + (point, index) => + point.key === right[index]?.key && + point.markId === right[index]?.markId && + point.datumIndex === right[index]?.datumIndex, + ) + ) +} + +function createTooltip(document: Document) { + const tooltipElement = document.createElement('div') + tooltipElement.className = 'ts-chart-tooltip' + tooltipElement.setAttribute('role', 'status') + tooltipElement.setAttribute('aria-live', 'polite') + Object.assign(tooltipElement.style, { + position: 'absolute', + zIndex: '1', + maxWidth: 'min(24rem, 80%)', + padding: '0.4rem 0.55rem', + border: '1px solid color-mix(in srgb, CanvasText 18%, transparent)', + borderRadius: '0.45rem', + background: 'Canvas', + color: 'CanvasText', + boxShadow: '0 6px 24px rgb(0 0 0 / 0.14)', + font: '500 0.75rem/1.3 system-ui, sans-serif', + pointerEvents: 'none', + overflowWrap: 'anywhere', + }) + tooltipElement.hidden = true + return tooltipElement +} + +function createTooltipContentContext( + scene: ChartScene, + options?: ChartTooltipOptions, +): ChartTooltipContentContext { + const x = findTooltipChannelItem(options?.items, 'x') + const y = findTooltipChannelItem(options?.items, 'y') + return { + xLabel: x?.label ?? findSceneLabel(scene, 'x-label') ?? 'x', + yLabel: y?.label ?? findSceneLabel(scene, 'y-label') ?? 'y', + formatX: formatValue, + formatY: formatValue, + } +} + +function defaultTooltipContent( + points: readonly ChartPoint[], + scene: ChartScene, + options?: ChartTooltipOptions, +): ChartTooltipContent { + const point = points[0] + if (!point) return { rows: [] } + const context = createTooltipContentContext(scene, options) + const x = findTooltipChannelItem(options?.items, 'x') + const y = findTooltipChannelItem(options?.items, 'y') + const group = findTooltipChannelItem(options?.items, 'group') + const sharedX = + points.length > 1 && + points.every((candidate) => chartValueEqual(candidate.xValue, point.xValue)) + const sharedY = + points.length > 1 && + points.every((candidate) => chartValueEqual(candidate.yValue, point.yValue)) + + if (sharedX || sharedY) { + const axis = sharedX ? 'x' : 'y' + const axisItem = sharedX ? x : y + const label = axisItem?.label ?? findSceneLabel(scene, `${axis}-label`) + const value = formatPointAxis(point, axis, axisItem, context) + return { + title: label ? `${label}: ${value}` : value, + rows: points.map((candidate) => ({ + label: formatTooltipGroup(candidate, group, context), + value: formatPointAxis( + candidate, + sharedX ? 'y' : 'x', + sharedX ? y : x, + context, + ), + color: candidate.color, + })), + } + } + + if (points.length > 1) { + return { + rows: points.map((candidate) => ({ + label: formatTooltipGroup(candidate, group, context), + value: `${formatPointAxis(candidate, 'x', x, context)} · ${formatPointAxis(candidate, 'y', y, context)}`, + color: candidate.color, + })), + } + } + + const items = options?.items + return { + title: + point.group == null || items?.some(isTooltipGroupItem) + ? undefined + : formatTooltipGroup(point, group, context), + color: + point.group == null || items?.some(isTooltipGroupItem) + ? undefined + : point.color, + rows: items + ? tooltipItemRows(point, items, context) + : [ + { + label: context.xLabel, + value: formatPointAxis(point, 'x', x, context), + }, + { + label: context.yLabel, + value: formatPointAxis(point, 'y', y, context), + }, + ], + } +} + +function orderTooltipPoints< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + scene: ChartScene, + sort: ChartTooltipOptions['sort'], +) { + if (sort === 'focus') return [...points] + if (typeof sort === 'function') return [...points].sort(sort) + return [...points].sort( + (left, right) => + colorOrder(scene, left.group) - colorOrder(scene, right.group), + ) +} + +function colorOrder(scene: ChartScene, group: ChartPoint['group']) { + const index = group == null ? -1 : scene.colors.domain.indexOf(group) + return index < 0 ? Number.MAX_SAFE_INTEGER : index +} + +function tooltipItemRows( + point: ChartPoint, + items: readonly ChartTooltipItem[], + context: ChartTooltipContentContext, +) { + return items.flatMap((item) => { + if (typeof item === 'string') { + if (item === 'group') { + return [ + { + label: 'Group', + value: point.groupLabel, + color: point.color, + }, + ] + } + return [ + { + label: item === 'x' ? context.xLabel : context.yLabel, + value: formatPointAxis(point, item, undefined, context), + }, + ] + } + if ('channel' in item) { + const text = item.text?.(point, context) + if (item.text && text == null) return [] + if (item.channel === 'group') { + return [ + { + label: item.label ?? 'Group', + value: text ?? point.groupLabel, + color: point.color, + }, + ] + } + return [ + { + label: + item.label ?? + (item.channel === 'x' ? context.xLabel : context.yLabel), + value: + text ?? formatPointAxis(point, item.channel, undefined, context), + }, + ] + } + if ('field' in item) { + const value = (point.datum as Record)[ + item.field + ] + if (value == null) return [] + const text = item.text?.(point, context) + if (item.text && text == null) return [] + return [ + { + label: item.label ?? item.field, + value: text ?? formatValue(value), + }, + ] + } + const value = item.text(point, context) + return value == null ? [] : [{ label: item.label ?? item.id, value }] + }) +} + +function findTooltipChannelItem( + items: readonly ChartTooltipItem[] | undefined, + channel: 'x' | 'y' | 'group', +): ChartTooltipChannelItem | undefined { + const item = items?.find( + (candidate) => tooltipItemChannel(candidate) === channel, + ) + return typeof item === 'object' && 'channel' in item + ? (item as ChartTooltipChannelItem) + : undefined +} + +function tooltipItemChannel(item: ChartTooltipItem) { + return typeof item === 'string' + ? item + : 'channel' in item + ? item.channel + : undefined +} + +function isTooltipGroupItem(item: ChartTooltipItem) { + return tooltipItemChannel(item) === 'group' +} + +function formatTooltipGroup( + point: ChartPoint, + item: ChartTooltipChannelItem | undefined, + context: ChartTooltipContentContext, +) { + return item?.text?.(point, context) ?? point.groupLabel +} + +function formatPointAxis( + point: ChartPoint, + axis: 'x' | 'y', + item: ChartTooltipChannelItem | undefined, + context: ChartTooltipContentContext, +) { + const itemText = item?.text?.(point, context) + if (itemText != null) return itemText + const start = axis === 'x' ? point.x1Value : point.y1Value + const end = axis === 'x' ? point.x2Value : point.y2Value + const interval = axis === 'x' ? point.xInterval : point.yInterval + if ( + interval === 'difference' && + typeof start === 'number' && + typeof end === 'number' + ) { + return formatValue(end - start) + } + if ( + interval === 'range' && + start !== undefined && + end !== undefined && + !chartValueEqual(start, end) + ) { + return `${formatValue(start)}–${formatValue(end)}` + } + return formatValue(axis === 'x' ? point.xValue : point.yValue) +} + +function findSceneLabel(scene: ChartScene, key: string) { + const axes = scene.nodes.find( + (node) => node.kind === 'group' && node.key === 'axes', + ) + if (axes?.kind !== 'group') return undefined + const label = axes.children.find((node) => node.key === key) + return label?.kind === 'label' ? label.text : undefined +} + +function paintPlainTooltip(tooltipElement: HTMLElement, text: string) { + setTooltipContentAccessibility(tooltipElement, text) + tooltipElement.textContent = text +} + +function paintStructuredTooltip( + tooltipElement: HTMLElement, + content: ChartTooltipContent, +) { + const document = tooltipElement.ownerDocument + const children: HTMLElement[] = [] + if (content.title) { + const title = document.createElement('div') + title.className = 'ts-chart-tooltip__title' + title.style.cssText = `display:flex;align-items:center;gap:.4rem;font-weight:650;margin-bottom:${content.rows.length ? '.3rem' : '0'}` + if (content.color) + title.append(createTooltipSwatch(document, content.color)) + title.append(content.title) + children.push(title) + } + if (content.rows.length) { + const rows = document.createElement('div') + rows.className = 'ts-chart-tooltip__rows' + rows.setAttribute('aria-hidden', 'true') + for (const row of content.rows) { + const line = document.createElement('div') + line.className = 'ts-chart-tooltip__row' + line.style.cssText = + 'display:grid;grid-template-columns:.55rem minmax(0,1fr) auto;align-items:center;column-gap:.4rem' + const swatch = row.color + ? createTooltipSwatch(document, row.color) + : document.createElement('span') + const label = document.createElement('span') + label.textContent = row.label + const value = document.createElement('span') + value.textContent = row.value + value.style.cssText = + 'text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap' + line.append(swatch, label, value) + rows.append(line) + } + children.push(rows) + } + tooltipElement.replaceChildren(...children) + setTooltipContentAccessibility(tooltipElement, content) +} + +function setTooltipContentAccessibility( + tooltipElement: HTMLElement, + content: ChartTooltipContent | string, +) { + if (typeof content === 'string') { + tooltipElement.removeAttribute('aria-label') + tooltipElement.style.whiteSpace = 'pre-wrap' + return + } + tooltipElement.style.whiteSpace = 'normal' + tooltipElement.setAttribute( + 'aria-label', + [content.title, ...content.rows.map((row) => `${row.label}: ${row.value}`)] + .filter(Boolean) + .join('\n'), + ) +} + +function configureTooltipSemantics( + tooltipElement: HTMLElement, + content: ChartTooltipContent | string, + custom: boolean, + pinned: boolean, +) { + if (custom && typeof content === 'string') { + tooltipElement.setAttribute('aria-label', content) + } + if (custom && pinned) { + tooltipElement.setAttribute('role', 'dialog') + tooltipElement.setAttribute('aria-modal', 'false') + tooltipElement.removeAttribute('aria-live') + return + } + tooltipElement.setAttribute('role', 'status') + tooltipElement.setAttribute('aria-live', 'polite') + tooltipElement.removeAttribute('aria-modal') + if (!custom && typeof content === 'string') { + tooltipElement.removeAttribute('aria-label') + } +} + +function createTooltipSwatch(document: Document, color: string) { + const swatch = document.createElement('span') + swatch.className = 'ts-chart-tooltip__swatch' + swatch.setAttribute('aria-hidden', 'true') + swatch.style.cssText = + 'display:block;width:.55rem;height:.55rem;border-radius:.15rem;box-shadow:inset 0 0 0 1px rgb(0 0 0/.12)' + swatch.style.background = color + return swatch +} + +function formatValue(value: ChartValue) { + return value instanceof Date + ? Number.isNaN(+value) + ? 'Invalid Date' + : value.toISOString().replace('T00:00:00.000Z', '') + : typeof value === 'number' + ? value.toLocaleString() + : String(value) +} + +function resolveTooltipAnchor( + point: ChartPoint, + points: readonly ChartPoint[], + scene: ChartScene, + pointer: { x: number; y: number } | null, + options?: ChartTooltipOptions, +): { x: number; y: number } { + const fallback = { x: point.x, y: point.y } + const anchor = options?.anchor ?? 'point' + if (anchor === 'point') return fallback + if (anchor === 'pointer') return pointer ?? fallback + if (anchor === 'group-center') { + let x1 = point.x + let x2 = point.x + let y1 = point.y + let y2 = point.y + for (const candidate of points) { + x1 = Math.min(x1, candidate.x) + x2 = Math.max(x2, candidate.x) + y1 = Math.min(y1, candidate.y) + y2 = Math.max(y2, candidate.y) + } + return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 } + } + const resolved = anchor(points, { + pointer, + chart: scene.chart, + width: scene.width, + height: scene.height, + }) + return resolved && Number.isFinite(resolved.x) && Number.isFinite(resolved.y) + ? resolved + : fallback +} + +function chartValueEqual(left: ChartValue, right: ChartValue) { + return left instanceof Date && right instanceof Date + ? left.getTime() === right.getTime() + : left === right +} diff --git a/packages/charts-core/src/type-contract.test.ts b/packages/charts-core/src/type-contract.test.ts index 7910bdb..9cdb90e 100644 --- a/packages/charts-core/src/type-contract.test.ts +++ b/packages/charts-core/src/type-contract.test.ts @@ -14,6 +14,8 @@ import type { RectOptions } from './rect' import { ruleX, ruleY } from './rule' import { createChartRuntime } from './runtime' import { createChartScene, defineChart } from './scene' +import { tooltip } from './tooltip' +import { portal } from './tooltip-portal' import type { ChartDefinition, ChartFocusStrategy, @@ -347,7 +349,8 @@ if (false) { const interactiveCategoricalDefinition = defineChart(staticDefinition, { focus: categoricalFocus, tooltip: { - portal: true, + use: tooltip, + portal, items: [ { channel: 'x', @@ -485,6 +488,7 @@ if (false) { // @ts-expect-error Boolean datum fields require a derived text item. defineChart(staticDefinition, { tooltip: { + use: tooltip, items: [ { field: 'enabled', @@ -492,11 +496,20 @@ if (false) { ], }, }) + // @ts-expect-error Configured tooltip options require an extension token. + defineChart(staticDefinition, { tooltip: { sticky: false } }) + // @ts-expect-error Portaling requires the explicit portal extension. + defineChart(staticDefinition, { + tooltip: { + use: tooltip, + portal: true, + }, + }) mountChart(container, { definition: staticDefinition, ariaLabel: 'Definition-only tooltip configuration', // @ts-expect-error Chart behavior belongs to the definition, not the host. - tooltip: true, + tooltip, }) mountChart(container, { // @ts-expect-error A numeric-x focus strategy cannot consume string-x points. diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index d922c36..77c9a1a 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -390,8 +390,8 @@ export interface ChartDefinitionOptions< animate?: boolean | ChartAnimationOptions keyboard?: boolean tooltip?: - | boolean - | ChartTooltipOptions, NoInfer, NoInfer> + | false + | ChartTooltipInput, NoInfer, NoInfer> } interface StoredChartDefinitionOptions { @@ -400,7 +400,7 @@ interface StoredChartDefinitionOptions { spatialIndex?: ChartSpatialIndexFactory animate?: boolean | ChartAnimationOptions keyboard?: boolean - tooltip?: boolean | ChartTooltipOptions + tooltip?: false | ChartTooltipInput } export interface StaticChartDefinition< @@ -776,7 +776,7 @@ export interface ChartTooltipOptions< TYValue extends ChartValue = ChartValue, > { className?: string - portal?: boolean + portal?: ChartTooltipPortalInput items?: readonly ChartTooltipItem[] sort?: ChartTooltipSort anchor?: ChartTooltipAnchor @@ -793,6 +793,100 @@ export interface ChartTooltipOptions< sticky?: boolean } +export type ChartExtensionInput = + TExtension | ({ use: TExtension } & TOptions) + +export type ChartTooltipInput< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> = ChartExtensionInput< + ChartTooltipExtension, + ChartTooltipOptions +> + +export interface ChartTooltipExtension { + readonly id: string + create: ( + context: ChartTooltipExtensionContext, + ) => ChartTooltipExtensionInstance +} + +export interface ChartTooltipExtensionContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + container: HTMLElement + dismiss: () => void + bodyChange: () => + | (( + target: ChartTooltipBodyTarget | null, + ) => void) + | undefined +} + +export interface ChartTooltipPaintContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + point: ChartPoint + points: readonly ChartPoint[] + scene: ChartScene + surface: ChartSurface + pointer: ChartTooltipPosition | null + pinned: boolean +} + +export interface ChartTooltipExtensionInstance< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + update: (options: ChartTooltipOptions) => void + paint: (context: ChartTooltipPaintContext) => void + hide: () => void + contains: (target: EventTarget | null) => boolean + destroy: () => void +} + +export type ChartTooltipPortalOptions = Record + +export type ChartTooltipPortalInput = ChartExtensionInput< + ChartTooltipPortalExtension, + ChartTooltipPortalOptions +> + +export interface ChartTooltipPortalExtension { + readonly id: string + create: ( + context: ChartTooltipPortalExtensionContext, + options: ChartTooltipPortalOptions, + ) => ChartTooltipPortalExtensionInstance +} + +export interface ChartTooltipPortalExtensionContext { + container: HTMLElement + element: HTMLElement + schedulePosition: () => void +} + +export interface ChartTooltipPortalPositionContext { + scene: ChartScene + surface: ChartSurface + anchor: ChartTooltipPosition + placement?: 'auto' | ChartTooltipPlacement | readonly ChartTooltipPlacement[] + offset?: number +} + +export interface ChartTooltipPortalExtensionInstance { + update: (options: ChartTooltipPortalOptions) => void + position: (context: ChartTooltipPortalPositionContext) => boolean + hide: () => void + destroy: () => void +} + export type ChartTooltipPlacement = | 'top' | 'top-right' diff --git a/packages/charts-scales/LICENSE b/packages/charts-scales/LICENSE new file mode 100644 index 0000000..f889227 --- /dev/null +++ b/packages/charts-scales/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-present Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/charts-scales/README.md b/packages/charts-scales/README.md new file mode 100644 index 0000000..341ade4 --- /dev/null +++ b/packages/charts-scales/README.md @@ -0,0 +1,13 @@ +# TanStack Charts Scales + +Small callable scales for TanStack Charts. Import only the required family: + +```ts +import { scaleLinear } from '@tanstack/charts-scales/linear' +import { scaleBand } from '@tanstack/charts-scales/band' +``` + +The API covers the D3 scale methods used by Charts, but is not a complete +`d3-scale` replacement. Linear scales are numeric and two-stop. Formatting, +time, logarithmic, sequential, threshold, and generic interpolation semantics +remain application-owned or available from D3. diff --git a/packages/charts-scales/package.json b/packages/charts-scales/package.json new file mode 100644 index 0000000..c45d359 --- /dev/null +++ b/packages/charts-scales/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/charts-scales", + "version": "0.0.1", + "private": false, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/charts.git", + "directory": "packages/charts-scales" + }, + "type": "module", + "sideEffects": false, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "dependencies": { + "d3-array": "3.2.4" + }, + "devDependencies": { + "@types/d3-array": "^3.2.2", + "@types/d3-scale": "^4.0.9", + "d3-scale": "4.0.2" + }, + "exports": { + "./band": "./src/band.ts", + "./linear": "./src/linear.ts", + "./ordinal": "./src/ordinal.ts", + "./point": "./src/point.ts" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./band": { + "types": "./dist/band.d.ts", + "import": "./dist/band.js" + }, + "./linear": { + "types": "./dist/linear.d.ts", + "import": "./dist/linear.js" + }, + "./ordinal": { + "types": "./dist/ordinal.d.ts", + "import": "./dist/ordinal.js" + }, + "./point": { + "types": "./dist/point.d.ts", + "import": "./dist/point.js" + } + } + } +} diff --git a/packages/charts-scales/src/band-kernel.ts b/packages/charts-scales/src/band-kernel.ts new file mode 100644 index 0000000..7d4fb48 --- /dev/null +++ b/packages/charts-scales/src/band-kernel.ts @@ -0,0 +1,182 @@ +import { intern, uniqueDomain, type ScaleDomainValue } from './intern' + +export interface BandScale { + (value: TDomain): number | undefined + domain(): TDomain[] + domain(values: Iterable): BandScale + range(): [number, number] + range(values: Iterable): BandScale + rangeRound(values: Iterable): BandScale + bandwidth(): number + step(): number + round(): boolean + round(value: boolean): BandScale + padding(): number + padding(value: number): BandScale + paddingInner(): number + paddingInner(value: number): BandScale + paddingOuter(): number + paddingOuter(value: number): BandScale + align(): number + align(value: number): BandScale + copy(): BandScale +} + +export interface PointScale { + (value: TDomain): number | undefined + domain(): TDomain[] + domain(values: Iterable): PointScale + range(): [number, number] + range(values: Iterable): PointScale + rangeRound(values: Iterable): PointScale + bandwidth(): 0 + step(): number + round(): boolean + round(value: boolean): PointScale + padding(): number + padding(value: number): PointScale + align(): number + align(value: number): PointScale + copy(): PointScale +} + +export function createBandScale( + point: false, + first?: Iterable | Iterable, + second?: Iterable, +): BandScale +export function createBandScale( + point: true, + first?: Iterable | Iterable, + second?: Iterable, +): PointScale +export function createBandScale( + point: boolean, + first?: Iterable | Iterable, + second?: Iterable, +): BandScale | PointScale { + let domain: TDomain[] = [] + let index = new Map() + let positions: number[] = [] + let range: [number, number] = [0, 1] + let step = 1 + let bandwidth = point ? 0 : 1 + let round = false + let paddingInner = point ? 1 : 0 + let paddingOuter = 0 + let align = 0.5 + + const scale = ((value: TDomain) => { + const position = index.get(intern(value)) + return position === undefined ? undefined : positions[position] + }) as BandScale + + const rescale = () => { + const count = domain.length + const reverse = range[1] < range[0] + let start = reverse ? range[1] : range[0] + const stop = reverse ? range[0] : range[1] + step = (stop - start) / Math.max(1, count - paddingInner + paddingOuter * 2) + if (round) step = Math.floor(step) + start += (stop - start - step * (count - paddingInner)) * align + bandwidth = step * (1 - paddingInner) + if (round) { + start = Math.round(start) + bandwidth = Math.round(bandwidth) + } + positions = Array.from( + { length: count }, + (_value, position) => start + step * position, + ) + if (reverse) positions.reverse() + return scale + } + + scale.domain = ((values?: Iterable) => { + if (values === undefined) return domain.slice() + const next = uniqueDomain(values) + domain = next.domain + index = next.index + return rescale() + }) as BandScale['domain'] + + scale.range = ((values?: Iterable) => { + if (values === undefined) return [...range] + range = pair(values) + return rescale() + }) as BandScale['range'] + + scale.rangeRound = (values) => { + range = pair(values) + round = true + return rescale() + } + scale.bandwidth = () => bandwidth + scale.step = () => step + scale.round = ((value?: boolean) => { + if (value === undefined) return round + round = Boolean(value) + return rescale() + }) as BandScale['round'] + scale.padding = ((value?: number) => { + if (value === undefined) return paddingInner + paddingOuter = number(value) + paddingInner = Math.min(1, paddingOuter) + return rescale() + }) as BandScale['padding'] + scale.paddingInner = ((value?: number) => { + if (value === undefined) return paddingInner + paddingInner = Math.min(1, number(value)) + return rescale() + }) as BandScale['paddingInner'] + scale.paddingOuter = ((value?: number) => { + if (value === undefined) return paddingOuter + paddingOuter = number(value) + return rescale() + }) as BandScale['paddingOuter'] + scale.align = ((value?: number) => { + if (value === undefined) return align + align = Math.max(0, Math.min(1, number(value))) + return rescale() + }) as BandScale['align'] + scale.copy = () => { + const copy = createBandScale(false, domain, range) + return copy + .round(round) + .paddingInner(paddingInner) + .paddingOuter(paddingOuter) + .align(align) + } + + if (point) { + const pointScale = scale as unknown as PointScale + pointScale.bandwidth = () => 0 + pointScale.padding = scale.paddingOuter as PointScale['padding'] + pointScale.copy = () => { + const copy = createBandScale(true, domain, range) + return copy.round(round).padding(paddingOuter).align(align) + } + delete (pointScale as unknown as Partial>).paddingInner + delete (pointScale as unknown as Partial>).paddingOuter + } + + rescale() + if (second !== undefined) { + scale.domain(first as Iterable).range(second) + } else if (first !== undefined) { + scale.range(first as Iterable) + } + return scale as BandScale | PointScale +} + +function pair(values: Iterable): [number, number] { + const resolved = Array.from(values, number) + if (resolved.length < 2) { + throw new TypeError('A scale range requires two numeric values') + } + return [resolved[0]!, resolved[resolved.length - 1]!] +} + +function number(value: unknown): number { + return Number(value) +} diff --git a/packages/charts-scales/src/band.ts b/packages/charts-scales/src/band.ts new file mode 100644 index 0000000..8a3c7e1 --- /dev/null +++ b/packages/charts-scales/src/band.ts @@ -0,0 +1,21 @@ +import { createBandScale, type BandScale } from './band-kernel' +import type { ScaleDomainValue } from './intern' + +export type { BandScale } + +export function scaleBand< + TDomain extends ScaleDomainValue = string, +>(): BandScale +export function scaleBand( + range: Iterable, +): BandScale +export function scaleBand( + domain: Iterable, + range: Iterable, +): BandScale +export function scaleBand( + first?: Iterable | Iterable, + second?: Iterable, +): BandScale { + return createBandScale(false, first, second) +} diff --git a/packages/charts-scales/src/categorical.test.ts b/packages/charts-scales/src/categorical.test.ts new file mode 100644 index 0000000..6f14c6e --- /dev/null +++ b/packages/charts-scales/src/categorical.test.ts @@ -0,0 +1,138 @@ +import { + scaleBand as d3ScaleBand, + scaleOrdinal as d3ScaleOrdinal, + scalePoint as d3ScalePoint, +} from 'd3-scale' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { scaleBand, type BandScale } from './band' +import { scaleOrdinal, type OrdinalScale } from './ordinal' +import { scalePoint, type PointScale } from './point' + +const domain = ['Alpha', 'Beta', 'Gamma'] as const + +describe('compact categorical scales', () => { + it.each([ + { + range: [0, 300], + paddingInner: 0, + paddingOuter: 0, + align: 0.5, + round: false, + }, + { + range: [300, 0], + paddingInner: 0.2, + paddingOuter: 0.35, + align: 0, + round: false, + }, + { + range: [3, 247], + paddingInner: 0.1, + paddingOuter: 0.2, + align: 1, + round: true, + }, + ])('matches D3 band geometry for $range', (options) => { + const compact = scaleBand() + .domain(domain) + .range(options.range) + .paddingInner(options.paddingInner) + .paddingOuter(options.paddingOuter) + .align(options.align) + .round(options.round) + const d3 = d3ScaleBand() + .domain(domain) + .range(options.range) + .paddingInner(options.paddingInner) + .paddingOuter(options.paddingOuter) + .align(options.align) + .round(options.round) + + expect(domain.map(compact)).toEqual(domain.map(d3)) + expect(compact.step()).toBe(d3.step()) + expect(compact.bandwidth()).toBe(d3.bandwidth()) + }) + + it.each([ + { range: [0, 300], padding: 0, align: 0.5, round: false }, + { range: [300, 0], padding: 0.4, align: 0, round: false }, + { range: [3, 247], padding: 1, align: 1, round: true }, + ])('matches D3 point geometry for $range', (options) => { + const compact = scalePoint() + .domain(domain) + .range(options.range) + .padding(options.padding) + .align(options.align) + .round(options.round) + const d3 = d3ScalePoint() + .domain(domain) + .range(options.range) + .padding(options.padding) + .align(options.align) + .round(options.round) + + expect(domain.map(compact)).toEqual(domain.map(d3)) + expect(compact.step()).toBe(d3.step()) + expect(compact.bandwidth()).toBe(0) + }) + + it('interns duplicate primitives and equal Date timestamps', () => { + const first = new Date('2026-01-01T00:00:00Z') + const second = new Date(first) + const band = scaleBand().domain(['1', '1', '2']) + const dates = scalePoint().domain([first, second]) + + expect(band.domain()).toEqual(['1', '2']) + expect(dates.domain()).toEqual([first]) + expect(dates(second)).toBe(dates(first)) + }) + + it('copies band and point policy without sharing domains', () => { + const band = scaleBand() + .domain(domain) + .rangeRound([0, 101]) + .padding(0.2) + .align(0) + const point = scalePoint() + .domain(domain) + .range([10, 90]) + .padding(0.3) + .align(1) + const bandCopy = band.copy() + const pointCopy = point.copy() + + expect(domain.map(bandCopy)).toEqual(domain.map(band)) + expect(domain.map(pointCopy)).toEqual(domain.map(point)) + bandCopy.domain(['Delta']) + pointCopy.domain(['Delta']) + expect(band.domain()).toEqual(domain) + expect(point.domain()).toEqual(domain) + }) + + it('matches D3 ordinal domain growth, cycling, unknowns, and copies', () => { + const compact = scaleOrdinal().range(['red', 'blue']) + const d3 = d3ScaleOrdinal().range(['red', 'blue']) + + for (const value of ['Alpha', 'Beta', 'Gamma', 'Alpha']) { + expect(compact(value)).toBe(d3(value)) + } + expect(compact.domain()).toEqual(d3.domain()) + + compact.unknown('missing') + expect(compact('Delta')).toBe('missing') + const copy = compact.copy() + expect(copy('Other')).toBe('missing') + copy.domain(['Only']) + expect(compact.domain()).toEqual(['Alpha', 'Beta', 'Gamma']) + }) + + it('exposes precise callable contracts', () => { + const band: BandScale = scaleBand() + const point: PointScale = scalePoint() + const ordinal: OrdinalScale = scaleOrdinal() + expectTypeOf(band('x')).toEqualTypeOf() + expectTypeOf(point(1)).toEqualTypeOf() + expectTypeOf(ordinal('x')).toEqualTypeOf() + }) +}) diff --git a/packages/charts-scales/src/exports.test.ts b/packages/charts-scales/src/exports.test.ts new file mode 100644 index 0000000..cb7da96 --- /dev/null +++ b/packages/charts-scales/src/exports.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import packageJson from '../package.json' + +describe('compact scale exports', () => { + it('resolves only exact family subpaths', async () => { + const specifiers = Object.keys(packageJson.exports).map( + (subpath) => `@tanstack/charts-scales${subpath.slice(1)}`, + ) + const modules = await Promise.all( + specifiers.map((specifier) => import(/* @vite-ignore */ specifier)), + ) + + expect(Object.hasOwn(packageJson.exports, '.')).toBe(false) + expect(modules).toHaveLength(4) + expect(modules.every((module) => Object.keys(module).length > 0)).toBe(true) + }) +}) diff --git a/packages/charts-scales/src/intern.ts b/packages/charts-scales/src/intern.ts new file mode 100644 index 0000000..5febb61 --- /dev/null +++ b/packages/charts-scales/src/intern.ts @@ -0,0 +1,21 @@ +export type ScaleDomainValue = string | number | Date + +export function intern(value: ScaleDomainValue): string { + return value instanceof Date + ? `date:${value.getTime()}` + : `${typeof value}:${String(value)}` +} + +export function uniqueDomain( + values: Iterable, +): { domain: TValue[]; index: Map } { + const domain: TValue[] = [] + const index = new Map() + for (const value of values) { + const key = intern(value) + if (index.has(key)) continue + index.set(key, domain.length) + domain.push(value) + } + return { domain, index } +} diff --git a/packages/charts-scales/src/linear.test.ts b/packages/charts-scales/src/linear.test.ts new file mode 100644 index 0000000..2c2e802 --- /dev/null +++ b/packages/charts-scales/src/linear.test.ts @@ -0,0 +1,69 @@ +import { scaleLinear as d3ScaleLinear } from 'd3-scale' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { scaleLinear, type LinearScale } from './linear' + +describe('compact linear scale', () => { + it.each([ + { domain: [0, 10], range: [0, 100] }, + { domain: [10, 0], range: [0, 100] }, + { domain: [0, 10], range: [100, 0] }, + { domain: [-0.000_4, 0.000_9], range: [-20, 40] }, + { domain: [5, 5], range: [0, 100] }, + ])('matches D3 numeric mapping for $domain', ({ domain, range }) => { + const compact = scaleLinear(domain, range) + const d3 = d3ScaleLinear(domain, range) + + for (const value of [-20, -1, 0, 2.5, 5, 10, 30]) { + expect(compact(value)).toBeCloseTo(d3(value), 12) + expect(compact.clamp(true)(value)).toBeCloseTo(d3.clamp(true)(value), 12) + } + }) + + it.each([ + [-1.2, 8.7, 5], + [8.7, -1.2, 5], + [0.000_12, 0.000_89, 6], + [-9e12, 4e12, 8], + ] as const)( + 'matches D3 ticks and nicening for %s..%s', + (start, stop, count) => { + const compact = scaleLinear().domain([start, stop]) + const d3 = d3ScaleLinear().domain([start, stop]) + + expect(compact.ticks(count)).toEqual(d3.ticks(count)) + expect(compact.nice(count).domain()).toEqual(d3.nice(count).domain()) + }, + ) + + it('copies domain, range, and clamp without sharing mutation', () => { + const source = scaleLinear([-2, 8], [10, 90]).clamp(true) + const copy = source.copy() + + expect(copy.domain()).toEqual(source.domain()) + expect(copy.range()).toEqual(source.range()) + expect(copy.clamp()).toBe(true) + copy.domain([0, 1]).range([0, 10]).clamp(false) + expect(source.domain()).toEqual([-2, 8]) + expect(source.range()).toEqual([10, 90]) + expect(source.clamp()).toBe(true) + }) + + it('inverts, formats fractional ticks, and rejects invalid pairs', () => { + const scale = scaleLinear([0, 1], [10, 30]) + + expect(scale.invert(20)).toBe(0.5) + expect(scale.tickFormat(5)(0.2)).toBe('0.2') + expect(scale(null)).toBeUndefined() + expect(scale(Number.NaN)).toBeUndefined() + expect(() => scale.domain([1])).toThrow('requires two finite numbers') + expect(() => scale.range([0, Number.NaN])).toThrow( + 'requires two finite numbers', + ) + }) + + it('has the callable, copyable Charts scale contract', () => { + const scale: LinearScale = scaleLinear() + expectTypeOf(scale(2)).toEqualTypeOf() + expectTypeOf(scale.copy()).toEqualTypeOf() + }) +}) diff --git a/packages/charts-scales/src/linear.ts b/packages/charts-scales/src/linear.ts new file mode 100644 index 0000000..cad283e --- /dev/null +++ b/packages/charts-scales/src/linear.ts @@ -0,0 +1,131 @@ +import { tickIncrement, ticks as createTicks, tickStep } from 'd3-array' + +export interface LinearScale { + (value: number | null | undefined): number | undefined + domain(): [number, number] + domain(values: Iterable): LinearScale + range(): [number, number] + range(values: Iterable): LinearScale + invert(value: number): number + clamp(): boolean + clamp(value: boolean): LinearScale + ticks(count?: number): number[] + tickFormat(count?: number): (value: number) => string + nice(count?: number): LinearScale + copy(): LinearScale +} + +export function scaleLinear(): LinearScale +export function scaleLinear(range: Iterable): LinearScale +export function scaleLinear( + domain: Iterable, + range: Iterable, +): LinearScale +export function scaleLinear( + first?: Iterable, + second?: Iterable, +): LinearScale { + let domain: [number, number] = [0, 1] + let range: [number, number] = [0, 1] + let clamped = false + + const scale = ((value: number | null | undefined) => { + if (value == null || !Number.isFinite(Number(value))) return undefined + return interpolate(Number(value), domain, range, clamped) + }) as LinearScale + + scale.domain = ((values?: Iterable) => { + if (values === undefined) return [...domain] + domain = pair(values, 'domain') + return scale + }) as LinearScale['domain'] + scale.range = ((values?: Iterable) => { + if (values === undefined) return [...range] + range = pair(values, 'range') + return scale + }) as LinearScale['range'] + scale.invert = (value) => interpolate(value, range, domain, clamped) + scale.clamp = ((value?: boolean) => { + if (value === undefined) return clamped + clamped = Boolean(value) + return scale + }) as LinearScale['clamp'] + scale.ticks = (count = 10) => createTicks(domain[0], domain[1], count) + scale.tickFormat = (count = 10) => { + const step = Math.abs(tickStep(domain[0], domain[1], count)) + const digits = + step > 0 && step < 1 + ? Math.min(20, Math.max(0, -Math.floor(Math.log10(step)))) + : 0 + return (value) => { + const formatted = digits ? value.toFixed(digits) : String(value) + return formatted === '-0' ? '0' : formatted + } + } + scale.nice = (count = 10) => { + let start = domain[0] + let stop = domain[1] + let startIndex = 0 + let stopIndex = 1 + if (stop < start) { + ;[start, stop] = [stop, start] + ;[startIndex, stopIndex] = [stopIndex, startIndex] + } + let previousStep: number | undefined + for (let remaining = 10; remaining > 0; remaining--) { + const step = tickIncrement(start, stop, count) + if (step === previousStep) { + const next: [number, number] = [...domain] + next[startIndex] = start + next[stopIndex] = stop + domain = next + break + } + if (step > 0) { + start = Math.floor(start / step) * step + stop = Math.ceil(stop / step) * step + } else if (step < 0) { + start = Math.ceil(start * step) / step + stop = Math.floor(stop * step) / step + } else { + break + } + previousStep = step + } + return scale + } + scale.copy = () => scaleLinear(domain, range).clamp(clamped) + + if (second !== undefined) { + scale.domain(first!).range(second) + } else if (first !== undefined) { + scale.range(first) + } + return scale +} + +function interpolate( + value: number, + domain: readonly [number, number], + range: readonly [number, number], + clamped: boolean, +) { + const span = domain[1] - domain[0] + let ratio = span ? (value - domain[0]) / span : 0.5 + if (clamped) ratio = Math.max(0, Math.min(1, ratio)) + return range[0] + ratio * (range[1] - range[0]) +} + +function pair( + values: Iterable, + name: 'domain' | 'range', +): [number, number] { + const resolved = Array.from(values, Number) + if ( + resolved.length < 2 || + resolved.some((value) => !Number.isFinite(value)) + ) { + throw new TypeError(`A linear scale ${name} requires two finite numbers`) + } + return [resolved[0]!, resolved[resolved.length - 1]!] +} diff --git a/packages/charts-scales/src/ordinal.ts b/packages/charts-scales/src/ordinal.ts new file mode 100644 index 0000000..fbe1ae6 --- /dev/null +++ b/packages/charts-scales/src/ordinal.ts @@ -0,0 +1,85 @@ +import { intern, uniqueDomain, type ScaleDomainValue } from './intern' + +const implicit = Symbol('implicit') + +export interface OrdinalScale< + TDomain extends ScaleDomainValue = string, + TRange = unknown, +> { + (value: TDomain): TRange + domain(): TDomain[] + domain(values: Iterable): OrdinalScale + range(): TRange[] + range(values: Iterable): OrdinalScale + unknown(): TRange | undefined + unknown(value: TRange | undefined): OrdinalScale + copy(): OrdinalScale +} + +export function scaleOrdinal< + TDomain extends ScaleDomainValue = string, + TRange = unknown, +>(): OrdinalScale +export function scaleOrdinal( + range: Iterable, +): OrdinalScale +export function scaleOrdinal( + domain: Iterable, + range: Iterable, +): OrdinalScale +export function scaleOrdinal( + first?: Iterable | Iterable, + second?: Iterable, +): OrdinalScale { + let domain: TDomain[] = [] + let index = new Map() + let range: TRange[] = [] + let unknown: TRange | typeof implicit | undefined = implicit + + const scale = ((value: TDomain) => { + const key = intern(value) + let position = index.get(key) + if (position === undefined) { + if (unknown !== implicit) return unknown + position = domain.length + index.set(key, position) + domain.push(value) + } + return range[position % range.length] + }) as OrdinalScale + + scale.domain = ((values?: Iterable) => { + if (values === undefined) return domain.slice() + const next = uniqueDomain(values) + domain = next.domain + index = next.index + return scale + }) as OrdinalScale['domain'] + + scale.range = ((values?: Iterable) => { + if (values === undefined) return range.slice() + range = Array.from(values) + return scale + }) as OrdinalScale['range'] + + scale.unknown = function (value?: TRange) { + if (arguments.length === 0) { + return unknown === implicit ? undefined : unknown + } + unknown = value + return scale + } as OrdinalScale['unknown'] + + scale.copy = () => { + const copy = scaleOrdinal(domain, range) + if (unknown !== implicit) copy.unknown(unknown) + return copy + } + + if (second !== undefined) { + scale.domain(first as Iterable).range(second) + } else if (first !== undefined) { + scale.range(first as Iterable) + } + return scale +} diff --git a/packages/charts-scales/src/point.ts b/packages/charts-scales/src/point.ts new file mode 100644 index 0000000..08d57fb --- /dev/null +++ b/packages/charts-scales/src/point.ts @@ -0,0 +1,21 @@ +import { createBandScale, type PointScale } from './band-kernel' +import type { ScaleDomainValue } from './intern' + +export type { PointScale } + +export function scalePoint< + TDomain extends ScaleDomainValue = string, +>(): PointScale +export function scalePoint( + range: Iterable, +): PointScale +export function scalePoint( + domain: Iterable, + range: Iterable, +): PointScale +export function scalePoint( + first?: Iterable | Iterable, + second?: Iterable, +): PointScale { + return createBandScale(true, first, second) +} diff --git a/packages/lit-charts/README.md b/packages/lit-charts/README.md index e08f4cd..a834ba0 100644 --- a/packages/lit-charts/README.md +++ b/packages/lit-charts/README.md @@ -8,6 +8,7 @@ pnpm add @tanstack/charts @tanstack/lit-charts lit d3-scale ```ts import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { defineChartElement } from '@tanstack/lit-charts' defineChartElement() @@ -16,7 +17,7 @@ defineChartElement() ```ts html`` diff --git a/packages/lit-charts/src/Chart.test.ts b/packages/lit-charts/src/Chart.test.ts index 15fda4d..7d33fb0 100644 --- a/packages/lit-charts/src/Chart.test.ts +++ b/packages/lit-charts/src/Chart.test.ts @@ -3,6 +3,8 @@ import type { TemplateResult } from 'lit' import { describe, expect, it, vi } from 'vitest' import { defineChart, lineY } from '@tanstack/charts' import type { ChartTooltipContent } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal as tooltipPortal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from 'd3-scale' import { Chart } from './index' import type { ChartProps } from './index' @@ -74,7 +76,8 @@ describe('Lit adapter', () => { const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltip, + portal: tooltipPortal, content: () => ({ title: 'First', color: 'red;position:fixed;inset:0', diff --git a/packages/octane-charts/README.md b/packages/octane-charts/README.md index dcc0903..b1ac11d 100644 --- a/packages/octane-charts/README.md +++ b/packages/octane-charts/README.md @@ -15,11 +15,12 @@ chart's actual imports. ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/octane-charts' const interactiveDefinition = defineChart(definition, { animate: true, - tooltip: true, + tooltip, }) ; diff --git a/packages/octane-charts/src/Chart.client.test.tsrx b/packages/octane-charts/src/Chart.client.test.tsrx index e79ae8c..b5248a5 100644 --- a/packages/octane-charts/src/Chart.client.test.tsrx +++ b/packages/octane-charts/src/Chart.client.test.tsrx @@ -2,6 +2,8 @@ import { act, createRoot, useState } from 'octane'; import { describe, expect, it, vi } from 'vitest'; import { defineChart, lineY } from '@tanstack/charts'; import type { ChartPoint } from '@tanstack/charts'; +import { tooltip } from '@tanstack/charts/tooltip'; +import { portal as tooltipPortal } from '@tanstack/charts/tooltip/portal'; import { Chart } from '@tanstack/octane-charts'; import { scaleLinear } from 'd3-scale'; @@ -120,7 +122,8 @@ describe('Octane Chart adapter client', () => { const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltip, + portal: tooltipPortal, content: () => ({ title: 'January', rows: [{ label: 'Revenue', value: '$8', color: '#2563eb' }], diff --git a/packages/preact-charts/README.md b/packages/preact-charts/README.md index 2ffe2ae..44a905a 100644 --- a/packages/preact-charts/README.md +++ b/packages/preact-charts/README.md @@ -8,9 +8,10 @@ pnpm add @tanstack/charts @tanstack/preact-charts preact d3-scale ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/preact-charts' -const interactiveDefinition = defineChart(definition, { tooltip: true }) +const interactiveDefinition = defineChart(definition, { tooltip }) ; ``` diff --git a/packages/preact-charts/src/Chart.test.tsx b/packages/preact-charts/src/Chart.test.tsx index c88358f..9f4ba01 100644 --- a/packages/preact-charts/src/Chart.test.tsx +++ b/packages/preact-charts/src/Chart.test.tsx @@ -6,6 +6,8 @@ import { act } from 'preact/test-utils' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { defineChart, lineY } from '@tanstack/charts' import type { ChartTooltipContent } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal as tooltipPortal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from 'd3-scale' import { Chart } from './Chart' @@ -96,7 +98,8 @@ describe('Preact adapter', () => { const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltip, + portal: tooltipPortal, content: () => ({ title: 'First', color: '#2563eb', diff --git a/packages/react-charts/README.md b/packages/react-charts/README.md index cd88818..219c6a1 100644 --- a/packages/react-charts/README.md +++ b/packages/react-charts/README.md @@ -15,11 +15,12 @@ chart's actual imports. ```tsx import { defineChart } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { Chart } from '@tanstack/react-charts' const interactiveDefinition = defineChart(definition, { animate: true, - tooltip: true, + tooltip, }) ; ``` +The base `Chart` renders the core native tooltip without including React +tooltip-body composition. Import the drop-in component from `/tooltip` only +when passing `renderTooltipBody`: + +```tsx +import { Chart } from '@tanstack/react-charts/tooltip' + +; ( + <> + {defaultBody} + {pinned ? : null} + + )} +/> +``` + +Existing `renderTooltipBody` users should move their component import from +`@tanstack/react-charts` to `@tanstack/react-charts/tooltip`. That entry also +exports `CanvasChart` and `RendererChart` for the same opt-in with those +renderers. + Switch only the import to opt into Canvas: ```tsx diff --git a/packages/react-charts/package.json b/packages/react-charts/package.json index 210ba61..a8c5707 100644 --- a/packages/react-charts/package.json +++ b/packages/react-charts/package.json @@ -18,7 +18,8 @@ "exports": { ".": "./src/index.ts", "./canvas": "./src/canvas.ts", - "./core": "./src/core.ts" + "./core": "./src/core.ts", + "./tooltip": "./src/tooltip.tsx" }, "dependencies": { "@tanstack/charts": "workspace:*" @@ -46,6 +47,10 @@ "./core": { "types": "./dist/core.d.ts", "import": "./dist/core.js" + }, + "./tooltip": { + "types": "./dist/tooltip.d.ts", + "import": "./dist/tooltip.js" } } } diff --git a/packages/react-charts/src/CanvasChart.test.tsx b/packages/react-charts/src/CanvasChart.test.tsx index 31942f2..049bb0d 100644 --- a/packages/react-charts/src/CanvasChart.test.tsx +++ b/packages/react-charts/src/CanvasChart.test.tsx @@ -4,6 +4,7 @@ import { createRoot, hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { defineChart, lineY } from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' import { scaleLinear } from 'd3-scale' import { CanvasChart } from './CanvasChart' @@ -27,7 +28,7 @@ const definition = defineChart({ }) const interactiveDefinition = defineChart(definition, { maxFocusDistance: 1_000, - tooltip: true, + tooltip, }) const dynamicDefinition = defineChart(() => ({ diff --git a/packages/react-charts/src/CanvasChart.tsx b/packages/react-charts/src/CanvasChart.tsx index 4d92147..f020cfd 100644 --- a/packages/react-charts/src/CanvasChart.tsx +++ b/packages/react-charts/src/CanvasChart.tsx @@ -1,6 +1,10 @@ import * as React from 'react' import { canvasChartRenderer } from '@tanstack/charts/canvas' -import type { ChartDefinition, ChartValue } from '@tanstack/charts' +import type { + ChartDefinition, + ChartTooltipBodyTarget, + ChartValue, +} from '@tanstack/charts' import { RendererChartImplementation, type RendererChartCommonProps, @@ -20,11 +24,29 @@ export type CanvasChartProps< definition: ChartDefinition } +export type CanvasChartImplementationProps< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> = CanvasChartProps & { + onTooltipBodyChange?: ( + target: ChartTooltipBodyTarget | null, + ) => void +} + export function CanvasChart< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, >(props: CanvasChartProps) { + return +} + +export function CanvasChartImplementation< + TDatum, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +>(props: CanvasChartImplementationProps) { return ( ) diff --git a/packages/react-charts/src/Chart.test.tsx b/packages/react-charts/src/Chart.test.tsx index 20e1ae5..112a5e9 100644 --- a/packages/react-charts/src/Chart.test.tsx +++ b/packages/react-charts/src/Chart.test.tsx @@ -6,8 +6,11 @@ import { describe, expect, it, vi } from 'vitest' import { areaY, defineChart, lineY } from '@tanstack/charts' import type { ChartDefinition } from '@tanstack/charts' import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' +import { tooltip } from '@tanstack/charts/tooltip' +import { portal as tooltipPortal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from 'd3-scale' import { Chart } from './Chart' +import { Chart as TooltipChart } from './tooltip' const data = [ { id: 'jan', month: 1, value: 8 }, @@ -82,7 +85,7 @@ if (false) { }, }) const inferredCallback = ( - { @@ -347,7 +350,8 @@ describe('React adapter', () => { const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, tooltip: { - portal: true, + use: tooltip, + portal: tooltipPortal, content: () => ({ title: 'January', color: '#2563eb', @@ -367,7 +371,7 @@ describe('React adapter', () => { await act(async () => { root.render( - void onSelect?: (point: ChartPoint | null) => void onRender?: (context: ChartRenderContext) => void - renderTooltipBody?: ( - context: ChartTooltipBodyRenderContext, - ) => React.ReactNode } export type ChartProps< @@ -56,11 +53,29 @@ export type ChartProps< definition: ChartDefinition } +export type ChartImplementationProps< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> = ChartProps & { + onTooltipBodyChange?: ( + target: ChartTooltipBodyTarget | null, + ) => void +} + export function Chart< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, >(props: ChartProps) { + return +} + +export function ChartImplementation< + TDatum, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +>(props: ChartImplementationProps) { const renderSvg = props.renderSvg ?? renderChartSvg const renderer = React.useMemo( () => createSvgChartRenderer(renderSvg), @@ -82,7 +97,11 @@ export function Chart< }) } }, [props.onRender]) - const rendererProps: RendererChartProps = { + const rendererProps: RendererChartImplementationProps< + TDatum, + TXValue, + TYValue + > = { ...props, renderer, onRender, diff --git a/packages/react-charts/src/RendererChart.tsx b/packages/react-charts/src/RendererChart.tsx index f26b942..28a6a1f 100644 --- a/packages/react-charts/src/RendererChart.tsx +++ b/packages/react-charts/src/RendererChart.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { createPortal } from 'react-dom' import { createChartRendererAdapter } from '@tanstack/charts/adapter/renderer' import type { ChartAdapter, @@ -9,9 +8,7 @@ import type { ChartRendererRenderContext, ChartPoint, ChartTextMeasurer, - ChartTooltipBodyContext, ChartTooltipBodyTarget, - ChartTooltipContent, ChartValue, ChartDefinition, } from '@tanstack/charts' @@ -62,25 +59,24 @@ export interface RendererChartCommonProps< onRender?: ( context: ChartRendererRenderContext, ) => void - renderTooltipBody?: ( - context: ChartTooltipBodyRenderContext, - ) => React.ReactNode } -export interface ChartTooltipBodyRenderContext< +export type RendererChartProps< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> extends ChartTooltipBodyContext { - defaultBody: React.ReactNode +> = RendererChartCommonProps & { + definition: ChartDefinition } -export type RendererChartProps< +export type RendererChartImplementationProps< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> = RendererChartCommonProps & { - definition: ChartDefinition +> = RendererChartProps & { + onTooltipBodyChange?: ( + target: ChartTooltipBodyTarget | null, + ) => void } export function RendererChart< @@ -95,7 +91,7 @@ export function RendererChartImplementation< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, ->(props: RendererChartProps) { +>(props: RendererChartImplementationProps) { const { ariaLabel, ariaDescription, @@ -113,7 +109,7 @@ export function RendererChartImplementation< onFocusGroupChange, onSelect, onRender, - renderTooltipBody, + onTooltipBodyChange, } = props const generatedId = React.useId() const idPrefix = @@ -132,16 +128,6 @@ export function RendererChartImplementation< TXValue, TYValue > | null>(null) - const [tooltipBodyTarget, setTooltipBodyTarget] = - React.useState | null>( - null, - ) - const handleTooltipBodyChange = React.useCallback( - (target: ChartTooltipBodyTarget | null) => { - setTooltipBodyTarget(target) - }, - [], - ) const commonHostOptions: ChartRendererHostCommonOptions< TDatum, TXValue, @@ -161,9 +147,7 @@ export function RendererChartImplementation< onFocusGroupChange, onSelect, onRender, - onTooltipBodyChange: renderTooltipBody - ? handleTooltipBodyChange - : undefined, + onTooltipBodyChange, } const hostOptions: ChartRendererHostOptions = { ...commonHostOptions, @@ -186,110 +170,18 @@ export function RendererChartImplementation< adapter.update(hostOptions) }, [adapter, hostOptions]) - const tooltipPortal = - renderTooltipBody && tooltipBodyTarget - ? createPortal( - renderTooltipBody({ - points: tooltipBodyTarget.points, - content: tooltipBodyTarget.content, - pinned: tooltipBodyTarget.pinned, - dismiss: tooltipBodyTarget.dismiss, - defaultBody: ( - - ), - }), - tooltipBodyTarget.element, - ) - : null - - return ( - <> -
- -
- {tooltipPortal} - - ) -} - -function DefaultTooltipBody({ - content, -}: { - content: ChartTooltipContent | string -}) { - if (typeof content === 'string') return content - - return ( - <> - {content.title ? ( -
- {content.color ? : null} - {content.title} -
- ) : null} - {content.rows.length ? ( - - ) : null} - - ) -} - -function TooltipSwatch({ color }: { color: string }) { return ( -