diff --git a/.changeset/README.md b/.changeset/README.md index b6e6944..042fc7a 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -6,6 +6,6 @@ Add a changeset for every user-visible package change: pnpm changeset ``` -All ten public Charts packages release as one fixed version. Documentation, +All eleven public Charts packages release as one fixed version. Documentation, tests, benchmarks, and build-only changes do not need a changeset unless they change the published package contract. diff --git a/.changeset/compact-scales.md b/.changeset/compact-scales.md new file mode 100644 index 0000000..987eacf --- /dev/null +++ b/.changeset/compact-scales.md @@ -0,0 +1,10 @@ +--- +'@tanstack/charts-scales': minor +--- + +Add compact callable linear, band, point, and ordinal scales through the exact +`@tanstack/charts-scales/linear`, `/band`, `/point`, and `/ordinal` entries. +There is no package root export. Compact linear scales are numeric and two-stop; +use `d3-scale` for time, transformed, piecewise, nonnumeric, locale-aware, and +other full D3 behavior. Unsupported D3 behavior does not warn or fall back at +runtime. diff --git a/.changeset/config.json b/.changeset/config.json index 78c1b4e..73e3ddd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -15,6 +15,7 @@ "fixed": [ [ "@tanstack/charts", + "@tanstack/charts-scales", "@tanstack/react-charts", "@tanstack/octane-charts", "@tanstack/preact-charts", diff --git a/.changeset/react-tooltip-entry.md b/.changeset/react-tooltip-entry.md new file mode 100644 index 0000000..7ae7d39 --- /dev/null +++ b/.changeset/react-tooltip-entry.md @@ -0,0 +1,9 @@ +--- +'@tanstack/react-charts': minor +--- + +Move React tooltip-body composition to `@tanstack/react-charts/tooltip`. +Consumers using `renderTooltipBody` must migrate root `Chart` to `Chart`, +`/canvas` `Chart` to `CanvasChart`, and `/core` `Chart` to `RendererChart` from +that entry. Move wrapper types to the matching `ChartProps`, `CanvasChartProps`, +or `RendererChartProps` names and their `CommonProps` counterparts. diff --git a/.changeset/tooltip-extensions.md b/.changeset/tooltip-extensions.md new file mode 100644 index 0000000..19ad0d5 --- /dev/null +++ b/.changeset/tooltip-extensions.md @@ -0,0 +1,13 @@ +--- +'@tanstack/charts': minor +--- + +Make native tooltips and tooltip portals explicit extensions. Import `tooltip` +from `@tanstack/charts/tooltip`; replace `tooltip: true` with `tooltip`, dynamic +booleans with `enabled ? tooltip : false`, and configured objects with +`{ use: tooltip, ...options }`. Complete definition values use +`ChartTooltipInput`; `ChartTooltipOptions` remains the options-only type. + +Import `portal` from `@tanstack/charts/tooltip/portal`; replace `portal: true` +with `portal`, omit `portal: false`, and replace dynamic booleans with +`enabled ? portal : undefined` inside the configured tooltip object. diff --git a/API-FRICTION.md b/API-FRICTION.md index b271bd2..40b0e56 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-07-30 +Last updated: 2026-07-31 ## Triage rule @@ -190,7 +190,9 @@ Each entry records: | F-152 | Version bumps invalidated workspace bundle evidence | Tooling/Release | resolved | | F-153 | Changesets left release-facing version claims behind | Tooling/Release | resolved | | F-154 | Root barrels crossed the browser host boundary | API/Tooling | resolved | -| F-155 | Conformance monitoring blocked unrelated changes | Tooling | resolved | +| F-155 | Optional tooltip code burdened every chart consumer | API | resolved | +| F-156 | Releases stranded manual Unreleased migration notes | Tooling/Release | resolved | +| F-157 | Conformance monitoring blocked unrelated changes | Tooling | resolved | ## Findings @@ -487,7 +489,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 @@ -495,13 +498,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,227 gzip bytes without + retaining `d3-scale`, `d3-format`, or `d3-interpolate`. ### F-016 — Stats animated export still renders through Plot @@ -1086,7 +1094,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 @@ -1097,8 +1108,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,227. Tooltip adds 3,381 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 @@ -1135,9 +1150,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. @@ -3207,14 +3225,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 @@ -3222,10 +3241,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 @@ -3675,17 +3695,67 @@ Each entry records: `@tanstack/charts/portable` for common authoring/runtime values plus `@tanstack/charts/types` for portable contracts. DOM surface, renderer, host, and render-context types now live behind an internal module while retaining - their existing root re-exports. Do not conditionally change the root until a - native host can test one coherent platform contract. + their existing root re-exports. Definition inputs retain DOM-free extension + token contracts while typed tooltip and portal lifecycles remain in the DOM + module. Do not conditionally change the root until a native host can test one + coherent platform contract. - Verification: root typechecking and 61 focused core tests pass. The packed package gate resolves both new entries from `dist`, compiles their - declarations with Web Worker rather than DOM globals, and proves the - portable bundle excludes the root, adapters, Canvas, DOM host/text, browser - export, reconciliation, renderer, and SVG surface modules. That full - portable barrel measures 55.26 kB minified and 17.04 kB gzip; granular + declarations, including tooltip definition inputs, with Web Worker rather + than DOM globals. Type regressions reject swapping tooltip and portal tokens. + The packed bundle proof excludes the root, adapters, Canvas, DOM host/text, + browser export, reconciliation, renderer, and SVG surface modules. That full + portable barrel measures 53.95 kB minified and 16.60 kB gzip; granular subpaths remain the bundle-sensitive option. -### F-155 — Conformance monitoring blocked unrelated changes +### F-155 — Optional tooltip code burdened every chart consumer + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: reducing the representative React line consumer from 25.11 KiB + to 13.89 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,227 gzip bytes. + Native tooltip adds 3,381 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. + +### F-156 — Releases stranded manual Unreleased migration notes + +- Status: resolved +- Severity: high +- Owner: Tooling/Release +- Observed in: rebasing the tooltip and compact-scale release onto the + Changesets-based `0.0.2` release flow +- Friction: the root changelog held the complete human- and agent-readable + migration under `## Unreleased`, while Changesets generated package sections + and prepended the new version without consuming that section. The resulting + release would leave the migration under an obsolete heading instead of the + version users were upgrading to. +- Decision: when synchronizing a new root release, move the body of + `## Unreleased` into the generated version section and remove the pending + heading. Package-specific changesets retain the core, React, and compact-scale + migration instructions in their published package changelogs. +- Verification: the focused changelog synchronization regression moves pending + breaking-change notes under the generated version, removes `## Unreleased`, + preserves earlier releases, and includes the migration in extracted GitHub + release notes. + +### F-157 — Conformance monitoring blocked unrelated changes - Status: resolved - Severity: high diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f412c..7cb9210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Changelog +## Unreleased + +This section documents migration from `0.0.2` and supersedes tooltip examples +in the historical release entries below. + +### Breaking changes + +#### Tooltip extensions + +Tooltips and tooltip portals are now explicit extensions: + +```ts +import { tooltip, type ChartTooltipInput } from '@tanstack/charts/tooltip' +import { portal } from '@tanstack/charts/tooltip/portal' + +interface Datum { + value: number +} + +const configuredTooltip = { + use: tooltip, + portal, + format(point) { + return String(point.datum.value) + }, +} satisfies ChartTooltipInput +``` + +Apply these replacements to chart definition options: + +| `0.0.2` input | `0.1.0` input | +| ------------------ | --------------------------------------- | +| `tooltip: true` | `tooltip` | +| `tooltip: false` | `tooltip: false` | +| `tooltip: enabled` | `tooltip: enabled ? tooltip : false` | +| `tooltip: options` | `tooltip: { use: tooltip, ...options }` | +| `portal: true` | `portal` | +| `portal: false` | Omit `portal` | +| `portal: enabled` | `portal: enabled ? portal : undefined` | + +`portal` remains a property of a configured tooltip object. `ChartTooltipOptions` +still describes only the options after `use`; it does not contain the extension +discriminator. Type a complete value assigned to `definition.tooltip` as +`ChartTooltipInput`, or wrap reusable `ChartTooltipOptions` with +`{ use: tooltip, ...options }`. The object containing `use: tooltip` is the +contextual typing boundary for inline tooltip callbacks. + +#### React tooltip bodies + +React consumers that provide `renderTooltipBody` must move the component and +matching prop-type imports to `@tanstack/react-charts/tooltip`: + +| `0.0.2` import | `0.1.0` component | `0.1.0` prop types | +| --------------------------------------- | ----------------- | ------------------------------------------------ | +| `@tanstack/react-charts` `Chart` | `Chart` | `ChartProps`, `ChartCommonProps` | +| `@tanstack/react-charts/canvas` `Chart` | `CanvasChart` | `CanvasChartProps`, `CanvasChartCommonProps` | +| `@tanstack/react-charts/core` `Chart` | `RendererChart` | `RendererChartProps`, `RendererChartCommonProps` | + +Do not rename `/canvas` or `/core` imports to `/tooltip` while retaining the +name `Chart`; that name selects the default SVG component in the new entry. The +base React entries retain native tooltips without including React DOM's portal +runtime. Other framework adapters retain their existing entry points and only +require the chart-definition migration above. + +### Added + +- Added the optional compact scale package: + + ```sh + pnpm add @tanstack/charts-scales + ``` + + Import one exact family; there is no `@tanstack/charts-scales` root export: + + ```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' + ``` + + These scales are documented subsets, not complete D3 replacements. Compact + linear scales are numeric and two-stop. Use `d3-scale` for time, UTC, log, + power, symlog, radial, sequential, diverging, quantile, quantize, threshold, + piecewise or nonnumeric interpolation, locale-aware format specifiers, and + other full D3 behavior. Unsupported D3 behavior does not trigger a runtime + warning or automatic fallback. See [Scales and D3](docs/concepts/scales-and-d3.md#compact-scales) + for the compatibility boundary. + +### Bundle impact + +- The representative compact React line consumer is 14,227 bytes gzip + (13.89 KiB), down from 25,708 bytes gzip (25.11 KiB). React, the React JSX + runtime, and React DOM are external in both measurements. +- Opting into the tooltip extension produces 17,608 bytes gzip, an increase of + 3,381 bytes. +- Adding the portal extension produces 18,414 bytes gzip, another 806 bytes. + ## 0.0.2 ### @tanstack/charts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07deea2..df71cb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ pnpm changeset ``` Choose the release impact and write the summary that should appear in the -changelog. All ten public Charts packages form one fixed release group, so one +changelog. All eleven public Charts packages form one fixed release group, so one package change advances every package to the same version. Documentation, tests, benchmarks, and build-only changes do not need a changeset unless they alter the published package contract. @@ -87,9 +87,9 @@ Every push to `main` starts the release workflow: publisher checks npm and builds fresh, consumer-tested tarballs only when the coordinated version is unpublished. 4. npm trusted publishing uses the workflow's OIDC identity to publish core - before the nine adapters with provenance. The repository has no long-lived - npm token. -5. After all ten registry entries report the expected integrity and + before compact scales and the nine adapters with provenance. The repository + has no long-lived npm token. +5. After all eleven registry entries report the expected integrity and attestations, the workflow creates one annotated `vX.Y.Z` tag and GitHub release from the root changelog. @@ -104,4 +104,4 @@ preflight resumes finalization without republishing existing versions. Changing the repository name or `.github/workflows/release.yml` requires updating the trusted-publisher -configuration for all ten packages on npm. +configuration for all eleven packages on npm. diff --git a/MARKETING.md b/MARKETING.md index d29ab0a..fe4c4c2 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -302,23 +302,24 @@ unrelated chart thumbnails. The durable public claim is: > Complete tested charts are approximately 24–29 KiB gzip. A static SVG line -> is 14.88 KiB gzip. Consumers pay for the capabilities they import. +> is 14.54 KiB gzip. Consumers pay for the capabilities they import. -Do not lead with the 6.28 KiB custom-scale scene. It proves the scene compiler +Do not lead with the 5.82 KiB custom-scale scene. It proves the scene compiler has a low isolated cost when an application supplies its own scale, but it is not a complete rendered chart. #### TanStack consumer boundaries -| Consumer surface | Gzip size | Evidence status | -| --------------------------------------------- | --------------: | -------------------------------------------------------- | -| Custom-scale line scene, no renderer | 6.28 KiB | Exact byte lock; not a complete chart | -| D3-scale line with static SVG | 14.88 KiB | Exact byte lock | -| Mounted basic line, bar, area, or scatter | 24.19–24.81 KiB | Checked four-chart comparison baseline | -| Mounted chart with legend and pointer tooltip | 25.38–25.98 KiB | Checked four-chart comparison baseline | -| Advanced two-series composition | 25.39–28.20 KiB | Checked four-chart comparison baseline | -| React line consumer, with React externalized | 25.11 KiB | Exact byte lock | -| TanStack Stats parity surface | 35.35 KiB | Isolated measurement under a 35.45 KiB capability budget | +| Consumer surface | Gzip size | Evidence status | +| ------------------------------------------------------- | -------------------: | -------------------------------------------------------- | +| Custom-scale line scene, no renderer | 5.82 KiB | Exact byte lock; not a complete chart | +| D3-scale line with static SVG | 14.54 KiB | Exact byte lock | +| Mounted basic line, bar, area, or scatter | 23.68–24.70 KiB | Checked four-chart comparison baseline | +| Mounted chart with legend and pointer tooltip | 25.27–25.92 KiB | Checked four-chart comparison baseline | +| Advanced two-series composition | 25.27–28.06 KiB | Checked four-chart comparison baseline | +| React line consumer, D3 scales, React externalized | 21,281 B / 20.78 KiB | Exact byte lock | +| React line consumer, compact scales, React externalized | 14,227 B / 13.89 KiB | Exact byte lock | +| TanStack Stats parity surface | 35.35 KiB | Isolated measurement under a 35.45 KiB capability budget | The exact ordinary-consumer locks live in [`universal-baseline.json`](./benchmarks/bundle-size/universal-baseline.json). @@ -334,16 +335,16 @@ variable point size. | Gzip comparison | Basic | Interactive | Advanced | | -------------------------- | --------------: | --------------: | --------------: | -| TanStack Charts | 24.19–24.81 KiB | 25.38–25.98 KiB | 25.39–28.20 KiB | -| Chart.js | 1.85–2.08× | 2.06–2.29× | 2.01–2.07× | -| Observable Plot | 3.44–3.79× | 3.28–3.61× | 3.21–3.29× | -| Recharts, React external | 3.82–3.98× | 4.22–4.28× | 3.87–4.27× | -| Recharts, full cold bundle | 6.17–6.39× | 6.47–6.58× | 5.94–6.57× | -| Apache ECharts | 6.33–6.57× | 6.57–6.81× | 6.14–6.57× | +| TanStack Charts | 23.68–24.70 KiB | 25.27–25.92 KiB | 25.27–28.06 KiB | +| Chart.js | 1.89–2.12× | 2.07–2.29× | 2.02–2.08× | +| Observable Plot | 3.45–3.87× | 3.29–3.63× | 3.23–3.30× | +| Recharts, React external | 3.85–4.07× | 4.24–4.30× | 3.89–4.29× | +| Recharts, full cold bundle | 6.20–6.53× | 6.49–6.61× | 5.97–6.60× | +| Apache ECharts | 6.37–6.72× | 6.60–6.83× | 6.17–6.60× | Competitor cells are competitor gzip divided by TanStack gzip, ranged across the matched line, bar, area, and scatter fixtures. Across every matched fixture -in this controlled suite, TanStack shipped 46–85% less gzipped JavaScript. +in this controlled suite, TanStack shipped 47–85% less gzipped JavaScript. This supports “substantially smaller than the measured mainstream libraries.” It does not support “smaller than every popular charting library.” Highcharts, @@ -352,7 +353,7 @@ matrix. Visx is the most important modular challenger to measure next. #### Evidence maturity -Internal bundle control is strong: eight ordinary TanStack consumers are exact +Internal bundle control is strong: ten ordinary TanStack consumers are exact minified and gzip byte locks, optional capabilities have isolated ceilings, and CI reproduces those locks. Comparative stability is not ready for an unqualified public superlative: diff --git a/PLAN.md b/PLAN.md index 93a4ab3..255a04b 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,227 gzip bytes. Its +native tooltip adds 3,381 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 d2bccf6..cf20f25 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 d45f226..63e3ede 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": 36320, - "gzip": 14119 + "bytes": 35111, + "gzip": 13765 }, "D3-scale line + static SVG": { - "bytes": 39372, - "gzip": 15238 + "bytes": 38163, + "gzip": 14887 }, "Representative marks": { - "bytes": 49134, - "gzip": 17884 + "bytes": 49068, + "gzip": 17979 }, "TanStack DOM host": { - "bytes": 42903, - "gzip": 15701 + "bytes": 31272, + "gzip": 11615 }, "React adapter": { - "bytes": 46343, - "gzip": 16906 + "bytes": 33442, + "gzip": 12314 }, "React line consumer": { - "bytes": 68664, - "gzip": 25712 + "bytes": 55812, + "gzip": 21281 + }, + "Compact-scale line scene": { + "bytes": 17541, + "gzip": 6711 + }, + "React compact-scale line consumer": { + "bytes": 38375, + "gzip": 14227 }, "Custom-scale line scene": { - "bytes": 17096, - "gzip": 6432 + "bytes": 15755, + "gzip": 5961 }, "D3 linear-scale line scene": { - "bytes": 36252, - "gzip": 14084 + "bytes": 35043, + "gzip": 13728 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 60be679..e475dc7 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "generatedAt": "2026-07-31T18:30:38.408Z", + "generatedAt": "2026-07-31T19:37:47.858Z", "packageVersions": { "tanstack": "0.0.2", "chartjs": "4.5.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "059faf0ed4d74f360529356c77e6e0fa46bacf65" + "revision": "b1bba0e6e22fab1099f27afab165d07d283244e9" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 65886, - "gzipBytes": 24861, - "brotliBytes": 22188, - "incrementalGzipBytes": 24861, - "incrementalBrotliBytes": 22188 + "minifiedBytes": 63594, + "gzipBytes": 24325, + "brotliBytes": 21616, + "incrementalGzipBytes": 24325, + "incrementalBrotliBytes": 21616 }, "tanstack-line-interactive": { - "minifiedBytes": 69927, - "gzipBytes": 26070, - "brotliBytes": 23217, - "incrementalGzipBytes": 26070, - "incrementalBrotliBytes": 23217 + "minifiedBytes": 68776, + "gzipBytes": 25969, + "brotliBytes": 23068, + "incrementalGzipBytes": 25969, + "incrementalBrotliBytes": 23068 }, "tanstack-line-advanced": { - "minifiedBytes": 77107, - "gzipBytes": 28419, - "brotliBytes": 25167, - "incrementalGzipBytes": 28419, - "incrementalBrotliBytes": 25167 + "minifiedBytes": 75961, + "gzipBytes": 28274, + "brotliBytes": 25010, + "incrementalGzipBytes": 28274, + "incrementalBrotliBytes": 25010 }, "tanstack-bar-basic": { - "minifiedBytes": 67480, - "gzipBytes": 25415, - "brotliBytes": 22635, - "incrementalGzipBytes": 25415, - "incrementalBrotliBytes": 22635 + "minifiedBytes": 66332, + "gzipBytes": 25290, + "brotliBytes": 22478, + "incrementalGzipBytes": 25290, + "incrementalBrotliBytes": 22478 }, "tanstack-bar-interactive": { - "minifiedBytes": 71517, - "gzipBytes": 26628, - "brotliBytes": 23703, - "incrementalGzipBytes": 26628, - "incrementalBrotliBytes": 23703 + "minifiedBytes": 70369, + "gzipBytes": 26537, + "brotliBytes": 23494, + "incrementalGzipBytes": 26537, + "incrementalBrotliBytes": 23494 }, "tanstack-bar-advanced": { - "minifiedBytes": 72907, - "gzipBytes": 27190, - "brotliBytes": 24112, - "incrementalGzipBytes": 27190, - "incrementalBrotliBytes": 24112 + "minifiedBytes": 71759, + "gzipBytes": 27019, + "brotliBytes": 23947, + "incrementalGzipBytes": 27019, + "incrementalBrotliBytes": 23947 }, "tanstack-area-basic": { - "minifiedBytes": 65765, - "gzipBytes": 24849, - "brotliBytes": 22138, - "incrementalGzipBytes": 24849, - "incrementalBrotliBytes": 22138 + "minifiedBytes": 63472, + "gzipBytes": 24298, + "brotliBytes": 21643, + "incrementalGzipBytes": 24298, + "incrementalBrotliBytes": 21643 }, "tanstack-area-interactive": { - "minifiedBytes": 69806, - "gzipBytes": 26069, - "brotliBytes": 23238, - "incrementalGzipBytes": 26069, - "incrementalBrotliBytes": 23238 + "minifiedBytes": 68658, + "gzipBytes": 25963, + "brotliBytes": 23099, + "incrementalGzipBytes": 25963, + "incrementalBrotliBytes": 23099 }, "tanstack-area-advanced": { - "minifiedBytes": 78219, - "gzipBytes": 28894, - "brotliBytes": 25616, - "incrementalGzipBytes": 28894, - "incrementalBrotliBytes": 25616 + "minifiedBytes": 77071, + "gzipBytes": 28733, + "brotliBytes": 25325, + "incrementalGzipBytes": 28733, + "incrementalBrotliBytes": 25325 }, "tanstack-scatter-basic": { - "minifiedBytes": 65638, - "gzipBytes": 24781, - "brotliBytes": 22078, - "incrementalGzipBytes": 24781, - "incrementalBrotliBytes": 22078 + "minifiedBytes": 63346, + "gzipBytes": 24249, + "brotliBytes": 21571, + "incrementalGzipBytes": 24249, + "incrementalBrotliBytes": 21571 }, "tanstack-scatter-interactive": { - "minifiedBytes": 69679, - "gzipBytes": 26007, - "brotliBytes": 23120, - "incrementalGzipBytes": 26007, - "incrementalBrotliBytes": 23120 + "minifiedBytes": 68528, + "gzipBytes": 25872, + "brotliBytes": 22974, + "incrementalGzipBytes": 25872, + "incrementalBrotliBytes": 22974 }, "tanstack-scatter-advanced": { - "minifiedBytes": 69695, - "gzipBytes": 26012, - "brotliBytes": 23107, - "incrementalGzipBytes": 26012, - "incrementalBrotliBytes": 23107 + "minifiedBytes": 68544, + "gzipBytes": 25877, + "brotliBytes": 22920, + "incrementalGzipBytes": 25877, + "incrementalBrotliBytes": 22920 }, "chartjs-line-basic": { "minifiedBytes": 137909, 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/comparison.md b/docs/comparison.md index 100eab7..f7ac50d 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `059faf0` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b1bba0e` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `059faf0`. +at page render time. The measured TanStack workspace revision is `b1bba0e`. ## Capability matrix @@ -59,7 +59,7 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 24.20–28.22 KiB | — | +| TanStack Charts | 23.68–28.06 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.08–168.27 KiB | 94.96–109.96 KiB | 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..67243c2 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -24,6 +24,9 @@ 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' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -49,6 +52,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..29417a5 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 @@ -275,29 +292,35 @@ continues to own focus, ordering, anchoring, placement, portal coordinates, and dismissal. This React example places a nested pie beside the native rows: ```tsx - ( -
-
{defaultBody}
-
- - {pinned ? ( - - ) : null} -
-
- )} -/> +import { Chart as TooltipChart } from '@tanstack/react-charts/tooltip' + +export function RevenueChart() { + return ( + ( +
+
{defaultBody}
+
+ + {pinned ? ( + + ) : null} +
+
+ )} + /> + ) +} ``` The nested component is an ordinary chart built from the focused group: @@ -414,8 +437,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 461edea..1f5f6ed 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: @@ -156,6 +171,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 0981382..d2fedae 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 166efa9..8332e68 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -205,31 +205,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 a1f5082..486b41e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,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/comparison.md b/packages/charts-core/docs/comparison.md index 100eab7..f7ac50d 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `059faf0` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b1bba0e` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `059faf0`. +at page render time. The measured TanStack workspace revision is `b1bba0e`. ## Capability matrix @@ -59,7 +59,7 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 24.20–28.22 KiB | — | +| TanStack Charts | 23.68–28.06 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.08–168.27 KiB | 94.96–109.96 KiB | 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..67243c2 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,9 @@ 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' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -49,6 +52,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..29417a5 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 @@ -275,29 +292,35 @@ continues to own focus, ordering, anchoring, placement, portal coordinates, and dismissal. This React example places a nested pie beside the native rows: ```tsx - ( -
-
{defaultBody}
-
- - {pinned ? ( - - ) : null} -
-
- )} -/> +import { Chart as TooltipChart } from '@tanstack/react-charts/tooltip' + +export function RevenueChart() { + return ( + ( +
+
{defaultBody}
+
+ + {pinned ? ( + + ) : null} +
+
+ )} + /> + ) +} ``` The nested component is an ordinary chart built from the focused group: @@ -414,8 +437,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 461edea..1f5f6ed 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: @@ -156,6 +171,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 0981382..d2fedae 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 166efa9..8332e68 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -205,31 +205,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 21f33e4..79ece26 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", @@ -66,6 +66,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", "./types": "./src/portable-types.ts", "./vector": "./src/vector.ts" }, @@ -217,6 +219,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" + }, "./types": { "types": "./dist/portable-types.d.ts", "import": "./dist/portable-types.js" diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 347b70e..41ca2d9 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 { ChartSurfaceRenderOptions } from './dom-types' import type { ChartScene, SceneNode } from './types' @@ -362,7 +363,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/dom-types.ts b/packages/charts-core/src/dom-types.ts index 0980783..41be750 100644 --- a/packages/charts-core/src/dom-types.ts +++ b/packages/charts-core/src/dom-types.ts @@ -6,6 +6,12 @@ import type { ChartSvgRenderer, ChartTextMeasurer, ChartTooltipBodyContext, + ChartTooltipExtensionToken, + ChartTooltipOptions, + ChartTooltipPlacement, + ChartTooltipPortalExtensionToken, + ChartTooltipPortalOptions, + ChartTooltipPosition, ChartValue, RenderChartOptions, RenderChartSvgOptions, @@ -72,6 +78,79 @@ export interface ChartTooltipBodyTarget< element: HTMLElement } +export interface ChartTooltipExtension extends ChartTooltipExtensionToken { + 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 interface ChartTooltipPortalExtension extends ChartTooltipPortalExtensionToken { + 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 interface ChartRenderContext< TDatum = unknown, TXValue extends ChartValue = ChartValue, diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 2b7dabb..853b73f 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -42,4 +42,17 @@ describe('public package exports', () => { .sort(), ) }) + + 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 2144176..564bbaa 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -59,6 +59,14 @@ export type { ChartSurface, ChartSurfaceRenderOptions, ChartTooltipBodyTarget, + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipPaintContext, + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalPositionContext, } from './dom-types' export type { Channel, @@ -89,6 +97,7 @@ export type { ChartCurve, ChartDefinition, ChartDefinitionOptions, + ChartExtensionInput, ChartFocusMode, ChartFocusPreset, ChartFocusStrategy, @@ -135,10 +144,13 @@ export type { ChartTooltipChannelItem, ChartTooltipDatumItem, ChartTooltipDerivedItem, + ChartTooltipInput, ChartTooltipItem, ChartTooltipItemBase, ChartTooltipOptions, ChartTooltipPlacement, + ChartTooltipPortalInput, + ChartTooltipPortalOptions, 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/portable-types.ts b/packages/charts-core/src/portable-types.ts index 4830c1b..6b47c81 100644 --- a/packages/charts-core/src/portable-types.ts +++ b/packages/charts-core/src/portable-types.ts @@ -43,6 +43,7 @@ export type { ChartCurve, ChartDefinition, ChartDefinitionOptions, + ChartExtensionInput, ChartFocusMode, ChartFocusPreset, ChartFocusStrategy, @@ -89,10 +90,13 @@ export type { ChartTooltipChannelItem, ChartTooltipDatumItem, ChartTooltipDerivedItem, + ChartTooltipInput, ChartTooltipItem, ChartTooltipItemBase, ChartTooltipOptions, ChartTooltipPlacement, + ChartTooltipPortalInput, + ChartTooltipPortalOptions, ChartTooltipPosition, ChartTooltipRow, ChartTooltipSort, diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index e66af17..23aa6e1 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, ChartSurface, @@ -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 b1ed502..538dab5 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -6,7 +6,8 @@ import type { ChartRendererHost, ChartRendererHostOptions, ChartSurface, - ChartTooltipBodyTarget, + ChartTooltipExtension, + ChartTooltipExtensionInstance, } from './dom-types' import type { ChartAnimationOptions, @@ -16,25 +17,14 @@ import type { ChartRuntime, ChartScene, ChartSpatialIndex, - ChartTooltipContent, - ChartTooltipContentContext, - ChartTooltipChannelItem, - ChartTooltipItem, + 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 @@ -65,26 +55,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 @@ -200,20 +173,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, @@ -232,7 +191,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 @@ -268,8 +227,7 @@ export function mountChartRenderer< if (pinnedKey) return updateFocus( pointsAtPointer(event.clientX, event.clientY), - tooltipTracksPointer() || - (tooltipUsesNativePopover && !tooltipPopoverIsOpen()), + tooltipTracksPointer(), ) } const clearTransientFocus = ({ relatedTarget }: MouseEvent | FocusEvent) => { @@ -286,7 +244,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) @@ -352,12 +310,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) @@ -398,7 +350,7 @@ export function mountChartRenderer< fontChanged const observerChanged = options.width !== nextOptions.width options = nextOptions - if (!tooltipUsesPortal()) moveTooltipToContainer() + syncTooltip() if (!tooltipIsSticky()) pinnedKey = null if (needsRender) { render( @@ -484,377 +436,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 as ChartTooltipExtension, + options: emptyTooltipOptions, + } + : { + extension: input.use as ChartTooltipExtension, + options: input, + } +} + function samePointIdentity< TDatum, TXValue extends ChartValue, @@ -873,20 +534,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, @@ -1104,560 +751,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..c868765 --- /dev/null +++ b/packages/charts-core/src/tooltip-portal.ts @@ -0,0 +1,223 @@ +import { + placeTooltip, + pointInBounds, + sceneToClient, + viewportBounds, +} from './tooltip-position' +import type { + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalPositionContext, +} from './dom-types' +import type { ChartTooltipPortalOptions } from './types' + +export const portal: ChartTooltipPortalExtension = { + id: 'portal', + create: createPortal, +} + +export type { + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionContext, + ChartTooltipPortalExtensionInstance, + ChartTooltipPortalPositionContext, +} from './dom-types' +export type { + ChartTooltipPortalInput, + ChartTooltipPortalOptions, +} 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..a482caf --- /dev/null +++ b/packages/charts-core/src/tooltip.ts @@ -0,0 +1,771 @@ +import { placeTooltip } from './tooltip-position' +import type { + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipPaintContext, + ChartTooltipPortalExtension, + ChartTooltipPortalExtensionInstance, +} from './dom-types' +import type { + ChartPoint, + ChartScene, + ChartTooltipChannelItem, + ChartTooltipContent, + ChartTooltipContentContext, + ChartTooltipItem, + ChartTooltipOptions, + ChartTooltipPortalOptions, + ChartValue, +} from './types' + +export const tooltip: ChartTooltipExtension = { + id: 'tooltip', + create: createTooltipExtension, +} + +export type { + ChartTooltipExtension, + ChartTooltipExtensionContext, + ChartTooltipExtensionInstance, + ChartTooltipPaintContext, +} from './dom-types' + +export type { + ChartTooltipAnchor, + ChartTooltipAnchorContext, + ChartTooltipChannelItem, + ChartTooltipContent, + ChartTooltipContentContext, + ChartTooltipDatumItem, + ChartTooltipDerivedItem, + ChartTooltipInput, + ChartTooltipItem, + ChartTooltipItemBase, + ChartTooltipOptions, + 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 + ) as ChartTooltipPortalExtension | 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..1436d3f 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,29 @@ if (false) { ], }, }) + // @ts-expect-error Configured tooltip options require an extension token. + defineChart(staticDefinition, { tooltip: { sticky: false } }) + // @ts-expect-error Portal tokens cannot enable the tooltip extension. + defineChart(staticDefinition, { tooltip: portal }) + // @ts-expect-error Portaling requires the explicit portal extension. + defineChart(staticDefinition, { + tooltip: { + use: tooltip, + portal: true, + }, + }) + // @ts-expect-error Tooltip tokens cannot configure portal transport. + defineChart(staticDefinition, { + tooltip: { + use: tooltip, + portal: tooltip, + }, + }) 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 8ded05e..957ddb3 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< @@ -723,7 +723,7 @@ export interface ChartTooltipOptions< TYValue extends ChartValue = ChartValue, > { className?: string - portal?: boolean + portal?: ChartTooltipPortalInput items?: readonly ChartTooltipItem[] sort?: ChartTooltipSort anchor?: ChartTooltipAnchor @@ -740,6 +740,37 @@ export interface ChartTooltipOptions< sticky?: boolean } +export type ChartExtensionInput = + TExtension | ({ use: TExtension } & TOptions) + +export interface ChartTooltipExtensionToken { + readonly id: string + readonly create: Function + readonly __chartExtensionType?: 'tooltip' +} + +export type ChartTooltipInput< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> = ChartExtensionInput< + ChartTooltipExtensionToken, + ChartTooltipOptions +> + +export type ChartTooltipPortalOptions = Record + +export interface ChartTooltipPortalExtensionToken { + readonly id: string + readonly create: Function + readonly __chartExtensionType?: 'tooltip-portal' +} + +export type ChartTooltipPortalInput = ChartExtensionInput< + ChartTooltipPortalExtensionToken, + ChartTooltipPortalOptions +> + 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..92a153c --- /dev/null +++ b/packages/charts-scales/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/charts-scales", + "version": "0.0.2", + "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 ef694dc..2b16e38 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 ( -