diff --git a/.changeset/brave-tooltips-expand.md b/.changeset/brave-tooltips-expand.md new file mode 100644 index 00000000..c44bcf12 --- /dev/null +++ b/.changeset/brave-tooltips-expand.md @@ -0,0 +1,26 @@ +--- +'@tanstack/charts': minor +'@tanstack/react-charts-catalog': patch +'@tanstack/react-native-charts': patch +--- + +Expose pinned state to tooltip content and item callbacks so built-in and +framework-rendered tooltips can expand with additional detail when pinned. +Keep dismissing clicks owned by the tooltip when a framework body unmounts +during event propagation. + +Standardize public callback parameters as primary data plus a context bag. +Tooltip `format` and `formatGroup` now receive the same content context as +`content`; channel accessors use `(datum, { index, data })`; facet, focus, +legend, and spatial-index extension callbacks move their supporting values +into named context objects. + +`ruleX` and `ruleY` now expose presentation-only focus points, so +`whenFocused(..., { match: 'x' })` and `whenFocused(..., { match: 'y' })` can +reveal focused guide rules without making them interaction or tooltip targets. + +Update the published pinned-tooltip catalog case to show the energy overview, +compact hover summary, and animated pinned detail. + +Preserve distinct source rows in the linear-regression, framed-scatter, and +many-point-scatter catalog examples when car names and years repeat. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5f63ee1b..486e8b4d 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-08-04 +Last updated: 2026-08-05 ## Triage rule @@ -244,6 +244,13 @@ Each entry records: | F-206 | CI repeated unaffected work across every partition | Tooling | monitoring | | F-207 | Packed consumers serialized independent verification | Tooling/Release | resolved | | F-208 | Benchmark shards repeated setup and skewed work | Tooling | monitoring | +| F-209 | Dismissal could click through a composed tooltip | API | resolved | +| F-210 | Recharts point replacement canceled activation events | Application | monitoring | +| F-211 | Structured tooltip rows could not interleave details | API | monitoring | +| F-212 | Paint parity normalized patterns but not gradients | Tooling | resolved | +| F-213 | Focused rules had no matchable presentation points | API | resolved | +| F-214 | Callback parameter shapes were inconsistent | API/Tooling | resolved | +| F-215 | Example keys collapsed distinct source rows | Application | resolved | ## Findings @@ -687,7 +694,11 @@ Each entry records: derived point text; `sort` controls grouped-series order. Point, pointer, group-center, and custom anchors combine with fixed or ordered fallback placements. `content` remains the escape hatch for application-specific - grouped structure without accepting arbitrary DOM. Every framework adapter + grouped structure without accepting arbitrary DOM. The shared content + context exposes pinned state to `content`, `format`, `formatGroup`, and item + `text` callbacks, so either structured or plaintext content can expand from + transient summaries to pinned details. + Every framework adapter adds a native body-composition boundary with the focused points, resolved content, native `defaultBody`, pinned state, and a dismissal action. Chart behavior remains definition-owned; adapter props, slots, snippets, templates, @@ -703,7 +714,10 @@ Each entry records: Octane adapter tests prove native-body composition and cleanup. Framework lifecycle suites additionally cover nested charts, stable body mounting, typed and sorted points, transient inertness, pinned dialog semantics, and - dismissal. With framework and core packages external, no adapter adds more + dismissal. Focused DOM and native tests verify that content, item, point, + and group formatters change from `false` while transient to `true` while + pinned. With framework and core + packages external, no adapter adds more than 1.4 kB gzip. No new runtime library was introduced; React DOM is now declared as the React adapter's portal peer. @@ -3694,16 +3708,18 @@ Each entry records: - Status: monitoring - Severity: low - Owner: Tooling -- Observed in: validating Nx from a sandboxed Git worktree +- Observed in: validating Nx from sandboxed Git worktrees, most recently the + expanding pinned energy-tooltip example - Friction: Nx resolved its relative cache and workspace-data directories through the worktree's common Git checkout. The sandbox could execute every target but could not write task metadata outside the active worktree. - Current decision: keep the portable repository defaults. In restricted worktrees, set `NX_CACHE_DIRECTORY` and `NX_WORKSPACE_DATA_DIRECTORY` to absolute paths inside that worktree. -- Verification: the full 17-target validation graph passes with both - directories scoped to the active worktree. Ordinary clones and GitHub - Actions retain `.nx/cache` and `.nx/workspace-data`. +- Verification: the full 17-target validation graph and the energy-tooltip + workspace typecheck pass with both directories scoped to the active + worktree. Ordinary clones and GitHub Actions retain `.nx/cache` and + `.nx/workspace-data`. ### F-151 — Artifact actions targeted deprecated Node 20 @@ -5173,3 +5189,165 @@ Each entry records: stress shard completes with zero failures. - Follow-up: replace predicted weights with measured per-case and per-workload cloud durations after the next complete scheduled runs. + +### F-209 — Dismissal could click through a composed tooltip + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: the expanding pinned energy-tooltip catalog example +- Friction: a framework close button called `dismiss()` during its target-phase + click handler. The adapter then unmounted that button before the event + reached the chart container, so a live descendant check no longer recognized + the event as tooltip-owned. When the portaled tooltip overlapped the chart, + the same click immediately pinned the point underneath it. +- Decision: identify tooltip-owned clicks from the event's immutable composed + path, filtering that path to DOM nodes, instead of relying only on parentage + after target handlers have run. +- Verification: a renderer regression unmounts its close button synchronously + during dismissal and proves the chart stays unfocused. The paired energy + case closes correctly at 320 and 640 pixels across both data revisions. + +### F-210 — Recharts point replacement canceled activation events + +- Status: monitoring +- Severity: low +- Owner: Application +- Observed in: the Recharts reference for the expanding energy tooltip +- Friction: showing the transient tooltip rerendered Recharts' custom point + elements between pointer movement and mouse activation. Browser click + synthesis could then lose the original point target even though the pointer + remained at the same semantic datum. The same replacement could disconnect a + focused point before a subsequent keyboard event. +- Current decision: make the behavior scenario explicit about its hover-then- + click sequence. The reference resolves activation during document capture, + scopes it to the chart bounds, ignores the tooltip subtree, and falls back to + the nearest current point geometry within the point hit radius. Keyboard + focus stays on a stable listbox root while `aria-activedescendant` identifies + the current month option. +- Verification: the paired behavior matrix passes hover, pointer pin, keyboard + pin, Escape, and close scenarios at 320 and 640 pixels across both revisions + without unsafe type assertions or renderer internals. + +### F-211 — Structured tooltip rows could not interleave custom detail + +- Status: monitoring +- Severity: low +- Owner: API +- Observed in: matching the expanding pinned energy tooltip to its source clip +- Friction: `ChartTooltipContent` can describe only a title and flat rows. The + source layout expands a consumption breakdown directly below the Consumption + row, then places a generation breakdown below the Generation row, followed by + a full-width coverage sentence. The framework renderer therefore had to own + and repeat the two summary rows instead of composing `defaultBody` with the + inserted detail. +- Current decision: keep the structured callback stable at two summary rows and + let the custom body renderer own non-tabular ordering. Do not expand the + generic content schema from one application layout. +- Verification: the paired case reports two summary rows in both transient and + pinned states, then adds four nested consumption segments, six detail rows, + and the persistent coverage footer only through the custom renderer. + +### F-212 — Paint parity normalized patterns but not gradients + +- Status: resolved +- Severity: low +- Owner: Tooling +- Observed in: the solid-and-hatched generation bars in the expanding energy + tooltip comparison +- Friction: the visual gate resolved an SVG pattern to its backing rectangle + color but left an equivalent linear-gradient paint as a raw resource URL. + The Recharts pattern and Charts gradient therefore failed paint parity even + though both used the same exported-energy color. +- Decision: resolve a referenced gradient to its first stop color when no + pattern rectangle or path exists, preserving the existing solid-color + comparison contract. +- Verification: the paired energy case compares the pattern and gradient fills + as the same exported-energy paint while retaining their rendered hatch + treatments. + +### F-213 — Focused rules had no matchable presentation points + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: the active-month guide in the expanding energy tooltip example +- Friction: `whenFocused(ruleX(...), { match: "x" })` typechecked and matched + the documented focused-rule recipe, but every rule stayed hidden because + rules intentionally emit no interaction points. Replacing the rule with a + link would have introduced the wrong semantic mark solely to obtain a focus + candidate. +- Decision: let a mark scene expose presentation-only `focusPoints`. Focus + layers consume those candidates without adding them to the chart's global + interaction points. `ruleX` and `ruleY` now provide candidates keyed to their + rendered nodes while the nodes remain interaction-free. +- Verification: focused-rule regressions reveal exactly one full-span rule for + matching x and y values, retain only the underlying data marks in + `scene.points`, and keep rule nodes free of interaction metadata. The paired + energy case passes its visual, behavior, geometry, and type gates with the + native focused `ruleX` guide. After integration with `0.6.5` and the callback + context migration, the complete PR adds 109 minified and 41 gzip bytes to the + locked line-scene entry and 476 minified and 101 gzip bytes to the + representative-marks entry. The Stats parity ceiling moves from 42.3 to 42.5 + KiB. + +### F-214 — Callback parameter shapes were inconsistent + +- Status: resolved +- Severity: medium +- Owner: API/Tooling +- Observed in: review of the expanding pinned-tooltip API +- Friction: `content(points, context)` exposed pinned and formatting state, + while sibling `format(point)` and `formatGroup(points)` did not. A full + public-surface audit also found authored callbacks with three or four + positional parameters and two-argument callbacks whose second parameter was + another unlabelled value. Consumers had to remember a different parameter + convention for channels, facets, focus strategies, legends, spatial indexes, + and tooltips. +- Decision: public callbacks accept at most two arguments. Primary data or + purpose comes first and additional state comes second in a named context or + options object; callbacks without a distinct primary payload receive one + object. Migrate channels to `(datum, { index, data })`, facet builders to + `(data, { key })`, focus resolution and grouping to `(points, context)`, + legend measurement to `(itemCount, context)`, spatial factories to + `(points, { scene })`, and all tooltip presentation callbacks to the shared + `ChartTooltipContentContext`. Keep standard comparators, paired geometry, + exact upstream protocols such as D3 threshold generators, and + consumer-called service methods as classified exceptions. +- Verification: the public callback inventory follows exported types, + functions, and values into nested package-owned types. It classifies all 527 + reachable callable surfaces, including Alpine's external directive protocol + and Vue's nested tooltip slot, and rejects unclassified surfaces, callback + arity above two, or a non-object second callback argument. Failed parameter + type resolution preserves the parameter as a fail-closed non-object bag + instead of aborting or undercounting the inventory. Focused core, React + Native, React, Octane, channel, facet, focus, legend, tooltip, and contract + tests cover the migrated shapes, including configured tooltip item labels in + React Native callback context; the migration guide records every breaking + before-and-after signature. Full type, documentation, package, format, + bundle, and comparison gates pass. Against `0.6.5`, the reviewed + universal bundle baseline increases by at most 476 minified and 137 gzip + bytes. The Stats parity and D3 quadtree ceilings each move by 0.2 KiB; the + React compact-line and Delaunay integration ceilings each move by 0.1 KiB. + The focused expanding tooltip conformance case retains 99.6% geometry + similarity and passes visual, behavior, and type gates at both sizes and + themes. + +### F-215 — Example keys collapsed distinct source rows + +- Status: resolved +- Severity: medium +- Owner: Application +- Observed in: the full conformance matrix for the linear-regression, + framed-scatter, and many-point-scatter examples +- Friction: all three examples keyed car rows with only `name` and `year`. The + cars dataset contains same-name, same-year rows with different measurements, + so keyed scene reconciliation retained fewer dots than requested: 318 of 320 + in the first two examples and as few as 297 of 300 in the third. +- Decision: include each example's plotted channel measurements in its key so + distinct source observations remain distinct while keys stay stable across + revisions. +- Verification: the first two examples now retain 320 unique keys and the + many-point example retains 300 unique keys for their initial and revised + windows. Focused standard conformance passes each example's full 320/640/960 + light/dark visual matrix. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5047343e..e8e2b900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,11 @@ pnpm package:check pnpm bundle:check ``` +The unit suite inventories callback surfaces from every published package. +Public callbacks use primary data plus one context/options bag and never exceed +two arguments. New external-protocol or service-method exceptions require an +explicit classification in the contract, not a broader allowlist. + Pull-request CI runs the cached Nx target graph, the locked comparison bundle gate, and these uncached browser matrices in parallel: diff --git a/PLAN.md b/PLAN.md index fe7bd89f..a6ac425f 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-30 +Last updated: 2026-08-05 ## Working thesis @@ -69,6 +69,18 @@ These are governing constraints, not optional aspirations. - Built-in marks and capabilities use the same public extension contracts available to application and ecosystem code. +### Predictable callback shape + +- Public callbacks accept at most two arguments. +- Primary data or purpose comes first. Additional state comes second in a + named context or options object. +- A callback without a distinct primary payload receives one context object. +- Standard comparators, exact upstream protocols, paired geometry, and + consumer-called service methods are classified exceptions rather than + accidental precedents. +- The public callback inventory is a test contract. A new callback must follow + this shape or document why an existing external protocol requires otherwise. + ### D3 is the algorithm layer This is the destination ownership contract. The checkpoint below records which diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 3b7d398a..5aac91b5 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +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": 39460, - "gzip": 15341 + "bytes": 39569, + "gzip": 15382 }, "D3-scale line + static SVG": { - "bytes": 42401, - "gzip": 16432 + "bytes": 42510, + "gzip": 16473 }, "Representative marks": { - "bytes": 58190, - "gzip": 21602 + "bytes": 58666, + "gzip": 21703 }, "TanStack DOM host": { - "bytes": 44655, - "gzip": 16307 + "bytes": 44879, + "gzip": 16403 }, "React adapter": { - "bytes": 46878, - "gzip": 17021 + "bytes": 47104, + "gzip": 17127 }, "React line consumer": { - "bytes": 69544, - "gzip": 26096 + "bytes": 69840, + "gzip": 26233 }, "Compact-scale line scene": { - "bytes": 21924, - "gzip": 8286 + "bytes": 22033, + "gzip": 8322 }, "React compact-scale line consumer": { - "bytes": 52054, - "gzip": 18994 + "bytes": 52348, + "gzip": 19127 }, "Custom-scale line scene": { - "bytes": 20108, - "gzip": 7547 + "bytes": 20217, + "gzip": 7583 }, "D3 linear-scale line scene": { - "bytes": 39392, - "gzip": 15304 + "bytes": 39501, + "gzip": 15346 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 5b650ff0..179e1df3 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, - "generatedAt": "2026-08-05T00:01:11.697Z", + "generatedAt": "2026-08-05T16:17:02.804Z", "packageVersions": { - "tanstack": "0.6.4", + "tanstack": "0.6.5", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "11ba4584e6aee639a58e353b5c828b2f3d207f62" + "revision": "dcce66ca3838ebd4a6eda17e2a11e5ef01832d5a" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 78296, - "gzipBytes": 29488, - "brotliBytes": 26175, - "incrementalGzipBytes": 29488, - "incrementalBrotliBytes": 26175 + "minifiedBytes": 78607, + "gzipBytes": 29626, + "brotliBytes": 26260, + "incrementalGzipBytes": 29626, + "incrementalBrotliBytes": 26260 }, "tanstack-line-interactive": { - "minifiedBytes": 83467, - "gzipBytes": 31135, - "brotliBytes": 27461, - "incrementalGzipBytes": 31135, - "incrementalBrotliBytes": 27461 + "minifiedBytes": 83809, + "gzipBytes": 31281, + "brotliBytes": 27591, + "incrementalGzipBytes": 31281, + "incrementalBrotliBytes": 27591 }, "tanstack-line-advanced": { - "minifiedBytes": 90652, - "gzipBytes": 33486, - "brotliBytes": 29525, - "incrementalGzipBytes": 33486, - "incrementalBrotliBytes": 29525 + "minifiedBytes": 90990, + "gzipBytes": 33624, + "brotliBytes": 29636, + "incrementalGzipBytes": 33624, + "incrementalBrotliBytes": 29636 }, "tanstack-bar-basic": { - "minifiedBytes": 85307, - "gzipBytes": 32155, - "brotliBytes": 28446, - "incrementalGzipBytes": 32155, - "incrementalBrotliBytes": 28446 + "minifiedBytes": 85622, + "gzipBytes": 32298, + "brotliBytes": 28578, + "incrementalGzipBytes": 32298, + "incrementalBrotliBytes": 28578 }, "tanstack-bar-interactive": { - "minifiedBytes": 89333, - "gzipBytes": 33360, - "brotliBytes": 29418, - "incrementalGzipBytes": 33360, - "incrementalBrotliBytes": 29418 + "minifiedBytes": 89673, + "gzipBytes": 33507, + "brotliBytes": 29550, + "incrementalGzipBytes": 33507, + "incrementalBrotliBytes": 29550 }, "tanstack-bar-advanced": { - "minifiedBytes": 89672, - "gzipBytes": 33491, - "brotliBytes": 29580, - "incrementalGzipBytes": 33491, - "incrementalBrotliBytes": 29580 + "minifiedBytes": 90012, + "gzipBytes": 33641, + "brotliBytes": 29696, + "incrementalGzipBytes": 33641, + "incrementalBrotliBytes": 29696 }, "tanstack-area-basic": { - "minifiedBytes": 82105, - "gzipBytes": 30987, - "brotliBytes": 27417, - "incrementalGzipBytes": 30987, - "incrementalBrotliBytes": 27417 + "minifiedBytes": 82421, + "gzipBytes": 31119, + "brotliBytes": 27580, + "incrementalGzipBytes": 31119, + "incrementalBrotliBytes": 27580 }, "tanstack-area-interactive": { - "minifiedBytes": 87284, - "gzipBytes": 32666, - "brotliBytes": 28823, - "incrementalGzipBytes": 32666, - "incrementalBrotliBytes": 28823 + "minifiedBytes": 87627, + "gzipBytes": 32804, + "brotliBytes": 28888, + "incrementalGzipBytes": 32804, + "incrementalBrotliBytes": 28888 }, "tanstack-area-advanced": { - "minifiedBytes": 94651, - "gzipBytes": 34963, - "brotliBytes": 30851, - "incrementalGzipBytes": 34963, - "incrementalBrotliBytes": 30851 + "minifiedBytes": 94990, + "gzipBytes": 35086, + "brotliBytes": 30958, + "incrementalGzipBytes": 35086, + "incrementalBrotliBytes": 30958 }, "tanstack-scatter-basic": { - "minifiedBytes": 77971, - "gzipBytes": 29401, - "brotliBytes": 26080, - "incrementalGzipBytes": 29401, - "incrementalBrotliBytes": 26080 + "minifiedBytes": 78269, + "gzipBytes": 29533, + "brotliBytes": 26166, + "incrementalGzipBytes": 29533, + "incrementalBrotliBytes": 26166 }, "tanstack-scatter-interactive": { - "minifiedBytes": 83142, - "gzipBytes": 31035, - "brotliBytes": 27375, - "incrementalGzipBytes": 31035, - "incrementalBrotliBytes": 27375 + "minifiedBytes": 83467, + "gzipBytes": 31175, + "brotliBytes": 27513, + "incrementalGzipBytes": 31175, + "incrementalBrotliBytes": 27513 }, "tanstack-scatter-advanced": { - "minifiedBytes": 83158, - "gzipBytes": 31041, - "brotliBytes": 27438, - "incrementalGzipBytes": 31041, - "incrementalBrotliBytes": 27438 + "minifiedBytes": 83483, + "gzipBytes": 31181, + "brotliBytes": 27567, + "incrementalGzipBytes": 31181, + "incrementalBrotliBytes": 27567 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts index 8985ac26..8b38556a 100644 --- a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts +++ b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts @@ -59,7 +59,8 @@ const definition = (input: ConformanceInput) => { return defineChart({ marks: [ dot(scatterRows, { - key: (row) => `${row.name}:${row.year}`, + key: (row) => + `${row.name}:${row.year}:${row['power (hp)']}:${row['economy (mpg)']}`, x: 'power (hp)', y: 'economy (mpg)', fill: '#93c5fd', diff --git a/benchmarks/conformance/cases/44-framed-scatter/tanstack.ts b/benchmarks/conformance/cases/44-framed-scatter/tanstack.ts index 10756f23..809becdf 100644 --- a/benchmarks/conformance/cases/44-framed-scatter/tanstack.ts +++ b/benchmarks/conformance/cases/44-framed-scatter/tanstack.ts @@ -24,7 +24,8 @@ const definition = (input: ConformanceInput) => { strokeOpacity: 0.7, }), dot(scatterRows, { - key: (row) => `${row.name}:${row.year}`, + key: (row) => + `${row.name}:${row.year}:${row['weight (lb)']}:${row['economy (mpg)']}`, x: 'weight (lb)', y: 'economy (mpg)', fill: '#2563eb', diff --git a/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts b/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts index 8da71f44..7278c9c2 100644 --- a/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts +++ b/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts @@ -23,7 +23,8 @@ const definition = (input: ConformanceInput) => { return defineChart({ marks: [ dot(points, { - key: (row) => `${row.name}:${row.year}`, + key: (row) => + `${row.name}:${row.year}:${row['weight (lb)']}:${row['0-60 mph (s)']}:${row['displacement (cc)']}`, x: 'weight (lb)', y: '0-60 mph (s)', color: 'cylinders', diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/case.json b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/case.json index 6d111a66..d33c072d 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/case.json +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/case.json @@ -3,96 +3,95 @@ "referenceRenderer": "recharts", "order": 840, "id": "84-pinned-nested-chart-tooltip", - "title": "Pinned penguin tooltip with a nested chart", + "title": "Expanding pinned energy tooltip", "family": "interaction", - "intent": "Pin a penguin observation on click and compare four nearby same-species observations in a real secondary chart without making hover destructive.", + "intent": "Keep monthly energy totals compact during transient inspection, then smoothly expand the same pinned tooltip into consumption and solar-use detail.", "support": "composed", "features": [ - "external rich tooltip", + "native structured tooltip content", + "pinned content context", + "custom tooltip body renderer", "click to pin", - "hover without activation", + "animated intrinsic height", "nested chart", "Escape dismissal" ], "geometry": [ - { - "view": "main", - "role": "dot", - "count": 5 - } + { "view": "main", "role": "bar", "count": 24 }, + { "view": "main", "role": "dot", "count": 12 } ], "interactionScenarios": [ { - "id": "hover-does-not-open-tooltip", + "id": "hover-shows-compact-summary", "steps": [ { "type": "pointerMove", - "target": { - "view": "main", - "anchor": "point:adelie-biscoe-female" - } + "target": { "view": "main", "anchor": "month:jun" } }, { "type": "assert", "assertions": [ - { "path": "hoveredId", "equals": "adelie-biscoe-female" }, - { "path": "tooltip.visible", "equals": false }, - { "path": "tooltip.pinnedId", "equals": null }, - { "path": "tooltip.miniBarCount", "equals": 0 } + { "path": "focusedMonth", "equals": "jun" }, + { "path": "tooltip.visible", "equals": true }, + { "path": "tooltip.pinned", "equals": false }, + { "path": "tooltip.role", "equals": "status" }, + { "path": "tooltip.inert", "equals": true }, + { "path": "tooltip.month", "equals": "June" }, + { "path": "tooltip.summaryRowCount", "equals": 2 }, + { "path": "tooltip.detailsExpanded", "equals": false }, + { "path": "tooltip.closeVisible", "equals": false } ] }, { "type": "pointerLeave", "view": "main" }, { "type": "assert", "assertions": [ - { "path": "hoveredId", "equals": null }, + { "path": "focusedMonth", "equals": null }, { "path": "tooltip.visible", "equals": false } ] } ] }, { - "id": "pin-and-dismiss-with-escape", + "id": "pin-expand-and-dismiss-with-escape", "steps": [ { - "type": "click", - "target": { - "view": "main", - "anchor": "point:gentoo-biscoe-male" - } + "type": "pointerMove", + "target": { "view": "main", "anchor": "month:jun" } }, { - "type": "assert", - "assertions": [ - { "path": "tooltip.visible", "equals": true }, - { "path": "tooltip.pinnedId", "equals": "gentoo-biscoe-male" }, - { "path": "tooltip.miniBarCount", "equals": 4 }, - { "path": "tooltip.chartCount", "equals": 1 } - ] + "type": "click", + "target": { "view": "main", "anchor": "month:jun" } }, + { "type": "wait", "durationMs": 400 }, { "type": "assert", "assertions": [ - { "path": "tooltip.selectedOverlayCount", "equals": 1 }, - { "path": "tooltip.flipperLabelCount", "equals": 4 }, - { "path": "tooltip.closeVisible", "equals": true } + { "path": "focusedMonth", "equals": "jun" }, + { "path": "tooltip.visible", "equals": true }, + { "path": "tooltip.pinned", "equals": true }, + { "path": "tooltip.role", "equals": "dialog" }, + { "path": "tooltip.inert", "equals": false }, + { "path": "tooltip.summaryRowCount", "equals": 2 }, + { "path": "tooltip.detailsExpanded", "equals": true }, + { "path": "tooltip.detailRowCount", "equals": 6 }, + { "path": "tooltip.nestedBarCount", "equals": 4 }, + { "path": "tooltip.closeVisible", "equals": true }, + { "path": "tooltip.text", "includes": "EV charging" } ] }, { "type": "assertRendered", "assertions": [ { - "target": { - "selector": "[data-external-tooltip=\"pinned\"]" - }, - "property": "contained", - "equals": true, - "tolerance": 1 + "target": { "selector": ".energy-tooltip__reveal" }, + "property": "height", + "atLeast": 180 }, { "target": { "role": "button", - "name": "Close pinned penguin details", + "name": "Close energy details", "exact": true }, "property": "visible", @@ -101,56 +100,26 @@ { "target": { "role": "button", - "name": "Close pinned penguin details", - "exact": true - }, - "property": "height", - "atLeast": 44 - }, - { - "target": { - "role": "button", - "name": "Close pinned penguin details", + "name": "Close energy details", "exact": true }, "property": "width", - "atLeast": 44 - }, - { - "target": { - "selector": "[data-external-tooltip=\"pinned\"]" - }, - "property": "attribute", - "attribute": "aria-labelledby", - "includes": "nested-tooltip-" - }, - { - "target": { - "role": "region", - "name": "Penguin measurements with a pinned nested-chart tooltip", - "exact": true - }, - "property": "count", - "equals": 1 + "atLeast": 36 } ] }, - { "type": "screenshot", "name": "pinned-nested-chart-open" }, + { "type": "screenshot", "name": "pinned-energy-tooltip-expanded" }, { "type": "key", "key": "Escape", - "target": { - "view": "main", - "anchor": "point:gentoo-biscoe-male" - } + "target": { "view": "main", "anchor": "month:jun" } }, { "type": "assert", "assertions": [ + { "path": "focusedMonth", "equals": null }, { "path": "tooltip.visible", "equals": false }, - { "path": "tooltip.pinnedId", "equals": null }, - { "path": "tooltip.miniBarCount", "equals": 0 }, - { "path": "focusedPoint", "equals": "gentoo-biscoe-male" } + { "path": "tooltip.pinned", "equals": false } ] } ] @@ -158,65 +127,73 @@ { "id": "close-button-dismisses", "steps": [ + { + "type": "pointerMove", + "target": { "view": "main", "anchor": "month:mar" } + }, { "type": "click", - "target": { - "view": "main", - "anchor": "point:chinstrap-dream-male" - } + "target": { "view": "main", "anchor": "month:mar" } }, + { "type": "wait", "durationMs": 400 }, { "type": "click", - "target": { - "view": "main", - "anchor": "tooltip:close" - } + "target": { "view": "main", "anchor": "tooltip:close" } }, { "type": "assert", "assertions": [ + { "path": "focusedMonth", "equals": null }, { "path": "tooltip.visible", "equals": false }, - { "path": "tooltip.pinnedId", "equals": null }, - { "path": "tooltip.selectedOverlayCount", "equals": 0 }, - { "path": "focusedPoint", "equals": "chinstrap-dream-male" } + { "path": "tooltip.pinned", "equals": false } ] } ] }, { - "id": "second-click-unpins", + "id": "keyboard-focus-pins-and-expands", "steps": [ { - "type": "click", - "target": { - "view": "main", - "anchor": "point:adelie-biscoe-female" - } + "type": "key", + "key": "Enter", + "target": { "view": "main", "anchor": "month:jan" } }, + { "type": "wait", "durationMs": 300 }, { - "type": "click", - "target": { - "view": "main", - "anchor": "point:adelie-biscoe-female" - } + "type": "assert", + "assertions": [ + { "path": "focusedMonth", "equals": "jan" }, + { "path": "tooltip.visible", "equals": true }, + { "path": "tooltip.pinned", "equals": true }, + { "path": "tooltip.role", "equals": "dialog" }, + { "path": "tooltip.summaryRowCount", "equals": 2 }, + { "path": "tooltip.detailsExpanded", "equals": true }, + { "path": "tooltip.detailRowCount", "equals": 6 }, + { "path": "tooltip.closeVisible", "equals": true } + ] + }, + { + "type": "key", + "key": "Escape", + "target": { "view": "main", "anchor": "month:jan" } }, { "type": "assert", "assertions": [ + { "path": "focusedMonth", "equals": null }, { "path": "tooltip.visible", "equals": false }, - { "path": "tooltip.pinnedId", "equals": null }, - { "path": "tooltip.miniBarCount", "equals": 0 } + { "path": "tooltip.pinned", "equals": false } ] } ] } ], "source": { - "title": "Recharts custom tooltip content", - "url": "https://recharts.github.io/en-US/examples/CustomContentOfTooltip/" + "title": "Dan Marek expanding energy tooltip interaction", + "url": "https://x.com/dan__marek/status/2083202252219559958" }, "ai": { - "create": "Create a selectable penguin chart where hover only exposes focus state, clicking pins an external tooltip, and the tooltip mounts a four-bar same-species comparison.", - "maintain": "Keep hover and pinned selection separate, key the five displayed cohort representatives by species, island, and sex, destroy nested chart hosts when hidden, and preserve Escape plus second-click dismissal." + "create": "Create a twelve-month household energy chart with a gray consumption area and line over stacked gold solar-generation bars: solid energy used on site and hatched energy exported. Hover or keyboard focus shows a compact two-row monthly summary with an always-visible solar-coverage footer. Click, Enter, or Space pins the same tooltip and smoothly reveals six detail rows plus a four-bar nested chart through the framework renderer. Keep the detailed subtree mounted and animate its intrinsic height from zero to auto with a one-row grid.", + "maintain": "Preserve twelve stable month IDs, two stacked generation bars per month, the gray consumption area and line, the compact two-row summary, always-visible solar-coverage footer, six detailed rows, four-bar nested chart, native inert/status versus interactive/dialog semantics, reduced-motion behavior, and Escape plus close-button dismissal." } } diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.test.ts b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.test.ts new file mode 100644 index 00000000..1c12777f --- /dev/null +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + consumptionBreakdown, + energyAnnualOverview, + energyMonths, + energyTooltipContent, + monthFromTarget, +} from './model' + +describe('expanding energy tooltip model', () => { + it('keeps monthly totals and breakdown segments internally consistent', () => { + const months = energyMonths() + + expect(months).toHaveLength(12) + for (const month of months) { + const parts = consumptionBreakdown(month) + expect(parts).toHaveLength(4) + expect(parts[0]?.start).toBe(0) + expect(parts.at(-1)?.end).toBe(month.consumption) + expect(parts.reduce((total, part) => total + part.value, 0)).toBe( + month.consumption, + ) + expect(month.usedOnSite + month.exported).toBe(month.generation) + } + }) + + it('keeps the compact totals stable when the tooltip is pinned', () => { + const june = energyMonths().find((month) => month.id === 'jun')! + + expect(energyTooltipContent([{ datum: june }], false)).toEqual({ + title: 'June', + rows: [ + { label: 'Consumption', value: '738 kWh' }, + { label: 'Generation', value: '482 kWh' }, + ], + }) + expect(energyTooltipContent([{ datum: june }], true)).toEqual( + energyTooltipContent([{ datum: june }], false), + ) + expect(Math.round((june.usedOnSite / june.consumption) * 100)).toBe(23) + expect(Math.round((june.usedOnSite / june.generation) * 100)).toBe(35) + }) + + it('matches the annual overview from the source example', () => { + const months = energyMonths() + + expect(months.reduce((total, month) => total + month.consumption, 0)).toBe( + energyAnnualOverview.consumption, + ) + expect(energyAnnualOverview.generation).toBe(3_509) + }) + + it('updates a stable month without changing the domain', () => { + const initial = energyMonths(0) + const updated = energyMonths(1) + + expect(updated.map((month) => month.id)).toEqual( + initial.map((month) => month.id), + ) + expect(updated.at(-1)?.consumption).toBe( + (initial.at(-1)?.consumption ?? 0) + 18, + ) + }) + + it('resolves only main-view month targets', () => { + expect(monthFromTarget({ anchor: 'month:jun' })).toBe('jun') + expect(monthFromTarget({ view: 'main', anchor: 'month:jan' })).toBe('jan') + expect(monthFromTarget({ view: 'nested', anchor: 'month:jun' })).toBeNull() + expect(monthFromTarget({ anchor: 'tooltip:jun' })).toBeNull() + expect(monthFromTarget({ anchor: 'month:smarch' })).toBeNull() + }) +}) diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.ts b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.ts index 4fc79b52..02557dec 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.ts +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/model.ts @@ -1,81 +1,221 @@ -import type { PenguinsRow } from '@charts-poc/demo-data/penguins' +import type { ChartTooltipContent } from '@tanstack/charts' +import type { ConformanceTarget } from '../../types' -export type CompletePenguin = PenguinsRow & { - readonly culmen_length_mm: number - readonly culmen_depth_mm: number - readonly flipper_length_mm: number - readonly body_mass_g: number - readonly sex: string -} +export const energyColors = { + consumption: '#8b8d90', + household: '#1685ff', + heatPump: '#e82285', + hotWater: '#ee4c91', + evCharging: '#5cbd68', + generationMuted: '#f4c675', + generation: '#f2a900', + exported: '#f8d99a', +} as const + +export const energyAnnualOverview = { + generation: 3_509, + consumption: 17_847, +} as const -export const nestedTooltipIds = [ - 'adelie-torgersen-male', - 'adelie-biscoe-female', - 'adelie-dream-female', - 'chinstrap-dream-male', - 'gentoo-biscoe-male', +export const energyMonthIds = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', ] as const -export type NestedTooltipId = (typeof nestedTooltipIds)[number] +export type EnergyMonthId = (typeof energyMonthIds)[number] -export function isNestedTooltipId(value: unknown): value is NestedTooltipId { - return nestedTooltipIds.some((id) => id === value) +export interface EnergyMonth { + readonly id: EnergyMonthId + readonly month: string + readonly monthShort: string + readonly household: number + readonly heatPump: number + readonly hotWater: number + readonly evCharging: number + readonly consumption: number + readonly generation: number + readonly usedOnSite: number + readonly exported: number + readonly householdStart: number + readonly householdEnd: number + readonly heatPumpStart: number + readonly heatPumpEnd: number + readonly hotWaterStart: number + readonly hotWaterEnd: number + readonly evChargingStart: number + readonly evChargingEnd: number } -export function penguinTooltipId(row: PenguinsRow): NestedTooltipId | null { - const key = `${row.species}-${row.island}-${row.sex}`.toLowerCase() - return isNestedTooltipId(key) ? key : null +export interface EnergyBreakdownPart { + readonly id: string + readonly label: string + readonly value: number + readonly start: number + readonly end: number + readonly color: string } -export function penguinTooltipLabel(row: CompletePenguin) { - return `${row.species} ${row.sex.toLowerCase()} on ${row.island}` +const baseMonths = [ + ['jan', 'January', 'Jan', 783, 637, 468, 688, 188, 180, 8], + ['feb', 'February', 'Feb', 672, 531, 400, 563, 219, 217, 2], + ['mar', 'March', 'Mar', 668, 466, 397, 614, 262, 257, 5], + ['apr', 'April', 'Apr', 570, 342, 352, 524, 375, 322, 53], + ['may', 'May', 'May', 376, 191, 232, 352, 427, 219, 208], + ['jun', 'June', 'Jun', 241, 122, 150, 225, 482, 169, 313], + ['jul', 'July', 'Jul', 246, 91, 155, 233, 367, 150, 217], + ['aug', 'August', 'Aug', 241, 96, 155, 233, 354, 138, 216], + ['sep', 'September', 'Sep', 242, 135, 147, 223, 304, 142, 162], + ['oct', 'October', 'Oct', 362, 239, 218, 335, 258, 185, 73], + ['nov', 'November', 'Nov', 577, 449, 345, 524, 174, 171, 3], + ['dec', 'December', 'Dec', 607, 495, 365, 570, 100, 99, 1], +] as const satisfies readonly (readonly [ + EnergyMonthId, + string, + string, + number, + number, + number, + number, + number, + number, + number, +])[] + +export function energyMonths(revision = 0): readonly EnergyMonth[] { + return baseMonths.map( + ([ + id, + month, + monthShort, + household, + heatPump, + hotWater, + baseEvCharging, + generation, + usedOnSite, + exported, + ]) => { + const evCharging = + id === 'dec' && revision % 2 === 1 + ? baseEvCharging + 18 + : baseEvCharging + const householdStart = 0 + const householdEnd = household + const heatPumpStart = householdEnd + const heatPumpEnd = heatPumpStart + heatPump + const hotWaterStart = heatPumpEnd + const hotWaterEnd = hotWaterStart + hotWater + const evChargingStart = hotWaterEnd + const evChargingEnd = evChargingStart + evCharging + return { + id, + month, + monthShort, + household, + heatPump, + hotWater, + evCharging, + consumption: evChargingEnd, + generation, + usedOnSite, + exported, + householdStart, + householdEnd, + heatPumpStart, + heatPumpEnd, + hotWaterStart, + hotWaterEnd, + evChargingStart, + evChargingEnd, + } + }, + ) } -export function nestedTooltipRows( - rows: readonly PenguinsRow[], - revision = 0, -): readonly CompletePenguin[] { - const representatives = nestedTooltipIds.flatMap((id) => { - const row = rows.find( - (row): row is CompletePenguin => - isCompletePenguin(row) && penguinTooltipId(row) === id, - ) - return row ? [row] : [] - }) - return revision % 2 === 1 ? representatives.reverse() : representatives +export function isEnergyMonthId(value: unknown): value is EnergyMonthId { + return energyMonthIds.some((id) => id === value) } -export function penguinCohort( - rows: readonly PenguinsRow[], - datum: CompletePenguin, -): readonly CompletePenguin[] { - const nearest = rows - .filter( - (row): row is CompletePenguin => - isCompletePenguin(row) && row.species === datum.species, - ) - .sort( - (a, b) => - Math.abs(a.flipper_length_mm - datum.flipper_length_mm) - - Math.abs(b.flipper_length_mm - datum.flipper_length_mm), - ) - const distinctLengths = new Set() - return nearest - .filter((row) => { - if (distinctLengths.has(row.flipper_length_mm)) return false - distinctLengths.add(row.flipper_length_mm) - return true - }) - .slice(0, 4) - .sort((a, b) => a.flipper_length_mm - b.flipper_length_mm) +export function monthFromTarget(target: ConformanceTarget) { + if (target.view !== undefined && target.view !== 'main') return null + const [kind, id] = target.anchor.split(':') + return kind === 'month' && isEnergyMonthId(id) ? id : null } -function isCompletePenguin(row: PenguinsRow): row is CompletePenguin { - return ( - row.culmen_length_mm !== null && - row.culmen_depth_mm !== null && - row.flipper_length_mm !== null && - row.body_mass_g !== null && - row.sex !== null - ) +export function consumptionBreakdown( + month: EnergyMonth, +): readonly EnergyBreakdownPart[] { + return [ + { + id: 'household', + label: 'Household', + value: month.household, + start: month.householdStart, + end: month.householdEnd, + color: energyColors.household, + }, + { + id: 'heat-pump', + label: 'Heat pump', + value: month.heatPump, + start: month.heatPumpStart, + end: month.heatPumpEnd, + color: energyColors.heatPump, + }, + { + id: 'hot-water', + label: 'Hot water', + value: month.hotWater, + start: month.hotWaterStart, + end: month.hotWaterEnd, + color: energyColors.hotWater, + }, + { + id: 'ev-charging', + label: 'EV charging', + value: month.evCharging, + start: month.evChargingStart, + end: month.evChargingEnd, + color: energyColors.evCharging, + }, + ] +} + +export function energyTooltipContent( + points: readonly { readonly datum: EnergyMonth }[], + _pinned: boolean, +): ChartTooltipContent { + const month = points[0]?.datum + if (!month) return { rows: [] } + return { + title: month.month, + rows: [ + { + label: 'Consumption', + value: formatEnergy(month.consumption), + }, + { + label: 'Generation', + value: formatEnergy(month.generation), + }, + ], + } +} + +export function formatEnergy(value: number) { + return `${value.toLocaleString('en-US')} kWh` +} + +export function formatPercent(value: number) { + return `${Math.round(value * 100)}%` } diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/recharts.ts b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/recharts.ts index e1163fee..43d02737 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/recharts.ts +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/recharts.ts @@ -1,391 +1,781 @@ -import { createElement, useId, useRef, useState } from 'react' +import { createElement, useEffect, useId, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' import { + Area, Bar, BarChart, CartesianGrid, - Scatter, - ScatterChart, + Cell, + ComposedChart, + Line, + ReferenceLine, XAxis, YAxis, } from 'recharts' -import { penguins } from '@charts-poc/demo-data/penguins' import { - isNestedTooltipId, - nestedTooltipRows, - penguinCohort, - penguinTooltipId, - penguinTooltipLabel, + energyAnnualOverview, + energyColors, + energyMonths, + energyTooltipContent, + formatEnergy, + formatPercent, + isEnergyMonthId, + monthFromTarget, } from './model' -import type { ReactNode } from 'react' -import type { ScatterPointItem, ScatterShapeProps } from 'recharts' -import type { CompletePenguin, NestedTooltipId } from './model' +import { EnergyTooltipBody, energyTooltipStyles } from './tooltip-body' +import type { ChartPoint } from '@tanstack/charts' import type { - ConformanceInput, - ConformanceMount, - ConformanceTarget, -} from '../../types' + FocusEvent as ReactFocusEvent, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + ReactNode, +} from 'react' +import type { ConformanceInput, ConformanceMount } from '../../types' +import type { EnergyMonth, EnergyMonthId } from './model' interface InteractionState { - hoveredId: NestedTooltipId | null - pinnedId: NestedTooltipId | null + focusedMonth: EnergyMonthId | null } -interface NestedTooltipChartProps { +interface EnergyChartProps { input: ConformanceInput onInteractionChange: (state: InteractionState) => void } -function NestedTooltipChart({ - input, - onInteractionChange, -}: NestedTooltipChartProps) { - const tooltipTitleId = `recharts-nested-tooltip-${useId()}` - const pointElements = useRef(new Map()) - const [hoveredId, setHoveredId] = useState(null) - const [pinnedId, setPinnedId] = useState(null) - const rows = nestedTooltipRows(penguins, input.revision) - const pinnedDatum = rows.find((row) => penguinTooltipId(row) === pinnedId) - const narrow = input.width < 520 - const panelHeight = Math.max( - 96, - Math.min(154, Math.round(input.height * 0.42)), +interface EnergyDotProps { + cx?: number + cy?: number + payload?: EnergyMonth +} + +interface EnergyXAxisTickProps { + x?: number | string + y?: number | string + fill?: string + payload?: { value?: unknown } + activeMonthShort: string | null + markerLength: number +} + +function EnergyChart({ input, onInteractionChange }: EnergyChartProps) { + const exportedPatternId = `energy-exported-${useId().replaceAll(':', '')}` + const viewRef = useRef(null) + const chartFocusRef = useRef(null) + const pointElements = useRef(new Map()) + const suppressFocusRef = useRef(false) + const [hoveredId, setHoveredId] = useState(null) + const [focusedId, setFocusedId] = useState(null) + const [pinnedId, setPinnedId] = useState(null) + const rows = energyMonths(input.revision) + const activeId = pinnedId ?? focusedId ?? hoveredId + const activeMonth = rows.find((month) => month.id === activeId) ?? null + const pinned = activeMonth !== null && pinnedId === activeMonth.id + const chartWidth = Math.max(1, input.width - 24) + const chartHeight = Math.max(1, input.height - 48) + const annualConsumption = rows.reduce( + (total, month) => total + month.consumption, + 0, ) - const chartHeight = - narrow && pinnedDatum - ? Math.max(1, input.height - panelHeight - 8) - : input.height - const updateHovered = (nextHoveredId: NestedTooltipId | null) => { - setHoveredId(nextHoveredId) - onInteractionChange({ hoveredId: nextHoveredId, pinnedId }) - } + useEffect(() => { + onInteractionChange({ focusedMonth: activeId }) + }, [activeId, onInteractionChange]) - const togglePinned = (id: NestedTooltipId) => { - setPinnedId((current) => { - const next = current === id ? null : id - onInteractionChange({ hoveredId, pinnedId: next }) - return next - }) + useEffect(() => { + const view = viewRef.current + if (!view) return + const document = view.ownerDocument + const handleClick = (event: MouseEvent) => { + const bounds = view.getBoundingClientRect() + if ( + event.clientX < bounds.left || + event.clientX > bounds.right || + event.clientY < bounds.top || + event.clientY > bounds.bottom + ) { + return + } + const id = monthIdAtPointer( + event.target, + event.clientX, + event.clientY, + pointElements.current, + ) + if (id) setPinnedId((current) => (current === id ? null : id)) + } + document.addEventListener('click', handleClick, true) + return () => document.removeEventListener('click', handleClick, true) + }, []) + + const togglePinned = (id: EnergyMonthId) => { + setPinnedId((current) => (current === id ? null : id)) } - const closePinned = () => { - const invokingId = pinnedId - if (!invokingId) return + const dismiss = () => { + if (!activeId) return + const invokingId = activeId setPinnedId(null) - onInteractionChange({ hoveredId, pinnedId: null }) - const restoreFocus = () => pointElements.current.get(invokingId)?.focus() + setHoveredId(null) + setFocusedId(null) + suppressFocusRef.current = true + const restoreFocus = () => { + chartFocusRef.current?.focus() + suppressFocusRef.current = false + } const view = pointElements.current.get(invokingId)?.ownerDocument.defaultView if (view?.requestAnimationFrame) view.requestAnimationFrame(restoreFocus) else restoreFocus() } - const renderPoint = (props: ScatterShapeProps): ReactNode => { - const datum = rows.find((row) => row === props.payload) - if (!datum || props.cx === undefined || props.cy === undefined) return null - const id = penguinTooltipId(datum) - if (!id) return null - const pinned = id === pinnedId + const renderPoint = ({ cx, cy, payload }: EnergyDotProps): ReactNode => { + if (cx === undefined || cy === undefined || !payload) return null + const id = payload.id + const isPinned = id === pinnedId + const isActive = id === activeId return createElement('circle', { className: 'recharts-dot', - cx: props.cx, - cy: props.cy, - r: pinned ? 7 : 5, - fill: pinned ? '#f97316' : '#2563eb', - stroke: '#ffffff', - strokeWidth: pinned ? 2 : 1, - 'data-point-id': id, + id: `energy-month-${id}`, + cx, + cy, + r: isPinned ? 5 : isActive ? 4.5 : 3, + fill: 'Canvas', + stroke: energyColors.consumption, + strokeWidth: isPinned ? 2 : isActive ? 1.8 : 1.4, + opacity: isActive ? 1 : 0, + 'data-month-id': id, ref: (element: SVGCircleElement | null) => { if (element) pointElements.current.set(id, element) else pointElements.current.delete(id) }, - role: 'button', - tabIndex: 0, - focusable: true, - 'aria-label': `${penguinTooltipLabel(datum)}, ${datum.body_mass_g} grams`, - 'aria-pressed': pinned, - onPointerEnter: () => updateHovered(id), - onPointerLeave: () => updateHovered(null), - onClick: () => togglePinned(id), - onKeyDown: (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - togglePinned(id) - }, + role: 'option', + 'aria-label': `${payload.month}: ${payload.consumption} kilowatt-hours consumed and ${payload.generation} generated`, + 'aria-selected': isPinned, + style: { cursor: 'pointer', transition: 'r 160ms ease' }, }) } return createElement( 'div', { + ref: viewRef, 'data-conformance-view': 'main', role: 'region', - 'aria-label': 'Penguin measurements with a pinned nested-chart tooltip', - onKeyDown: (event: KeyboardEvent) => { - if (event.key !== 'Escape') return + tabIndex: -1, + 'aria-label': 'Monthly household energy with an expanding pinned tooltip', + onPointerMove: (event: ReactPointerEvent) => { + if (pinnedId) return + setHoveredId(monthIdFromEventTarget(event.target)) + }, + onPointerLeave: () => setHoveredId(null), + onKeyDown: (event: ReactKeyboardEvent) => { + if (event.key !== 'Escape' || !pinnedId) return event.stopPropagation() - closePinned() + dismiss() }, style: { position: 'relative', width: `${input.width}px`, height: `${input.height}px`, + paddingTop: '4px', + background: 'Canvas', + color: 'CanvasText', + boxSizing: 'border-box', }, }, [ + createElement('style', { key: 'tooltip-styles' }, energyTooltipStyles), + createElement( + 'header', + { + key: 'header', + className: 'energy-overview-card', + style: { + display: 'flex', + height: '36px', + alignItems: 'center', + padding: '0 24px', + font: '500 12px/1.3 system-ui, sans-serif', + }, + }, + createElement( + 'strong', + { style: { fontSize: '13px', fontWeight: 680 } }, + 'Annual overview', + ), + ), createElement( - ScatterChart, + 'div', { - key: 'chart', - width: input.width, - height: chartHeight, - margin: { top: 18, right: 24, bottom: 20, left: 16 }, - accessibilityLayer: true, + key: 'chart-card', + style: { + position: 'relative', + width: `${chartWidth}px`, + height: `${chartHeight}px`, + margin: '0 12px', + border: '1px solid color-mix(in srgb, CanvasText 8%, transparent)', + borderRadius: '7px', + boxSizing: 'border-box', + }, }, [ - createElement(CartesianGrid, { - key: 'grid', - stroke: '#e2e8f0', - }), - createElement(XAxis, { - key: 'x', - type: 'number', - dataKey: 'flipper_length_mm', - domain: [170, 235], - name: 'Flipper length (mm)', - }), - createElement(YAxis, { - key: 'y', - type: 'number', - dataKey: 'body_mass_g', - domain: [3000, 6000], - name: 'Body mass (g)', - width: 52, - }), - createElement(Scatter, { - key: 'points', - data: rows, - dataKey: 'body_mass_g', - fill: '#2563eb', - shape: renderPoint, - isAnimationActive: false, - }), + createElement( + 'div', + { + key: 'annual-metrics', + 'aria-hidden': true, + style: { + position: 'absolute', + zIndex: 1, + top: '14px', + left: '16px', + display: 'flex', + gap: '26px', + pointerEvents: 'none', + font: '500 11px/1.2 system-ui, sans-serif', + }, + }, + [ + createElement(AnnualMetric, { + key: 'generation', + label: 'Energy generated', + value: formatEnergy(energyAnnualOverview.generation), + }), + createElement(AnnualMetric, { + key: 'consumption', + label: 'Total consumption', + value: formatEnergy(annualConsumption), + }), + ], + ), + createElement( + 'div', + { + key: 'chart-focus', + ref: chartFocusRef, + role: 'listbox', + tabIndex: 0, + 'aria-label': 'Monthly household energy', + 'aria-orientation': 'horizontal', + 'aria-activedescendant': activeId + ? `energy-month-${activeId}` + : undefined, + onFocus: (event: ReactFocusEvent) => { + if ( + event.target !== event.currentTarget || + suppressFocusRef.current + ) { + return + } + setFocusedId( + (current) => current ?? pinnedId ?? rows[0]?.id ?? null, + ) + }, + onBlur: (event: ReactFocusEvent) => { + const NodeConstructor = + event.currentTarget.ownerDocument.defaultView?.Node + if ( + NodeConstructor && + event.relatedTarget instanceof NodeConstructor && + event.currentTarget.contains(event.relatedTarget) + ) { + return + } + if (!pinnedId) setFocusedId(null) + }, + onKeyDown: (event: ReactKeyboardEvent) => { + if (event.key === 'Escape' && pinnedId) { + event.preventDefault() + event.stopPropagation() + dismiss() + return + } + if (event.key === 'Enter' || event.key === ' ') { + const id = focusedId ?? pinnedId ?? rows[0]?.id + if (!id) return + event.preventDefault() + togglePinned(id) + return + } + const direction = + event.key === 'ArrowRight' + ? 1 + : event.key === 'ArrowLeft' + ? -1 + : 0 + if (!direction) return + event.preventDefault() + const currentId = focusedId ?? pinnedId ?? rows[0]?.id + const currentIndex = rows.findIndex( + (month) => month.id === currentId, + ) + const nextIndex = Math.min( + rows.length - 1, + Math.max(0, currentIndex + direction), + ) + setFocusedId(rows[nextIndex]?.id ?? null) + }, + }, + createElement( + ComposedChart, + { + width: chartWidth, + height: chartHeight, + data: rows, + margin: { top: 82, right: 24, bottom: 8, left: 12 }, + barCategoryGap: '16%', + barGap: 0, + accessibilityLayer: true, + role: 'group', + title: 'Annual household energy overview', + }, + [ + createElement( + 'defs', + { key: 'fills' }, + createElement( + 'pattern', + { + id: exportedPatternId, + width: 6, + height: 6, + patternUnits: 'userSpaceOnUse', + }, + [ + createElement('rect', { + key: 'background', + width: 6, + height: 6, + fill: energyColors.exported, + }), + createElement('path', { + key: 'hatch', + d: 'M-1 1L1 -1M0 6L6 0M5 7L7 5', + fill: 'none', + stroke: energyColors.generation, + strokeOpacity: 0.55, + strokeWidth: 1, + }), + ], + ), + ), + createElement(CartesianGrid, { + key: 'grid', + stroke: 'color-mix(in srgb, CanvasText 13%, transparent)', + vertical: false, + }), + createElement(XAxis, { + key: 'x', + dataKey: 'monthShort', + tickLine: false, + axisLine: false, + tickMargin: 8, + tick: (props) => + createElement(EnergyXAxisTick, { + ...props, + activeMonthShort: activeMonth?.monthShort ?? null, + markerLength: Math.max( + 14, + ((chartWidth - 72 - 24) / 12) * 0.84, + ), + }), + }), + createElement(YAxis, { + key: 'y', + domain: [0, 2600], + tickCount: 5, + width: 60, + tickLine: false, + axisLine: false, + tickMargin: 7, + tickFormatter: (value: number) => + `${value.toLocaleString('en-US')} kWh`, + }), + createElement(Area, { + key: 'consumption-area', + type: 'monotone', + dataKey: 'consumption', + fill: energyColors.consumption, + fillOpacity: 0.13, + stroke: 'none', + isAnimationActive: false, + }), + createElement( + Bar, + { + key: 'used-on-site', + dataKey: 'usedOnSite', + stackId: 'generation', + fill: energyColors.generationMuted, + isAnimationActive: false, + }, + rows.map((row) => + createElement(Cell, { + key: row.id, + fill: + row.id === activeId + ? energyColors.generation + : energyColors.generationMuted, + }), + ), + ), + createElement(Bar, { + key: 'exported', + dataKey: 'exported', + stackId: 'generation', + fill: `url(#${exportedPatternId})`, + radius: [3, 3, 0, 0], + isAnimationActive: false, + }), + activeMonth + ? createElement(ReferenceLine, { + key: 'focused-month-guide', + x: activeMonth.monthShort, + stroke: 'CanvasText', + strokeOpacity: 0.45, + strokeWidth: 1, + strokeDasharray: '4 4', + }) + : null, + createElement(Line, { + key: 'consumption-line', + type: 'monotone', + dataKey: 'consumption', + stroke: energyColors.consumption, + strokeWidth: 1.6, + dot: renderPoint, + activeDot: false, + isAnimationActive: false, + }), + ], + ), + ), ], ), - pinnedDatum - ? createElement(PinnedTooltip, { + activeMonth + ? createElement(EnergyTooltip, { key: 'tooltip', - datum: pinnedDatum, + month: activeMonth, + monthIndex: rows.indexOf(activeMonth), + pinned, input, - chartHeight, - panelHeight, - titleId: tooltipTitleId, - onClose: closePinned, + dismiss, }) : null, ], ) } -interface PinnedTooltipProps { - datum: CompletePenguin +interface EnergyTooltipProps { + month: EnergyMonth + monthIndex: number + pinned: boolean input: ConformanceInput - chartHeight: number - panelHeight: number - titleId: string - onClose: () => void + dismiss: () => void } -function PinnedTooltip({ - datum, +function EnergyTooltip({ + month, + monthIndex, + pinned, input, - chartHeight, - panelHeight, - titleId, - onClose, -}: PinnedTooltipProps) { - const narrow = input.width < 520 - const width = narrow ? Math.max(1, input.width - 16) : 224 - const miniWidth = narrow ? Math.max(1, input.width - 32) : 208 - const miniHeight = narrow ? Math.max(48, panelHeight - 60) : 106 - const cohort = penguinCohort(penguins, datum) - const position = tooltipPosition( - datum, - input, - chartHeight, - panelHeight, - width, - ) + dismiss, +}: EnergyTooltipProps) { + const content = energyTooltipContent([energyPoint(month, monthIndex)], pinned) + const coverage = formatPercent(month.usedOnSite / month.consumption) + const accessibleLabel = [ + content.title, + ...content.rows.map((row) => `${row.label}: ${row.value}`), + ...(pinned + ? [ + `Consumption mix: Household ${formatEnergy(month.household)}, Heat pump ${formatEnergy(month.heatPump)}, Hot water ${formatEnergy(month.hotWater)}, EV charging ${formatEnergy(month.evCharging)}`, + `Generation use: Used on site ${formatEnergy(month.usedOnSite)} (${formatPercent(month.usedOnSite / month.generation)}), Exported ${formatEnergy(month.exported)} (${formatPercent(month.exported / month.generation)})`, + ] + : []), + `Solar covered ${coverage} of household consumption`, + ] + .filter(Boolean) + .join('\n') + const position = tooltipPosition(input, pinned, monthIndex, month.consumption) return createElement( 'aside', { - 'data-external-tooltip': 'pinned', - role: 'dialog', - 'aria-modal': false, - 'aria-labelledby': titleId, + className: 'energy-reference-tooltip', + 'data-sticky': String(pinned), 'data-placement': position.placement, - style: { ...tooltipStyle, ...position.style, width }, + role: pinned ? 'dialog' : 'status', + 'aria-modal': pinned ? false : undefined, + 'aria-live': pinned ? undefined : 'polite', + 'aria-label': accessibleLabel, + style: { + ...position.style, + pointerEvents: 'none', + transition: 'top 260ms cubic-bezier(0.22, 1, 0.36, 1)', + }, + }, + createElement( + 'div', + { + className: 'ts-chart-tooltip__body', + inert: pinned ? undefined : true, + }, + createElement(EnergyTooltipBody, { + month, + pinned, + dismiss, + consumptionChart: createElement(ConsumptionMixChart, { month }), + }), + ), + ) +} + +function EnergyXAxisTick({ + x, + y, + fill = '#666', + payload, + activeMonthShort, + markerLength, +}: EnergyXAxisTickProps) { + const tickX = Number(x) + const tickY = Number(y) + const value = typeof payload?.value === 'string' ? payload.value : '' + if (!Number.isFinite(tickX) || !Number.isFinite(tickY)) return null + const active = value === activeMonthShort + return createElement('g', { transform: `translate(${tickX},${tickY})` }, [ + active + ? createElement('line', { + key: 'active-line', + x1: -markerLength / 2, + x2: markerLength / 2, + y1: -6, + y2: -6, + stroke: 'CanvasText', + strokeWidth: 1.5, + }) + : null, + active + ? createElement('line', { + key: 'active-tick', + x1: 0, + x2: 0, + y1: -6, + y2: 1, + stroke: 'CanvasText', + strokeWidth: 1.5, + }) + : null, + createElement( + 'text', + { + key: 'label', + x: 0, + y: 0, + dy: '0.71em', + fill, + fontSize: 11, + textAnchor: 'middle', + }, + value, + ), + ]) +} + +function ConsumptionMixChart({ month }: { month: EnergyMonth }) { + return createElement( + BarChart, + { + width: 264, + height: 10, + data: [month], + layout: 'vertical', + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + accessibilityLayer: false, }, [ - createElement( - 'div', - { - key: 'header', - style: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: '8px', - minHeight: '44px', - }, + createElement(XAxis, { + key: 'x', + type: 'number', + domain: [0, month.consumption], + hide: true, + }), + createElement(YAxis, { + key: 'y', + type: 'category', + dataKey: 'monthShort', + hide: true, + }), + createElement(Bar, { + key: 'household', + dataKey: 'household', + stackId: 'mix', + fill: energyColors.household, + isAnimationActive: false, + }), + createElement(Bar, { + key: 'heat-pump', + dataKey: 'heatPump', + stackId: 'mix', + fill: energyColors.heatPump, + isAnimationActive: false, + }), + createElement(Bar, { + key: 'hot-water', + dataKey: 'hotWater', + stackId: 'mix', + fill: energyColors.hotWater, + isAnimationActive: false, + }), + createElement(Bar, { + key: 'ev-charging', + dataKey: 'evCharging', + stackId: 'mix', + fill: energyColors.evCharging, + isAnimationActive: false, + }), + ], + ) +} + +function AnnualMetric({ label, value }: { label: string; value: string }) { + const [amount, unit] = value.split(' ') + return createElement('div', { style: { display: 'grid', gap: '3px' } }, [ + createElement( + 'span', + { + key: 'label', + style: { + color: 'color-mix(in srgb, CanvasText 55%, transparent)', }, - [ - createElement( - 'strong', - { key: 'title', id: titleId }, - `${penguinTooltipLabel(datum)}: ${datum.body_mass_g.toLocaleString()} g`, - ), - createElement( - 'button', - { - key: 'close', - type: 'button', - 'aria-label': 'Close pinned penguin details', - onPointerDown: (event: PointerEvent) => event.stopPropagation(), - onClick: onClose, - style: { - width: '44px', - minWidth: '44px', - height: '44px', - flex: '0 0 44px', - padding: 0, - border: - '1px solid color-mix(in srgb, CanvasText 24%, transparent)', - borderRadius: '6px', - background: 'Canvas', - color: 'CanvasText', - cursor: 'pointer', - font: '700 20px/1 system-ui, sans-serif', - }, - }, - '×', - ), - ], - ), - createElement( - BarChart, - { - key: 'mini-chart', - width: miniWidth, - height: miniHeight, - data: cohort, - margin: { top: 6, right: 6, bottom: 12, left: 6 }, - accessibilityLayer: true, + }, + label, + ), + createElement( + 'strong', + { + key: 'value', + style: { + fontSize: '19px', + fontWeight: 680, + letterSpacing: '-0.02em', }, - [ - createElement(XAxis, { - key: 'x', - dataKey: 'flipper_length_mm', - tick: { fontSize: 9 }, - tickLine: false, - axisLine: false, - }), - createElement(YAxis, { - key: 'y', - hide: true, - }), - createElement(Bar, { - key: 'bars', - dataKey: 'body_mass_g', - fill: '#8b5cf6', - radius: [2, 2, 0, 0], - isAnimationActive: false, - }), - ], - ), - createElement( - 'div', - { - key: 'history-description', - style: { - position: 'absolute', - width: '1px', - height: '1px', - overflow: 'hidden', - clipPath: 'inset(50%)', + }, + [ + amount, + ' ', + createElement( + 'span', + { + key: 'unit', + style: { + fontSize: '11px', + fontWeight: 620, + letterSpacing: 0, + }, }, - }, - cohort - .map( - (row) => - `${row.flipper_length_mm} millimeter flipper: ${row.body_mass_g} grams`, - ) - .join('. '), - ), - ], - ) + unit, + ), + ], + ), + ]) } -const tooltipStyle = { - position: 'absolute', - zIndex: 2, - boxSizing: 'border-box', - padding: '8px', - border: '1px solid rgb(100 116 139 / 0.35)', - borderRadius: '8px', - background: 'Canvas', - color: 'CanvasText', - boxShadow: '0 8px 28px rgb(15 23 42 / 0.16)', - font: '600 12px/1.3 system-ui, sans-serif', - pointerEvents: 'auto', -} as const +function energyPoint( + month: EnergyMonth, + datumIndex: number, +): ChartPoint { + return { + key: month.id, + markId: 'consumption-points', + group: null, + groupLabel: 'Consumption', + datum: month, + datumIndex, + xValue: month.monthShort, + yValue: month.consumption, + x: 0, + y: 0, + color: energyColors.consumption, + } +} function tooltipPosition( - datum: CompletePenguin, input: ConformanceInput, - chartHeight: number, - panelHeight: number, - width: number, + pinned: boolean, + monthIndex: number, + consumption: number, ) { - if (input.width < 520) { - return { - placement: 'panel', - style: { - left: '8px', - top: `${chartHeight + 4}px`, - height: `${panelHeight}px`, - }, - } - } - const chartLeft = 68 - const chartRight = input.width - 24 - const pointX = - chartLeft + - ((datum.flipper_length_mm - 170) / (235 - 170)) * (chartRight - chartLeft) - const pointY = - 18 + - ((6000 - datum.body_mass_g) / (6000 - 3000)) * Math.max(1, chartHeight - 60) - const gap = 14 const edge = 8 - const estimatedHeight = 168 + const gap = 12 + const width = Math.min(292, Math.max(1, input.width - edge * 2)) + const estimatedHeight = pinned ? 334 : 128 + const chartWidth = Math.max(1, input.width - 24) + const chartHeight = Math.max(1, input.height - 48) + const plotWidth = Math.max(1, chartWidth - 72 - 24) + const plotHeight = Math.max(1, chartHeight - 82 - 38) + const pointX = 12 + 72 + ((monthIndex + 0.5) / 12) * plotWidth + const pointY = 40 + 82 + (1 - consumption / 2600) * plotHeight const placeRight = pointX + gap + width <= input.width - edge + const placeLeft = pointX - gap - width >= edge + const verticalOverlay = !placeRight && !placeLeft + const left = placeRight + ? pointX + gap + : placeLeft + ? pointX - gap - width + : Math.max(edge, Math.min(input.width - width - edge, pointX - width / 2)) + const top = Math.max( + edge, + Math.min( + input.height - estimatedHeight - edge, + pointY - estimatedHeight / 2, + ), + ) return { - placement: placeRight ? 'right' : 'left', + placement: placeRight ? 'right' : placeLeft ? 'left' : 'overlay', style: { - left: `${Math.max(edge, placeRight ? pointX + gap : pointX - gap - width)}px`, - top: `${Math.max( - edge, - Math.min( - input.height - estimatedHeight - edge, - pointY - estimatedHeight / 2, - ), - )}px`, + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, }, } } +function monthIdFromEventTarget(target: EventTarget | null) { + if (!(target instanceof Element)) return null + const id = + target.closest('[data-month-id]')?.dataset.monthId + return isEnergyMonthId(id) ? id : null +} + +function monthIdAtPointer( + target: EventTarget | null, + clientX: number, + clientY: number, + points: ReadonlyMap, +) { + const direct = monthIdFromEventTarget(target) + if (direct) return direct + if ( + !(target instanceof Element) || + target.closest('.energy-reference-tooltip') + ) { + return null + } + let nearest: { id: EnergyMonthId; distance: number } | null = null + for (const [id, point] of points) { + const bounds = point.getBoundingClientRect() + const distance = Math.hypot( + clientX - (bounds.left + bounds.width / 2), + clientY - (bounds.top + bounds.height / 2), + ) + if (distance <= 16 && (!nearest || distance < nearest.distance)) { + nearest = { id, distance } + } + } + return nearest?.id ?? null +} + function center(element: HTMLElement | SVGElement) { const bounds = element.getBoundingClientRect() return { @@ -395,21 +785,12 @@ function center(element: HTMLElement | SVGElement) { } } -function pointFromTarget(target: ConformanceTarget) { - if (target.view !== undefined && target.view !== 'main') return null - const [kind, id] = target.anchor.split(':') - return kind === 'point' && isNestedTooltipId(id) ? id : null -} - export const mount: ConformanceMount = (container, input) => { const surface = container.ownerDocument.createElement('div') container.append(surface) const root = createRoot(surface) let currentInput = input - let interaction: InteractionState = { - hoveredId: null, - pinnedId: null, - } + let interaction: InteractionState = { focusedMonth: null } const onInteractionChange = (next: InteractionState) => { interaction = next @@ -418,7 +799,7 @@ export const mount: ConformanceMount = (container, input) => { const render = () => { flushSync(() => { root.render( - createElement(NestedTooltipChart, { + createElement(EnergyChart, { input: currentInput, onInteractionChange, }), @@ -436,47 +817,57 @@ export const mount: ConformanceMount = (container, input) => { driver: { resolveTarget(target) { if (target.anchor === 'tooltip:close') { - const close = surface.querySelector( - 'button[aria-label="Close pinned penguin details"]', + const close = surface.querySelector( + '[data-energy-tooltip-close]', ) return close ? center(close) : null } - const pointId = pointFromTarget(target) - if (!pointId) return null - const point = [ - ...surface.querySelectorAll('[data-point-id]'), - ].find((element) => element.dataset.pointId === pointId) - return point ? center(point) : null + const monthId = monthFromTarget(target) + if (!monthId) return null + const point = surface.querySelector( + `[data-month-id="${monthId}"]`, + ) + const chartFocus = + surface.querySelector('[role="listbox"]') + if (!point) return null + return { ...center(point), focusElement: chartFocus ?? point } }, readState() { const tooltip = surface.querySelector( - '[data-external-tooltip="pinned"]', + '.energy-reference-tooltip', + ) + const body = tooltip?.querySelector('.energy-tooltip') + const reveal = tooltip?.querySelector( + '.energy-tooltip__reveal', ) return { - hoveredId: interaction.hoveredId, - focusedPoint: - surface.ownerDocument.activeElement instanceof SVGElement - ? (surface.ownerDocument.activeElement.dataset.pointId ?? null) - : null, + focusedMonth: interaction.focusedMonth, tooltip: { visible: Boolean(tooltip), - pinnedId: interaction.pinnedId, - miniBarCount: + pinned: tooltip?.dataset.sticky === 'true', + role: tooltip?.getAttribute('role') ?? null, + inert: + tooltip + ?.querySelector('.ts-chart-tooltip__body') + ?.hasAttribute('inert') ?? false, + month: + tooltip + ?.querySelector('.ts-chart-tooltip__title') + ?.textContent?.trim() ?? null, + summaryRowCount: + tooltip?.querySelectorAll('.ts-chart-tooltip__row').length ?? 0, + detailRowCount: + tooltip?.querySelectorAll('[data-energy-detail-row]').length ?? 0, + detailsExpanded: body?.dataset.expanded === 'true', + detailHeight: Math.round( + reveal?.getBoundingClientRect().height ?? 0, + ), + nestedBarCount: tooltip?.querySelectorAll('.recharts-bar-rectangle').length ?? 0, - chartCount: tooltip?.querySelectorAll('svg').length ?? 0, - selectedOverlayCount: surface.querySelectorAll( - '[data-point-id][aria-pressed="true"]', - ).length, - flipperLabelCount: - tooltip?.querySelectorAll( - '.recharts-xAxis .recharts-cartesian-axis-tick', - ).length ?? 0, - placement: tooltip?.dataset.placement ?? null, closeVisible: Boolean( - tooltip?.querySelector( - 'button[aria-label="Close pinned penguin details"]', - ), + tooltip?.querySelector('[data-energy-tooltip-close]'), ), + text: tooltip?.textContent?.replace(/\s+/g, ' ').trim() ?? '', }, } }, diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tooltip-body.tsx b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tooltip-body.tsx new file mode 100644 index 00000000..fabe9b1e --- /dev/null +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tooltip-body.tsx @@ -0,0 +1,415 @@ +import type { ReactNode } from 'react' +import { + consumptionBreakdown, + energyColors, + formatEnergy, + formatPercent, +} from './model' +import type { EnergyMonth } from './model' + +interface EnergyTooltipBodyProps { + readonly month: EnergyMonth + readonly pinned: boolean + readonly dismiss: () => void + readonly consumptionChart: ReactNode +} + +export function EnergyTooltipBody({ + month, + pinned, + dismiss, + consumptionChart, +}: EnergyTooltipBodyProps) { + const coverageShare = month.usedOnSite / month.consumption + const usedShare = month.usedOnSite / month.generation + const exportedShare = month.exported / month.generation + + return ( +
+
+
{month.month}
+ {pinned ? ( + + ) : ( + + )} +
+ +
+
+ +
+
+
+
+
+
+
+ {consumptionChart} +
+ {consumptionBreakdown(month).map((part) => ( + + ))} +
+ +
+ + + + +
+
+
+
+

+ Solar covered {formatPercent(coverageShare)} of this month's + consumption, with the rest coming from the grid. +

+
+ ) +} + +function Chevron({ expanded }: { readonly expanded: boolean }) { + return ( + + ) +} + +function MetricRow({ + className, + label, + summary = true, + value, +}: { + readonly className?: string + readonly label: string + readonly summary?: boolean + readonly value: string +}) { + return ( +
+ {label} + {value} +
+ ) +} + +function DetailRow({ + color, + label, + value, +}: { + readonly color: string + readonly label: string + readonly value: string +}) { + return ( +
+
+ ) +} + +export const energyTooltipStyles = ` + .energy-overview-card .ts-chart:focus, + .energy-overview-card [role='listbox']:focus { + outline: none; + } + + .ts-chart-tooltip.energy-tooltip-surface, + .energy-reference-tooltip { + box-sizing: border-box; + width: 292px; + max-width: calc(100vw - 24px) !important; + padding: 0 !important; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 0.1) !important; + border-radius: 10px !important; + background: #2b2b2e !important; + color: #f4f4f5 !important; + box-shadow: 0 14px 34px rgb(0 0 0 / 0.3) !important; + font: 500 12px/1.35 system-ui, sans-serif !important; + } + + .energy-reference-tooltip { + position: absolute; + z-index: 2; + } + + .energy-tooltip { + padding: 12px; + } + + .energy-tooltip__summary { + position: relative; + min-width: 0; + padding-right: 28px; + } + + .energy-tooltip .ts-chart-tooltip__title { + display: flex; + align-items: center; + min-height: 18px; + margin: 0 0 6px; + color: #f4f4f5; + font-size: 12px; + font-weight: 650; + } + + .energy-tooltip__metric-row { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto !important; + align-items: center !important; + column-gap: 12px !important; + font-variant-numeric: tabular-nums; + } + + .energy-tooltip__metric-row > :last-child { + color: #fafafa; + font-weight: 620; + text-align: right; + white-space: nowrap; + } + + .energy-tooltip__detail-row { + display: grid; + grid-template-columns: 3px minmax(0, 1fr) auto; + align-items: center; + column-gap: 8px; + min-height: 17px; + color: #d4d4d8; + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .energy-tooltip__detail-row > :last-child { + color: #f4f4f5; + font-weight: 600; + text-align: right; + white-space: nowrap; + } + + .energy-tooltip__swatch { + display: block; + width: 3px; + height: 10px; + border-radius: 999px; + } + + .energy-tooltip__close, + .energy-tooltip__toggle { + position: absolute; + top: -9px; + right: -9px; + display: grid; + width: 36px; + height: 36px; + place-items: center; + color: #a1a1aa; + } + + .energy-tooltip__close { + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: #a1a1aa; + cursor: pointer; + pointer-events: auto; + } + + .energy-tooltip__chevron { + width: 12px; + height: 12px; + overflow: visible; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + } + + .energy-tooltip__close:hover, + .energy-tooltip__close:focus-visible { + background: rgb(255 255 255 / 0.08); + color: #fafafa; + outline: none; + } + + .energy-tooltip__close:focus-visible { + box-shadow: inset 0 0 0 2px #f5b942; + } + + .energy-tooltip__reveal { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 160ms ease; + } + + .energy-tooltip[data-expanded='true'] .energy-tooltip__reveal { + grid-template-rows: 1fr; + opacity: 1; + } + + .energy-tooltip__reveal-inner { + min-height: 0; + overflow: hidden; + } + + .energy-tooltip__details { + display: grid; + gap: 12px; + margin-top: 5px; + transform: translateY(-4px); + transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .energy-tooltip[data-expanded='true'] .energy-tooltip__details { + transform: translateY(0); + } + + .energy-tooltip__details section { + display: grid; + gap: 5px; + } + + .energy-tooltip__compact-generation { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + transition: + grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 120ms ease; + } + + .energy-tooltip[data-expanded='true'] .energy-tooltip__compact-generation { + grid-template-rows: 0fr; + opacity: 0; + } + + .energy-tooltip__compact-generation-inner { + min-height: 0; + overflow: hidden; + } + + .energy-tooltip__generation-heading { + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid rgb(255 255 255 / 0.09); + } + + .energy-tooltip__mini-chart, + .energy-tooltip__generation-bar { + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 3px; + background: rgb(255 255 255 / 0.08); + } + + .energy-tooltip__mini-chart svg { + display: block; + width: 100%; + height: 8px; + } + + .energy-tooltip__generation-bar { + display: flex; + } + + .energy-tooltip__footer { + margin: 10px -12px -12px; + padding: 10px 12px 11px; + border-top: 1px solid rgb(255 255 255 / 0.07); + background: #222225; + color: #a8a8af; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + } + + @media (prefers-reduced-motion: reduce) { + .energy-tooltip__compact-generation, + .energy-tooltip__reveal, + .energy-tooltip__details { + transition: none; + } + } +` diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/view.tsx b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/view.tsx index 3ff0f4d1..9d839d04 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/view.tsx +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/view.tsx @@ -1,417 +1,462 @@ +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' import { - forwardRef, - useId, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react' -import { barY, defineChart, dot } from '@tanstack/charts' -import { Chart } from '@tanstack/react-charts' -import { penguins } from '@charts-poc/demo-data/penguins' + areaY, + barX, + barY, + d3Curve, + defineChart, + dot, + lineY, + ruleX, + tickY, + whenFocused, +} from '@tanstack/charts' +import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' +import { Chart as NestedChart } from '@tanstack/react-charts' +import { Chart as TooltipChart } from '@tanstack/react-charts/tooltip' +import { tooltip } from '@tanstack/charts/tooltip' import { scaleBand, scaleLinear } from 'd3-scale' +import { curveMonotoneX } from 'd3-shape' import { reactMount } from '../../shared/react-mount' import { - isNestedTooltipId, - nestedTooltipRows, - penguinCohort, - penguinTooltipId, - penguinTooltipLabel, + consumptionBreakdown, + energyAnnualOverview, + energyColors, + energyMonths, + energyTooltipContent, + formatEnergy, + monthFromTarget, } from './model' +import { EnergyTooltipBody, energyTooltipStyles } from './tooltip-body' import type { ChartScene } from '@tanstack/charts' -import type { ConformanceTarget, ConformanceTestDriver } from '../../types' +import type { ConformanceTestDriver } from '../../types' import type { ReactConformanceProps } from '../../shared/react-mount' -import type { CompletePenguin, NestedTooltipId } from './model' +import type { EnergyMonth, EnergyMonthId } from './model' -const NestedTooltipExample = forwardRef< +const EnergyTooltipExample = forwardRef< ConformanceTestDriver, ReactConformanceProps ->(function NestedTooltipExample({ input, idPrefix }, ref) { - const generatedTitleId = `tanstack-nested-tooltip-${useId().replaceAll(':', '')}` - const titleId = idPrefix ? `${idPrefix}-tooltip-title` : generatedTitleId +>(function EnergyTooltipExample({ input, idPrefix }, ref) { const viewRef = useRef(null) - const chartSurfaceRef = useRef(null) - const tooltipRef = useRef(null) - const miniSurfaceRef = useRef(null) - const hoveredIdRef = useRef(null) - const restorePointRef = useRef(null) - const renderedMainRef = useRef<{ - scene: ChartScene + const focusedIdRef = useRef(null) + const renderedRef = useRef<{ + scene: ChartScene svg: SVGSVGElement } | null>(null) - const [pinnedId, setPinnedId] = useState(null) - const [placement, setPlacement] = useState<'left' | 'right' | 'panel' | null>( - null, + const rows = useMemo(() => energyMonths(input.revision), [input.revision]) + const chartWidth = Math.max(1, input.width - 24) + const chartHeight = Math.max(1, input.height - 48) + const annualConsumption = rows.reduce( + (total, month) => total + month.consumption, + 0, ) - const rows = useMemo( - () => nestedTooltipRows(penguins, input.revision), - [input.revision], + const mainDefinition = useMemo( + () => energyDefinition(rows, chartWidth), + [chartWidth, rows], ) - const pinnedDatum = rows.find((row) => penguinTooltipId(row) === pinnedId) - const cohort = useMemo( - () => (pinnedDatum ? penguinCohort(penguins, pinnedDatum) : []), - [pinnedDatum], - ) - const narrowLayout = input.width < 520 - const panelHeight = Math.max( - 96, - Math.min(154, Math.round(input.height * 0.42)), - ) - const mainHeight = - narrowLayout && pinnedDatum - ? Math.max(1, input.height - panelHeight - 8) - : input.height - const miniDimensions = { - width: narrowLayout ? Math.max(1, input.width - 32) : 208, - height: narrowLayout ? Math.max(48, panelHeight - 60) : 106, - } - const mainDefinition = useMemo(() => { - const selectedRows = rows.filter( - (row) => penguinTooltipId(row) === pinnedId, - ) - return defineChart( - defineChart({ - marks: [ - dot(rows, { - id: 'penguins', - x: 'flipper_length_mm', - y: 'body_mass_g', - r: 5, - fill: '#2563eb', - stroke: '#ffffff', - strokeWidth: 1, - }), - ...(selectedRows.length - ? [ - dot(selectedRows, { - id: 'pinned-penguin', - x: 'flipper_length_mm', - y: 'body_mass_g', - r: 9, - fill: '#f97316', - stroke: '#ffffff', - strokeWidth: 3, - }), - ] - : []), - ], - x: { - scale: scaleLinear().domain([170, 235]), - axis: { label: 'Flipper length (mm)' }, - }, - y: { - scale: scaleLinear().domain([3000, 6000]), - grid: true, - axis: { ticks: { count: 5 }, label: 'Body mass (g)' }, - }, - margin: { top: 18, right: 24, bottom: 42, left: 68 }, - }), - { animate: false, keyboard: true }, - ) - }, [pinnedId, rows]) - const miniDefinition = useMemo( - () => - defineChart( - defineChart({ - marks: [ - barY(cohort, { - x: (row) => String(row.flipper_length_mm), - y: 'body_mass_g', - fill: '#8b5cf6', - inset: 1, - }), - ], - x: { - scale: () => - scaleBand().paddingInner(0.18).paddingOuter(0.08), - }, - y: { scale: scaleLinear, axis: false }, - margin: { top: 6, right: 6, bottom: 24, left: 6 }, - }), - { animate: false, keyboard: false }, - ), - [cohort], - ) - - const closePinned = () => { - if (!pinnedId) return - restorePointRef.current = pinnedId - setPinnedId(null) - } - - useLayoutEffect(() => { - const restorePoint = restorePointRef.current - if (pinnedId !== null || !restorePoint) return - const svg = renderedMainRef.current?.svg - if (!svg) return - svg.dataset.restoredPoint = restorePoint - svg.focus() - restorePointRef.current = null - }, [pinnedId]) - - useLayoutEffect(() => { - if (!pinnedDatum) { - setPlacement(null) - return - } - if (narrowLayout) { - setPlacement('panel') - return - } - const rendered = renderedMainRef.current - const tooltip = tooltipRef.current - if (!rendered || !tooltip) return - const point = rendered.scene.points.find( - (candidate) => - candidate.markId === 'penguins' && - penguinTooltipId(candidate.datum) === pinnedId, - ) - if (!point) return - const width = tooltip.offsetWidth || 224 - const edge = 8 - const gap = 14 - setPlacement(point.x + gap + width <= input.width - edge ? 'right' : 'left') - }, [input.height, input.width, narrowLayout, pinnedDatum, pinnedId]) useImperativeHandle( ref, () => ({ resolveTarget(target) { - if (target.anchor === 'tooltip:close' && pinnedDatum) { - const button = tooltipRef.current?.querySelector( - '[data-tooltip-close]', - ) - return button ? center(button) : null + if (target.anchor === 'tooltip:close') { + const close = + viewRef.current?.ownerDocument.querySelector( + '[data-energy-tooltip-close]', + ) + return close ? center(close) : null } - const pointId = pointFromTarget(target) - if (!pointId) return null - return pointCoordinate(renderedMainRef.current, pointId) + const monthId = monthFromTarget(target) + if (!monthId) return null + return pointCoordinate(renderedRef.current, monthId) }, readState() { - const rendered = renderedMainRef.current - const miniSurface = miniSurfaceRef.current - const chartSurface = chartSurfaceRef.current + const document = viewRef.current?.ownerDocument + const surface = document?.querySelector( + '.ts-chart-tooltip.energy-tooltip-surface', + ) + const tooltip = surface && !surface.hidden ? surface : null + const body = tooltip?.querySelector('.energy-tooltip') + const reveal = tooltip?.querySelector( + '.energy-tooltip__reveal', + ) return { - hoveredId: hoveredIdRef.current, - focusedPoint: - rendered && - rendered.svg.ownerDocument.activeElement === rendered.svg - ? (rendered.svg.dataset.restoredPoint ?? null) - : null, + focusedMonth: focusedIdRef.current, tooltip: { - visible: Boolean(pinnedDatum), - pinnedId, - miniBarCount: pinnedDatum - ? (miniSurface?.querySelectorAll('.ts-chart__bar rect').length ?? - 0) - : 0, - chartCount: pinnedDatum - ? (miniSurface?.querySelectorAll('svg.ts-chart').length ?? 0) - : 0, - selectedOverlayCount: - chartSurface?.querySelectorAll( - '.ts-chart__dot[data-ts-key="pinned-penguin"] circle', - ).length ?? 0, - flipperLabelCount: pinnedDatum - ? (miniSurface?.querySelectorAll('[data-ts-key^="x-tick-label:"]') - .length ?? 0) - : 0, - placement, - closeVisible: Boolean(pinnedDatum), + visible: Boolean(tooltip), + pinned: tooltip?.dataset.sticky === 'true', + role: tooltip?.getAttribute('role') ?? null, + inert: + tooltip + ?.querySelector('.ts-chart-tooltip__body') + ?.hasAttribute('inert') ?? false, + month: + tooltip + ?.querySelector('.ts-chart-tooltip__title') + ?.textContent?.trim() ?? null, + summaryRowCount: + tooltip?.querySelectorAll('.ts-chart-tooltip__row').length ?? 0, + detailRowCount: + tooltip?.querySelectorAll('[data-energy-detail-row]').length ?? 0, + detailsExpanded: body?.dataset.expanded === 'true', + detailHeight: Math.round( + reveal?.getBoundingClientRect().height ?? 0, + ), + nestedBarCount: + tooltip?.querySelectorAll('.ts-chart__bar rect').length ?? 0, + closeVisible: Boolean( + tooltip?.querySelector('[data-energy-tooltip-close]'), + ), + text: tooltip?.textContent?.replace(/\s+/g, ' ').trim() ?? '', }, } }, }), - [pinnedDatum, pinnedId, placement], + [], ) if (input.preview) { return ( - ) } - const tooltipPosition = tooltipStyle( - input.width, - input.height, - mainHeight, - panelHeight, - placement, - pinnedId, - renderedMainRef.current, - tooltipRef.current, - ) - return (
{ - if (event.key !== 'Escape') return - event.stopPropagation() - closePinned() + aria-label="Monthly household energy with an expanding pinned tooltip" + style={{ + position: 'relative', + width: input.width, + height: input.height, + paddingTop: 4, + background: 'Canvas', + color: 'CanvasText', + boxSizing: 'border-box', }} - style={{ position: 'relative', width: input.width, height: input.height }} > -
- {energyTooltipStyles} +
+ + Annual overview + +
+
+ + { - hoveredIdRef.current = point ? penguinTooltipId(point.datum) : null - }} - onSelect={(point) => { - const selectedId = point ? penguinTooltipId(point.datum) : null - const svg = renderedMainRef.current?.svg - if (svg) delete svg.dataset.restoredPoint - setPinnedId((current) => - current === selectedId ? null : selectedId, - ) + initialWidth={chartWidth} + height={chartHeight} + renderSvg={renderChartSvgWithResources} + ariaLabel="Annual household energy overview" + ariaDescription="A gray area tracks monthly electricity consumption. Stacked gold bars show solar energy used on site and exported. Hover or focus a month for totals, then click or press Enter to expand the breakdown." + onFocusGroupChange={(points) => { + focusedIdRef.current = points[0]?.datum.id ?? null }} onRender={({ scene, svg }) => { - renderedMainRef.current = { scene, svg } + renderedRef.current = { scene, svg } + }} + renderTooltipBody={({ points, defaultBody, pinned, dismiss }) => { + const month = points[0]?.datum + if (!month) return defaultBody + return ( + + } + /> + ) }} />
- {pinnedDatum ? ( - - ) : null}
) }) -export const catalogComponent = NestedTooltipExample -export const mount = reactMount(NestedTooltipExample) +export const catalogComponent = EnergyTooltipExample +export const mount = reactMount(EnergyTooltipExample) -const visuallyHiddenStyle = { - position: 'absolute', - width: 1, - height: 1, - overflow: 'hidden', - clipPath: 'inset(50%)', -} as const +function energyDefinition(rows: readonly EnergyMonth[], chartWidth: number) { + const months = rows.map((row) => row.monthShort) + const tooltipPlacement = + chartWidth >= 500 + ? ('right' as const) + : (['right', 'left', 'top', 'bottom'] as const) + return defineChart( + defineChart({ + marks: [ + areaY(rows, { + id: 'consumption-area', + x: 'monthShort', + y: 'consumption', + fill: energyColors.consumption, + fillOpacity: 0.13, + curve: d3Curve(curveMonotoneX), + }), + barY(rows, { + id: 'used-on-site', + x: 'monthShort', + y: 'usedOnSite', + fill: energyColors.generationMuted, + inset: 0.5, + states: [ + { + when: { focus: 'group' }, + style: { fill: energyColors.generation }, + transition: { + type: 'tween', + duration: 120, + easing: 'ease-out', + }, + }, + ], + }), + barY(rows, { + id: 'exported', + x: 'monthShort', + y1: 'usedOnSite', + y2: 'generation', + fill: 'url(#energy-exported-hatch)', + inset: 0.5, + radius: 3, + }), + lineY(rows, { + id: 'consumption-line', + x: 'monthShort', + y: 'consumption', + stroke: energyColors.consumption, + strokeWidth: 1.6, + curve: d3Curve(curveMonotoneX), + }), + whenFocused( + ruleX(rows, { + id: 'focused-month-guide', + x: 'monthShort', + stroke: 'CanvasText', + strokeOpacity: 0.45, + strokeWidth: 1, + strokeDasharray: '4 4', + }), + { match: 'x' }, + ), + whenFocused( + tickY(rows, { + id: 'focused-month-axis-marker', + x: 'monthShort', + y: () => 0, + stroke: 'CanvasText', + strokeWidth: 1.5, + }), + { match: 'x' }, + ), + dot(rows, { + id: 'consumption-points', + x: 'monthShort', + y: 'consumption', + fill: 'Canvas', + fillOpacity: 0, + stroke: energyColors.consumption, + strokeOpacity: 0, + strokeWidth: 1.4, + r: 4, + states: [ + { + when: { focus: 'group' }, + style: { + r: 4, + fillOpacity: 1, + strokeOpacity: 1, + strokeWidth: 1.8, + }, + transition: { + type: 'tween', + duration: 150, + easing: 'ease-out', + }, + }, + { + when: { focus: 'primary', pinned: true }, + style: { + r: 4.5, + fillOpacity: 1, + strokeOpacity: 1, + strokeWidth: 2, + }, + transition: { + type: 'tween', + duration: 180, + easing: 'ease-out', + }, + }, + ], + }), + ], + x: { + scale: scaleBand() + .domain(months) + .paddingInner(0.16) + .paddingOuter(0.06), + axis: { + line: false, + ticks: { size: 0, padding: 8 }, + }, + }, + y: { + scale: scaleLinear().domain([0, 2600]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 650, 1300, 1950, 2600], + size: 0, + padding: 7, + format: (value) => `${value.toLocaleString('en-US')} kWh`, + }, + }, + }, + margin: { top: 82, right: 24, bottom: 38, left: 72 }, + gradients: [ + { + id: 'energy-exported-hatch', + x1: 0, + y1: 0, + x2: 1, + y2: 1, + stops: exportedHatchStops, + }, + ], + }), + { + animate: false, + keyboard: true, + focus: 'group-x', + focusRing: false, + tooltip: { + use: tooltip, + className: 'energy-tooltip-surface', + anchor: 'point', + placement: tooltipPlacement, + offset: 12, + content: (points, { pinned }) => energyTooltipContent(points, pinned), + }, + }, + ) +} + +function ConsumptionMixChart({ + month, + idPrefix, +}: { + readonly month: EnergyMonth + readonly idPrefix?: string +}) { + const definition = useMemo(() => { + const parts = consumptionBreakdown(month) + return defineChart( + defineChart({ + marks: [ + barX(parts, { + id: 'consumption-breakdown', + x1: 'start', + x2: 'end', + y: () => 'mix', + fill: (part) => part.color, + inset: 0, + }), + ], + x: { + scale: scaleLinear().domain([0, month.consumption]), + axis: false, + }, + y: { + scale: scaleBand().domain(['mix']), + axis: false, + }, + margin: 0, + }), + { animate: false, keyboard: false, tooltip: false }, + ) + }, [month]) -function pointFromTarget(target: ConformanceTarget) { - if (target.view !== undefined && target.view !== 'main') return null - const [kind, id] = target.anchor.split(':') - return kind === 'point' && isNestedTooltipId(id) ? id : null + return ( + + ) } function pointCoordinate( rendered: { - scene: ChartScene + scene: ChartScene svg: SVGSVGElement } | null, - id: NestedTooltipId, + id: EnergyMonthId, ) { if (!rendered) return null const point = rendered.scene.points.find( (candidate) => - candidate.markId === 'penguins' && - penguinTooltipId(candidate.datum) === id, + candidate.markId === 'consumption-points' && candidate.datum.id === id, ) if (!point) return null const bounds = rendered.svg.getBoundingClientRect() @@ -422,50 +467,39 @@ function pointCoordinate( } } -function tooltipStyle( - width: number, - height: number, - mainHeight: number, - panelHeight: number, - placement: 'left' | 'right' | 'panel' | null, - pinnedId: NestedTooltipId | null, - rendered: { - scene: ChartScene - svg: SVGSVGElement - } | null, - tooltip: HTMLElement | null, -) { - if (placement === 'panel') { - return { - left: 8, - top: mainHeight + 4, - width: Math.max(1, width - 16), - height: panelHeight, - } - } - const point = rendered?.scene.points.find( - (candidate) => - candidate.markId === 'penguins' && - penguinTooltipId(candidate.datum) === pinnedId, +function AnnualMetric({ label, value }: { label: string; value: string }) { + const [amount, unit] = value.split(' ') + return ( +
+ + {label} + + + {amount}{' '} + + {unit} + + +
) - if (!point) return { width: 224, left: 8, top: 8 } - const edge = 8 - const gap = 14 - const tooltipWidth = tooltip?.offsetWidth || 224 - const tooltipHeight = tooltip?.offsetHeight || 150 - const left = - placement === 'right' ? point.x + gap : point.x - gap - tooltipWidth - return { - left: Math.max(edge, left), - top: Math.max( - edge, - Math.min(height - tooltipHeight - edge, point.y - tooltipHeight / 2), - ), - width: 224, - height: 'auto', - } } +const exportedHatchStops = Array.from({ length: 14 }, (_, index) => { + const start = index / 14 + const lineStart = (index + 0.82) / 14 + const end = (index + 1) / 14 + return [ + { offset: start, color: energyColors.exported }, + { offset: lineStart, color: energyColors.exported }, + { offset: lineStart, color: '#fff7e8' }, + { offset: end, color: '#fff7e8' }, + ] +}).flat() + function center(element: HTMLElement | SVGElement) { const bounds = element.getBoundingClientRect() return { diff --git a/docs/comparison.md b/docs/comparison.md index 40c6a265..05b513f9 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `11ba458` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `dcce66c` | | [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 `11ba458`. +at page render time. The measured TanStack workspace revision is `dcce66c`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 28.71–34.14 KiB | — | Controlled suite | +| TanStack Charts | 28.84–34.26 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/concepts/data-and-channels.md b/docs/concepts/data-and-channels.md index d50db0af..11ca61e3 100644 --- a/docs/concepts/data-and-channels.md +++ b/docs/concepts/data-and-channels.md @@ -42,10 +42,12 @@ dot(rows, { Every accessor receives: ```ts -;(datum, index, data) => value +;(datum, { index, data }) => value ``` -`datum` has the exact source type, `index` is the zero-based position, and `data` is the readonly materialized source array. Accessors are evaluated when the mark initializes; keep expensive cross-row transforms in application code. +`datum` has the exact source type. The context contains the zero-based `index` +and readonly materialized `data` array. Accessors are evaluated when the mark +initializes; keep expensive cross-row transforms in application code. ## Positional channels diff --git a/docs/concepts/grammar-of-graphics.md b/docs/concepts/grammar-of-graphics.md index 708d7b2d..0ab9b776 100644 --- a/docs/concepts/grammar-of-graphics.md +++ b/docs/concepts/grammar-of-graphics.md @@ -124,7 +124,9 @@ dot(rows, { }) ``` -Accessors receive `(datum, index, data)` and remain fully typed. Field channels are filtered by the value type the mark accepts, so TypeScript rejects a date field where a numeric bar length is required. +Accessors receive `(datum, { index, data })` and remain fully typed. Field +channels are filtered by the value type the mark accepts, so TypeScript rejects +a date field where a numeric bar length is required. Channels describe mappings. Constant appearance options such as `stroke: '#2563eb'` or `fillOpacity: 0.2` describe a fixed style. The distinction keeps semantic encodings visible in source. diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index ce629d53..c580a5ca 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -77,33 +77,49 @@ marks: [ [Open the grouped focus example](https://tanstack.com/charts/catalog/35-grouped-tooltip/) to inspect its live chart and complete source. -## Pin rich nested detail +## Pin and expand rich detail -A rich tooltip can compose the native rows with framework UI, including a -second chart. Hover remains transient; click or keyboard activation pins the -surface before it accepts pointer input. +This energy tooltip stays compact on hover or keyboard focus. Click, Enter, or +Space pins the same surface, adds solar coverage to its native rows, and +smoothly reveals the detailed consumption and generation breakdown. -The embedded framework-neutral case uses a fully application-owned surface. -Use the adapter's tooltip-body composition surface and include `defaultBody` -to retain native rows and swatches. A grouped parent can pass `points` -directly into a pie definition, placing the series comparison beside the rows -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. - -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`. +The definition's `content` callback receives `pinned`, so it can keep the +transient summary short and add structured rows only after activation. The +React `renderTooltipBody` callback receives that updated `defaultBody` and the +same pinned state: + +```tsx + ( + + )} +/> +``` + +The detail wrapper stays mounted and transitions from +`grid-template-rows: 0fr` to `1fr`; its direct child uses `min-height: 0` and +`overflow: hidden`. This animates intrinsic height without measuring content. +The transient body remains inert, controls render only while pinned, and the +nested consumption chart has its own accessible label and lifecycle. + +Add the `portal` extension to escape clipped ancestors and use viewport +collision handling. Wire the close button to `dismiss`; the shared host also +owns Escape, focus return, and non-modal dialog semantics. ## Scroll a wide schedule diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index af65a8fd..dbc726bb 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -238,13 +238,14 @@ clipping. ## Custom focus and spatial indexes A `ChartFocusStrategy` owns pointer resolution, grouping, and keyboard -navigation. Its generic types must remain identical to the chart points it -receives. +navigation. Pointer coordinates and the point being grouped arrive through +the second context bag. Its generic types must remain identical to the chart +points it receives. A `ChartSpatialIndexFactory` builds optional nearest-point acceleration from -scene points and receives the complete resolved scene as its second argument. -Return original typed points from the index. Do not erase them to `unknown` and -cast them back in callbacks. +scene points and receives the complete resolved scene through +`context.scene`. Return original typed points from the index. Do not erase +them to `unknown` and cast them back in callbacks. ## Extension checklist diff --git a/docs/guides/migrating.md b/docs/guides/migrating.md index f0e8c347..c7f94365 100644 --- a/docs/guides/migrating.md +++ b/docs/guides/migrating.md @@ -63,6 +63,20 @@ For the current breaking API: datum-, or guide-specific policy on the definition; - add `type: 'tween'` to focus-state transitions that previously supplied only `duration` and `easing`. +- replace channel `(datum, index, data)` accessors with + `(datum, { index, data })`; +- replace facet `chart(data, key)` builders with `chart(data, { key })`; +- replace focus `resolve(points, x, y, maxDistance)` and + `group(points, point)` implementations with `resolve(points, context)` and + `group(points, { point })`; +- replace spatial-index `(points, scene)` factories with + `(points, { scene })`; +- replace legend `height(itemCount, width, colors)` implementations with + `height(itemCount, context)`. The previous `width` value is now + `context.chart.width`; and +- use the second `ChartTooltipContentContext` argument in `format` and + `formatGroup` when formatter output depends on pinned state or axis + formatting. See [Marks and Layering](../concepts/marks-and-layering.md) and the [Example Gallery](../examples/index.md). diff --git a/docs/guides/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index 87cfc900..3f3cb6e9 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -106,8 +106,10 @@ const definition = defineChart({ { id: 'change', label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), + text: (point, { pinned }) => + pinned && point.datum.change != null + ? percent(point.datum.change) + : null, }, 'x', ], @@ -116,6 +118,11 @@ const definition = defineChart({ ``` Array order is row order. A nullish field or `text` result omits the row. +Item `text`, `content`, `format`, and `formatGroup` callbacks receive `pinned`, +which is `false` during transient inspection and `true` after click, Enter, or +Space. +Use it to keep the transient tooltip compact and reveal detailed rows when the +same tooltip is pinned. Grouped focus keeps its shared-axis heading and series rows. By default, rows follow the marks top-to-bottom for an x-group and left-to-right for a y-group. Override that with `sort: 'color-domain'`, `sort: 'focus'`, or a typed @@ -129,8 +136,9 @@ Customize plaintext content with typed formatters: const formattedDefinition = defineChart(definition, { tooltip: { use: tooltip, - format(point) { - return `${point.datum.label}: ${point.datum.value.toLocaleString()}` + format(point, { pinned }) { + const suffix = pinned ? ' · pinned' : '' + return `${point.datum.label}: ${point.datum.value.toLocaleString()}${suffix}` }, }, }) @@ -142,13 +150,13 @@ For grouped focus: const groupedDefinition = defineChart(definition, { tooltip: { use: tooltip, - formatGroup(points) { + formatGroup(points, { pinned }) { const date = points[0]?.xValue const heading = date instanceof Date ? date.toLocaleDateString() : String(date ?? '') return [ - heading, + pinned ? `${heading} · pinned` : heading, ...points.map( (point) => `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, @@ -160,8 +168,9 @@ const groupedDefinition = defineChart(definition, { ``` Formatting precedence is `content`, `formatGroup`, `format`, then the automatic -content. `content` returns safe title and row data. `format` and `formatGroup` -return plain text; returning HTML does not create DOM. +content. All three callbacks receive the same `ChartTooltipContentContext`. +`content` returns safe title and row data. `format` and `formatGroup` return +plain text; returning HTML does not create DOM. Add `className` to style the native HTML surface. Clicking pins the current tooltip for text selection. A later click or Escape unpins it. Set diff --git a/docs/reference/custom-extensions.md b/docs/reference/custom-extensions.md index b7828ae2..393c5388 100644 --- a/docs/reference/custom-extensions.md +++ b/docs/reference/custom-extensions.md @@ -90,9 +90,15 @@ interface MarkScene< > { nodes: readonly SceneNode[] points?: readonly ChartPoint[] + focusPoints?: readonly ChartPoint[] } ``` +Use `focusPoints` only for presentation geometry wrapped in `whenFocused` +whose candidates must not become native pointer, keyboard, tooltip, or callback +targets. Candidate keys must match their scene-node keys. The scene compiler +uses them only inside the focused presentation layer. + ### Mark requirements - Give the mark a stable ID. Derive a fallback from `markIndex` only when layer @@ -100,6 +106,7 @@ interface MarkScene< - Materialize every value needed to establish scale domains before rendering. - Map through `context.scales`; do not recalculate responsive ranges. - Give each scene node and point a deterministic key. +- Keep presentation-only `focusPoints` keyed to the nodes they reveal. - Emit finite geometry only. - Preserve the original datum and index in every interaction point. - Use one honest focus coordinate and semantic x/y pair per point. @@ -196,9 +203,9 @@ for the contract. `ChartSpatialIndexFactory` replaces the default linear pointer lookup without changing scene compilation. Build a point-only index from its first argument, -or use its second `scene` argument to index resolved primitive bounds. Return -the nearest original point within the requested distance. The host recreates -the index when the scene or factory changes. +or use `context.scene` from its second argument to index resolved primitive +bounds. Return the nearest original point within the requested distance. The +host recreates the index when the scene or factory changes. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The appropriate granular spatial primitive can be brought through the boundary @@ -207,8 +214,10 @@ described in [Scales](../concepts/scales-and-d3.md). ## Custom focus and gestures `ChartFocusStrategy` owns pointer resolution, focus grouping, and keyboard task -order. Rich gestures can instead disable chart-owned focus and maintain -selection or viewport state in the application. +order. Its `resolve(points, context)` and `group(points, context)` methods keep +coordinates and the active point in named context bags. Rich gestures can +instead disable chart-owned focus and maintain selection or viewport state in +the application. See [Focus and interaction](./focus-and-interaction.md). diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 25d4268f..a65d6bb9 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -136,14 +136,12 @@ interface ChartFocusStrategy< > { resolve( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ): readonly ChartPoint[] group( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ): readonly ChartPoint[] navigation( @@ -152,9 +150,10 @@ interface ChartFocusStrategy< } ``` -`resolve` receives scene-pixel pointer coordinates and returns primary point -first. `group` is called when an existing point is restored or reached through -keyboard navigation. `navigation` returns the ordered keyboard task set. +`ChartFocusResolveContext` contains scene-pixel `x`, `y`, and `maxDistance`. +`resolve` returns the primary point first. `ChartFocusGroupContext` contains +the point restored or reached through keyboard navigation. `navigation` +returns the ordered keyboard task set. `ChartFocusMode` accepts a `ChartFocusPreset` string or a `ChartFocusStrategy`. @@ -184,9 +183,13 @@ interface ChartTooltipOptions< points: readonly ChartPoint[], context: ChartTooltipContentContext, ) => ChartTooltipContent - format?: (point: ChartPoint) => string + format?: ( + point: ChartPoint, + context: ChartTooltipContentContext, + ) => string formatGroup?: ( points: readonly ChartPoint[], + context: ChartTooltipContentContext, ) => string sticky?: boolean } @@ -210,6 +213,11 @@ Formatting precedence is `content`, `formatGroup`, `format`, then the default. The text formatters do not parse HTML, and newlines are preserved. `className` is appended to `ts-chart-tooltip`. +`ChartTooltipContentContext.pinned` is `false` during transient inspection and +`true` after activation. `content`, `format`, `formatGroup`, and item `text` +receive the same context, so either structured or plaintext content can reveal +additional detail when pinned. + ### Ordered point items `items` is an ordered single-point row list. Use `x`, `y`, and `group` @@ -335,14 +343,14 @@ type ChartSpatialIndexFactory< TYValue extends ChartValue = ChartValue, > = ( points: readonly ChartPoint[], - scene: ChartScene, + context: ChartSpatialIndexFactoryContext, ) => ChartSpatialIndex ``` The host rebuilds the index when the scene or definition changes. The index -owns its search algorithm and must apply `maxDistance`. Existing point-only -factories can ignore the second argument; geometry-aware indexes can traverse -the resolved scene and use primitive bounds as their acceleration layer. +owns its search algorithm and must apply `maxDistance`. Point-only factories +can ignore the second argument; geometry-aware indexes can traverse +`context.scene` and use primitive bounds as their acceleration layer. Use the granular spatial primitive appropriate to the data; the boundary is described in [Scales](../concepts/scales-and-d3.md). diff --git a/docs/reference/marks/geo.md b/docs/reference/marks/geo.md index 06f97623..fa6738f1 100644 --- a/docs/reference/marks/geo.md +++ b/docs/reference/marks/geo.md @@ -62,7 +62,7 @@ and materialized `data`; return a `GeoProjection`, `GeoStreamWrapper`, or | `strokeWidth` | `number` | SVG default | Boundary width | | `strokeDasharray` | `string` | SVG default | Boundary dash pattern | | `opacity` | `number` | SVG default | Whole-feature opacity | -| `anchor` | `(datum, index, data) => [lon, lat]` | `geoCentroid()` | Semantic longitude/latitude for the interaction point | +| `anchor` | `(datum, context) => [lon, lat]` | `geoCentroid()` | Semantic longitude/latitude for the interaction point | Semantic `color` becomes fill for closed geometry, stroke for linework, and both for a mixed collection. Explicit `fill` or `stroke` channels override that diff --git a/docs/reference/marks/text-frame-and-facet.md b/docs/reference/marks/text-frame-and-facet.md index b9273f67..8ef00217 100644 --- a/docs/reference/marks/text-frame-and-facet.md +++ b/docs/reference/marks/text-frame-and-facet.md @@ -120,16 +120,18 @@ function facet( ### Options -| Option | Type | Default | Meaning | -| ---------- | ------------------------------------------------------- | ------------------------- | -------------------------------------------------------------- | -| `id` | `string` | Layer-derived | Stable outer mark ID | -| `by` | `Channel` | Required | String or number grouping key | -| `chart` | `(data: readonly TDatum[], key: ChartKey) => ChartSpec` | Required | Builds one static child spec per group | -| `columns` | `number` | Automatic | Requested column count, floored and clamped to `1..groupCount` | -| `minWidth` | `number` | `220` | Target minimum cell width used for automatic columns | -| `gap` | `number` | `16` | Gap between rows and columns, clamped to at least zero | -| `label` | `boolean \| ((key) => string)` | `true` | Shows default key labels, formats them, or disables labels | -| `axes` | `'outer' \| 'cell'` | `'outer'` where shareable | Shared outside axes or independent axes in every cell | +| Option | Type | Default | Meaning | +| ---------- | -------------------------------------------------------------------- | ------------------------- | -------------------------------------------------------------- | +| `id` | `string` | Layer-derived | Stable outer mark ID | +| `by` | `Channel` | Required | String or number grouping key | +| `chart` | `(data: readonly TDatum[], context: FacetChartContext) => ChartSpec` | Required | Builds one static child spec per group | +| `columns` | `number` | Automatic | Requested column count, floored and clamped to `1..groupCount` | +| `minWidth` | `number` | `220` | Target minimum cell width used for automatic columns | +| `gap` | `number` | `16` | Gap between rows and columns, clamped to at least zero | +| `label` | `boolean \| ((key) => string)` | `true` | Shows default key labels, formats them, or disables labels | +| `axes` | `'outer' \| 'cell'` | `'outer'` where shareable | Shared outside axes or independent axes in every cell | + +`FacetChartContext.key` is the materialized value for the current group. Facet preserves first-seen group order and original row order within each group. Non-string and non-number `by` results are skipped. diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index f82aefcc..f5729ce6 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -450,14 +450,14 @@ value and throws for a nonnumeric domain. ```ts interface ChartColorLegend { - height(itemCount: number, width: number, colors?: ResolvedColorScale): number + height(itemCount: number, context: ChartColorLegendContext): number render(context: ChartColorLegendContext): SceneNode } ``` -`height` reserves space before chart bounds are finalized. `render` receives -the resolved colors, chart bounds, theme, and full width. Return one keyed -[scene node](./runtime-and-scene.md#scene-nodes). +`height` returns the reserved pixel height before chart bounds are finalized. +`render` returns one keyed [scene node](./runtime-and-scene.md#scene-nodes). +Both callbacks receive the resolved colors, chart bounds, theme, and full width. ## Theme and gradients diff --git a/docs/reference/types.md b/docs/reference/types.md index 0a640106..486b4b57 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -13,16 +13,28 @@ libraries can import the same definition, mark, scene, runtime, focus, and tooltip-model contracts from `@tanstack/charts/types`; DOM host and renderer types remain available from the root. +## Callback shape + +Public callbacks take at most two arguments: primary data or purpose first, +then a named context or options object. A callback without a distinct primary +payload takes one context object. Standard comparators, exact upstream +protocols, paired geometry, and consumer-called service methods are explicit +exceptions. + ## Values and channels ```ts type ChartValue = number | string | Date type ChartKey = string | number +interface ChannelAccessorContext { + index: number + data: readonly TDatum[] +} + type ChannelAccessor = ( datum: TDatum, - index: number, - data: readonly TDatum[], + context: ChannelAccessorContext, ) => TValue type Channel = @@ -31,13 +43,14 @@ type Channel = type VisualChannel = TValue | ChannelAccessor ``` -The corresponding public type names are `Channel`, `ChannelAccessor`, and -`VisualChannel`. +The corresponding public type names are `Channel`, `ChannelAccessor`, +`ChannelAccessorContext`, and `VisualChannel`. A `Channel` accepts only datum keys whose declared values are compatible with -the channel, or an accessor that derives a value from the row, index, and full -readonly data array. A `VisualChannel` replaces the field-name form with a -constant: it accepts either one constant value or an accessor. +the channel, or an accessor that derives a value from the row. Its context +contains the index and full readonly data array. A `VisualChannel` replaces +the field-name form with a constant: it accepts either one constant value or +an accessor. ```ts import { lineY } from '@tanstack/charts' @@ -134,7 +147,7 @@ The complete overloads and runtime rules are in | `InitializedMark` | Stable ID, materialized channels, optional layout labels, and render function | | `MaterializedChannel` | Values contributed to an optional named scale | | `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | -| `MarkScene` | Mark-owned scene nodes and optional interaction points | +| `MarkScene` | Mark-owned nodes plus optional interaction and presentation-focus points | | `ChartScene` | Complete renderer-neutral output | | `ChartPoint` | Typed interaction target | | `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | @@ -222,6 +235,8 @@ See [DOM host](./dom-host.md) and | Type | Purpose | | ------------------------------------- | ---------------------------------------------------------------------- | | `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | +| `ChartFocusResolveContext` | Pointer coordinates and maximum focus distance | +| `ChartFocusGroupContext` | Point being grouped or restored | | `ChartFocusPreset` | Built-in nearest and grouped axis focus names | | `ChartFocusMode` | Focus preset or custom strategy | | `ChartFocusState` | Primary, group, source, and pinned interaction state | @@ -231,6 +246,7 @@ See [DOM host](./dom-host.md) and | `ChartFocusAffinity` | Primitive fallback axis after exact geometry containment | | `ChartSpatialIndex` | Nearest-point query | | `ChartSpatialIndexFactory` | Builds an index from current scene points and resolved scene | +| `ChartSpatialIndexFactoryContext` | Resolved scene supplied to an index factory | | `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | | `ChartTooltipInput` | Tooltip extension token or configured extension options | | `ChartTooltipExtensionToken` | Environment-neutral contract implemented by host tooltip extensions | @@ -263,7 +279,7 @@ See [DOM host](./dom-host.md) and | `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 | +| `ChartTooltipContentContext` | Pinned state, axis labels, and value formatters for tooltip callbacks | | `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | | `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | @@ -324,7 +340,7 @@ subpath: - `TickXOptions`, `TickYOptions` - `TextOptions`, `TextAnchor` - `FrameOptions` -- `FacetOptions`, `FacetAxes` +- `FacetOptions`, `FacetAxes`, `FacetChartContext` - `ColorLegendOptions`, `ColorGradientLegendOptions` Their public fields and defaults are owned by the diff --git a/examples/sandbox/src/InteractionGeometryLab.test.ts b/examples/sandbox/src/InteractionGeometryLab.test.ts index 148c18a0..f3e7eae5 100644 --- a/examples/sandbox/src/InteractionGeometryLab.test.ts +++ b/examples/sandbox/src/InteractionGeometryLab.test.ts @@ -116,7 +116,11 @@ describe('intentional interaction contracts', () => { if (!probe) continue const strategy = proof.grouped.axis === 'x' ? focusX : focusY - const points = strategy.resolve(scene.points, probe.x, probe.y, 48) + const points = strategy.resolve(scene.points, { + x: probe.x, + y: probe.y, + maxDistance: 48, + }) expect(proof.grouped.definition.focus).toBe( proof.grouped.axis === 'x' ? 'group-x' : 'group-y', @@ -197,12 +201,11 @@ describe('interaction geometry proof gallery', () => { expect(probe).not.toBeNull() if (!probe) return - const before = legacyPointFocus.resolve( - scene.points, - probe.x, - probe.y, - 48, - )[0] + const before = legacyPointFocus.resolve(scene.points, { + x: probe.x, + y: probe.y, + maxDistance: 48, + })[0] const after = findNearestPoint(scene, probe.x, probe.y, 48) expect(before?.datum.label ?? 'Nothing focused yet').toBe( diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx index bd83dbc3..0660ca30 100644 --- a/examples/sandbox/src/InteractionGeometryLab.tsx +++ b/examples/sandbox/src/InteractionGeometryLab.tsx @@ -93,7 +93,7 @@ const canvasProofIds = new Set([ export const legacyPointFocus: ChartFocusStrategy = { - resolve(points, x, y, maxDistance) { + resolve(points, { x, y, maxDistance }) { let nearest: ChartPoint | undefined let nearestDistance = Number.POSITIVE_INFINITY for (const point of points) { @@ -107,7 +107,7 @@ export const legacyPointFocus: ChartFocusStrategy = ? [nearest] : [] }, - group(_points, point) { + group(_points, { point }) { return [point] }, navigation(points) { diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 40c6a265..05b513f9 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `11ba458` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `dcce66c` | | [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 `11ba458`. +at page render time. The measured TanStack workspace revision is `dcce66c`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 28.71–34.14 KiB | — | Controlled suite | +| TanStack Charts | 28.84–34.26 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/concepts/data-and-channels.md b/packages/charts-core/docs/concepts/data-and-channels.md index d50db0af..11ca61e3 100644 --- a/packages/charts-core/docs/concepts/data-and-channels.md +++ b/packages/charts-core/docs/concepts/data-and-channels.md @@ -42,10 +42,12 @@ dot(rows, { Every accessor receives: ```ts -;(datum, index, data) => value +;(datum, { index, data }) => value ``` -`datum` has the exact source type, `index` is the zero-based position, and `data` is the readonly materialized source array. Accessors are evaluated when the mark initializes; keep expensive cross-row transforms in application code. +`datum` has the exact source type. The context contains the zero-based `index` +and readonly materialized `data` array. Accessors are evaluated when the mark +initializes; keep expensive cross-row transforms in application code. ## Positional channels diff --git a/packages/charts-core/docs/concepts/grammar-of-graphics.md b/packages/charts-core/docs/concepts/grammar-of-graphics.md index 708d7b2d..0ab9b776 100644 --- a/packages/charts-core/docs/concepts/grammar-of-graphics.md +++ b/packages/charts-core/docs/concepts/grammar-of-graphics.md @@ -124,7 +124,9 @@ dot(rows, { }) ``` -Accessors receive `(datum, index, data)` and remain fully typed. Field channels are filtered by the value type the mark accepts, so TypeScript rejects a date field where a numeric bar length is required. +Accessors receive `(datum, { index, data })` and remain fully typed. Field +channels are filtered by the value type the mark accepts, so TypeScript rejects +a date field where a numeric bar length is required. Channels describe mappings. Constant appearance options such as `stroke: '#2563eb'` or `fillOpacity: 0.2` describe a fixed style. The distinction keeps semantic encodings visible in source. diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index ce629d53..c580a5ca 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -77,33 +77,49 @@ marks: [ [Open the grouped focus example](https://tanstack.com/charts/catalog/35-grouped-tooltip/) to inspect its live chart and complete source. -## Pin rich nested detail +## Pin and expand rich detail -A rich tooltip can compose the native rows with framework UI, including a -second chart. Hover remains transient; click or keyboard activation pins the -surface before it accepts pointer input. +This energy tooltip stays compact on hover or keyboard focus. Click, Enter, or +Space pins the same surface, adds solar coverage to its native rows, and +smoothly reveals the detailed consumption and generation breakdown. -The embedded framework-neutral case uses a fully application-owned surface. -Use the adapter's tooltip-body composition surface and include `defaultBody` -to retain native rows and swatches. A grouped parent can pass `points` -directly into a pie definition, placing the series comparison beside the rows -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. - -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`. +The definition's `content` callback receives `pinned`, so it can keep the +transient summary short and add structured rows only after activation. The +React `renderTooltipBody` callback receives that updated `defaultBody` and the +same pinned state: + +```tsx + ( + + )} +/> +``` + +The detail wrapper stays mounted and transitions from +`grid-template-rows: 0fr` to `1fr`; its direct child uses `min-height: 0` and +`overflow: hidden`. This animates intrinsic height without measuring content. +The transient body remains inert, controls render only while pinned, and the +nested consumption chart has its own accessible label and lifecycle. + +Add the `portal` extension to escape clipped ancestors and use viewport +collision handling. Wire the close button to `dismiss`; the shared host also +owns Escape, focus return, and non-modal dialog semantics. ## Scroll a wide schedule diff --git a/packages/charts-core/docs/guides/custom-marks-and-renderers.md b/packages/charts-core/docs/guides/custom-marks-and-renderers.md index af65a8fd..dbc726bb 100644 --- a/packages/charts-core/docs/guides/custom-marks-and-renderers.md +++ b/packages/charts-core/docs/guides/custom-marks-and-renderers.md @@ -238,13 +238,14 @@ clipping. ## Custom focus and spatial indexes A `ChartFocusStrategy` owns pointer resolution, grouping, and keyboard -navigation. Its generic types must remain identical to the chart points it -receives. +navigation. Pointer coordinates and the point being grouped arrive through +the second context bag. Its generic types must remain identical to the chart +points it receives. A `ChartSpatialIndexFactory` builds optional nearest-point acceleration from -scene points and receives the complete resolved scene as its second argument. -Return original typed points from the index. Do not erase them to `unknown` and -cast them back in callbacks. +scene points and receives the complete resolved scene through +`context.scene`. Return original typed points from the index. Do not erase +them to `unknown` and cast them back in callbacks. ## Extension checklist diff --git a/packages/charts-core/docs/guides/migrating.md b/packages/charts-core/docs/guides/migrating.md index f0e8c347..c7f94365 100644 --- a/packages/charts-core/docs/guides/migrating.md +++ b/packages/charts-core/docs/guides/migrating.md @@ -63,6 +63,20 @@ For the current breaking API: datum-, or guide-specific policy on the definition; - add `type: 'tween'` to focus-state transitions that previously supplied only `duration` and `easing`. +- replace channel `(datum, index, data)` accessors with + `(datum, { index, data })`; +- replace facet `chart(data, key)` builders with `chart(data, { key })`; +- replace focus `resolve(points, x, y, maxDistance)` and + `group(points, point)` implementations with `resolve(points, context)` and + `group(points, { point })`; +- replace spatial-index `(points, scene)` factories with + `(points, { scene })`; +- replace legend `height(itemCount, width, colors)` implementations with + `height(itemCount, context)`. The previous `width` value is now + `context.chart.width`; and +- use the second `ChartTooltipContentContext` argument in `format` and + `formatGroup` when formatter output depends on pinned state or axis + formatting. See [Marks and Layering](../concepts/marks-and-layering.md) and the [Example Gallery](../examples/index.md). diff --git a/packages/charts-core/docs/guides/tooltips-and-focus.md b/packages/charts-core/docs/guides/tooltips-and-focus.md index 87cfc900..3f3cb6e9 100644 --- a/packages/charts-core/docs/guides/tooltips-and-focus.md +++ b/packages/charts-core/docs/guides/tooltips-and-focus.md @@ -106,8 +106,10 @@ const definition = defineChart({ { id: 'change', label: 'Change', - text: (point) => - point.datum.change == null ? null : percent(point.datum.change), + text: (point, { pinned }) => + pinned && point.datum.change != null + ? percent(point.datum.change) + : null, }, 'x', ], @@ -116,6 +118,11 @@ const definition = defineChart({ ``` Array order is row order. A nullish field or `text` result omits the row. +Item `text`, `content`, `format`, and `formatGroup` callbacks receive `pinned`, +which is `false` during transient inspection and `true` after click, Enter, or +Space. +Use it to keep the transient tooltip compact and reveal detailed rows when the +same tooltip is pinned. Grouped focus keeps its shared-axis heading and series rows. By default, rows follow the marks top-to-bottom for an x-group and left-to-right for a y-group. Override that with `sort: 'color-domain'`, `sort: 'focus'`, or a typed @@ -129,8 +136,9 @@ Customize plaintext content with typed formatters: const formattedDefinition = defineChart(definition, { tooltip: { use: tooltip, - format(point) { - return `${point.datum.label}: ${point.datum.value.toLocaleString()}` + format(point, { pinned }) { + const suffix = pinned ? ' · pinned' : '' + return `${point.datum.label}: ${point.datum.value.toLocaleString()}${suffix}` }, }, }) @@ -142,13 +150,13 @@ For grouped focus: const groupedDefinition = defineChart(definition, { tooltip: { use: tooltip, - formatGroup(points) { + formatGroup(points, { pinned }) { const date = points[0]?.xValue const heading = date instanceof Date ? date.toLocaleDateString() : String(date ?? '') return [ - heading, + pinned ? `${heading} · pinned` : heading, ...points.map( (point) => `${point.groupLabel}: ${point.datum.value.toLocaleString()}`, @@ -160,8 +168,9 @@ const groupedDefinition = defineChart(definition, { ``` Formatting precedence is `content`, `formatGroup`, `format`, then the automatic -content. `content` returns safe title and row data. `format` and `formatGroup` -return plain text; returning HTML does not create DOM. +content. All three callbacks receive the same `ChartTooltipContentContext`. +`content` returns safe title and row data. `format` and `formatGroup` return +plain text; returning HTML does not create DOM. Add `className` to style the native HTML surface. Clicking pins the current tooltip for text selection. A later click or Escape unpins it. Set diff --git a/packages/charts-core/docs/reference/custom-extensions.md b/packages/charts-core/docs/reference/custom-extensions.md index b7828ae2..393c5388 100644 --- a/packages/charts-core/docs/reference/custom-extensions.md +++ b/packages/charts-core/docs/reference/custom-extensions.md @@ -90,9 +90,15 @@ interface MarkScene< > { nodes: readonly SceneNode[] points?: readonly ChartPoint[] + focusPoints?: readonly ChartPoint[] } ``` +Use `focusPoints` only for presentation geometry wrapped in `whenFocused` +whose candidates must not become native pointer, keyboard, tooltip, or callback +targets. Candidate keys must match their scene-node keys. The scene compiler +uses them only inside the focused presentation layer. + ### Mark requirements - Give the mark a stable ID. Derive a fallback from `markIndex` only when layer @@ -100,6 +106,7 @@ interface MarkScene< - Materialize every value needed to establish scale domains before rendering. - Map through `context.scales`; do not recalculate responsive ranges. - Give each scene node and point a deterministic key. +- Keep presentation-only `focusPoints` keyed to the nodes they reveal. - Emit finite geometry only. - Preserve the original datum and index in every interaction point. - Use one honest focus coordinate and semantic x/y pair per point. @@ -196,9 +203,9 @@ for the contract. `ChartSpatialIndexFactory` replaces the default linear pointer lookup without changing scene compilation. Build a point-only index from its first argument, -or use its second `scene` argument to index resolved primitive bounds. Return -the nearest original point within the requested distance. The host recreates -the index when the scene or factory changes. +or use `context.scene` from its second argument to index resolved primitive +bounds. Return the nearest original point within the requested distance. The +host recreates the index when the scene or factory changes. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The appropriate granular spatial primitive can be brought through the boundary @@ -207,8 +214,10 @@ described in [Scales](../concepts/scales-and-d3.md). ## Custom focus and gestures `ChartFocusStrategy` owns pointer resolution, focus grouping, and keyboard task -order. Rich gestures can instead disable chart-owned focus and maintain -selection or viewport state in the application. +order. Its `resolve(points, context)` and `group(points, context)` methods keep +coordinates and the active point in named context bags. Rich gestures can +instead disable chart-owned focus and maintain selection or viewport state in +the application. See [Focus and interaction](./focus-and-interaction.md). diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 25d4268f..a65d6bb9 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -136,14 +136,12 @@ interface ChartFocusStrategy< > { resolve( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ): readonly ChartPoint[] group( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ): readonly ChartPoint[] navigation( @@ -152,9 +150,10 @@ interface ChartFocusStrategy< } ``` -`resolve` receives scene-pixel pointer coordinates and returns primary point -first. `group` is called when an existing point is restored or reached through -keyboard navigation. `navigation` returns the ordered keyboard task set. +`ChartFocusResolveContext` contains scene-pixel `x`, `y`, and `maxDistance`. +`resolve` returns the primary point first. `ChartFocusGroupContext` contains +the point restored or reached through keyboard navigation. `navigation` +returns the ordered keyboard task set. `ChartFocusMode` accepts a `ChartFocusPreset` string or a `ChartFocusStrategy`. @@ -184,9 +183,13 @@ interface ChartTooltipOptions< points: readonly ChartPoint[], context: ChartTooltipContentContext, ) => ChartTooltipContent - format?: (point: ChartPoint) => string + format?: ( + point: ChartPoint, + context: ChartTooltipContentContext, + ) => string formatGroup?: ( points: readonly ChartPoint[], + context: ChartTooltipContentContext, ) => string sticky?: boolean } @@ -210,6 +213,11 @@ Formatting precedence is `content`, `formatGroup`, `format`, then the default. The text formatters do not parse HTML, and newlines are preserved. `className` is appended to `ts-chart-tooltip`. +`ChartTooltipContentContext.pinned` is `false` during transient inspection and +`true` after activation. `content`, `format`, `formatGroup`, and item `text` +receive the same context, so either structured or plaintext content can reveal +additional detail when pinned. + ### Ordered point items `items` is an ordered single-point row list. Use `x`, `y`, and `group` @@ -335,14 +343,14 @@ type ChartSpatialIndexFactory< TYValue extends ChartValue = ChartValue, > = ( points: readonly ChartPoint[], - scene: ChartScene, + context: ChartSpatialIndexFactoryContext, ) => ChartSpatialIndex ``` The host rebuilds the index when the scene or definition changes. The index -owns its search algorithm and must apply `maxDistance`. Existing point-only -factories can ignore the second argument; geometry-aware indexes can traverse -the resolved scene and use primitive bounds as their acceleration layer. +owns its search algorithm and must apply `maxDistance`. Point-only factories +can ignore the second argument; geometry-aware indexes can traverse +`context.scene` and use primitive bounds as their acceleration layer. Use the granular spatial primitive appropriate to the data; the boundary is described in [Scales](../concepts/scales-and-d3.md). diff --git a/packages/charts-core/docs/reference/marks/geo.md b/packages/charts-core/docs/reference/marks/geo.md index 06f97623..fa6738f1 100644 --- a/packages/charts-core/docs/reference/marks/geo.md +++ b/packages/charts-core/docs/reference/marks/geo.md @@ -62,7 +62,7 @@ and materialized `data`; return a `GeoProjection`, `GeoStreamWrapper`, or | `strokeWidth` | `number` | SVG default | Boundary width | | `strokeDasharray` | `string` | SVG default | Boundary dash pattern | | `opacity` | `number` | SVG default | Whole-feature opacity | -| `anchor` | `(datum, index, data) => [lon, lat]` | `geoCentroid()` | Semantic longitude/latitude for the interaction point | +| `anchor` | `(datum, context) => [lon, lat]` | `geoCentroid()` | Semantic longitude/latitude for the interaction point | Semantic `color` becomes fill for closed geometry, stroke for linework, and both for a mixed collection. Explicit `fill` or `stroke` channels override that diff --git a/packages/charts-core/docs/reference/marks/text-frame-and-facet.md b/packages/charts-core/docs/reference/marks/text-frame-and-facet.md index b9273f67..8ef00217 100644 --- a/packages/charts-core/docs/reference/marks/text-frame-and-facet.md +++ b/packages/charts-core/docs/reference/marks/text-frame-and-facet.md @@ -120,16 +120,18 @@ function facet( ### Options -| Option | Type | Default | Meaning | -| ---------- | ------------------------------------------------------- | ------------------------- | -------------------------------------------------------------- | -| `id` | `string` | Layer-derived | Stable outer mark ID | -| `by` | `Channel` | Required | String or number grouping key | -| `chart` | `(data: readonly TDatum[], key: ChartKey) => ChartSpec` | Required | Builds one static child spec per group | -| `columns` | `number` | Automatic | Requested column count, floored and clamped to `1..groupCount` | -| `minWidth` | `number` | `220` | Target minimum cell width used for automatic columns | -| `gap` | `number` | `16` | Gap between rows and columns, clamped to at least zero | -| `label` | `boolean \| ((key) => string)` | `true` | Shows default key labels, formats them, or disables labels | -| `axes` | `'outer' \| 'cell'` | `'outer'` where shareable | Shared outside axes or independent axes in every cell | +| Option | Type | Default | Meaning | +| ---------- | -------------------------------------------------------------------- | ------------------------- | -------------------------------------------------------------- | +| `id` | `string` | Layer-derived | Stable outer mark ID | +| `by` | `Channel` | Required | String or number grouping key | +| `chart` | `(data: readonly TDatum[], context: FacetChartContext) => ChartSpec` | Required | Builds one static child spec per group | +| `columns` | `number` | Automatic | Requested column count, floored and clamped to `1..groupCount` | +| `minWidth` | `number` | `220` | Target minimum cell width used for automatic columns | +| `gap` | `number` | `16` | Gap between rows and columns, clamped to at least zero | +| `label` | `boolean \| ((key) => string)` | `true` | Shows default key labels, formats them, or disables labels | +| `axes` | `'outer' \| 'cell'` | `'outer'` where shareable | Shared outside axes or independent axes in every cell | + +`FacetChartContext.key` is the materialized value for the current group. Facet preserves first-seen group order and original row order within each group. Non-string and non-number `by` results are skipped. 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 f82aefcc..f5729ce6 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -450,14 +450,14 @@ value and throws for a nonnumeric domain. ```ts interface ChartColorLegend { - height(itemCount: number, width: number, colors?: ResolvedColorScale): number + height(itemCount: number, context: ChartColorLegendContext): number render(context: ChartColorLegendContext): SceneNode } ``` -`height` reserves space before chart bounds are finalized. `render` receives -the resolved colors, chart bounds, theme, and full width. Return one keyed -[scene node](./runtime-and-scene.md#scene-nodes). +`height` returns the reserved pixel height before chart bounds are finalized. +`render` returns one keyed [scene node](./runtime-and-scene.md#scene-nodes). +Both callbacks receive the resolved colors, chart bounds, theme, and full width. ## Theme and gradients diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 0a640106..486b4b57 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -13,16 +13,28 @@ libraries can import the same definition, mark, scene, runtime, focus, and tooltip-model contracts from `@tanstack/charts/types`; DOM host and renderer types remain available from the root. +## Callback shape + +Public callbacks take at most two arguments: primary data or purpose first, +then a named context or options object. A callback without a distinct primary +payload takes one context object. Standard comparators, exact upstream +protocols, paired geometry, and consumer-called service methods are explicit +exceptions. + ## Values and channels ```ts type ChartValue = number | string | Date type ChartKey = string | number +interface ChannelAccessorContext { + index: number + data: readonly TDatum[] +} + type ChannelAccessor = ( datum: TDatum, - index: number, - data: readonly TDatum[], + context: ChannelAccessorContext, ) => TValue type Channel = @@ -31,13 +43,14 @@ type Channel = type VisualChannel = TValue | ChannelAccessor ``` -The corresponding public type names are `Channel`, `ChannelAccessor`, and -`VisualChannel`. +The corresponding public type names are `Channel`, `ChannelAccessor`, +`ChannelAccessorContext`, and `VisualChannel`. A `Channel` accepts only datum keys whose declared values are compatible with -the channel, or an accessor that derives a value from the row, index, and full -readonly data array. A `VisualChannel` replaces the field-name form with a -constant: it accepts either one constant value or an accessor. +the channel, or an accessor that derives a value from the row. Its context +contains the index and full readonly data array. A `VisualChannel` replaces +the field-name form with a constant: it accepts either one constant value or +an accessor. ```ts import { lineY } from '@tanstack/charts' @@ -134,7 +147,7 @@ The complete overloads and runtime rules are in | `InitializedMark` | Stable ID, materialized channels, optional layout labels, and render function | | `MaterializedChannel` | Values contributed to an optional named scale | | `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | -| `MarkScene` | Mark-owned scene nodes and optional interaction points | +| `MarkScene` | Mark-owned nodes plus optional interaction and presentation-focus points | | `ChartScene` | Complete renderer-neutral output | | `ChartPoint` | Typed interaction target | | `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | @@ -222,6 +235,8 @@ See [DOM host](./dom-host.md) and | Type | Purpose | | ------------------------------------- | ---------------------------------------------------------------------- | | `ChartFocusStrategy` | Pointer resolution, grouping, and keyboard ordering | +| `ChartFocusResolveContext` | Pointer coordinates and maximum focus distance | +| `ChartFocusGroupContext` | Point being grouped or restored | | `ChartFocusPreset` | Built-in nearest and grouped axis focus names | | `ChartFocusMode` | Focus preset or custom strategy | | `ChartFocusState` | Primary, group, source, and pinned interaction state | @@ -231,6 +246,7 @@ See [DOM host](./dom-host.md) and | `ChartFocusAffinity` | Primitive fallback axis after exact geometry containment | | `ChartSpatialIndex` | Nearest-point query | | `ChartSpatialIndexFactory` | Builds an index from current scene points and resolved scene | +| `ChartSpatialIndexFactoryContext` | Resolved scene supplied to an index factory | | `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | | `ChartTooltipInput` | Tooltip extension token or configured extension options | | `ChartTooltipExtensionToken` | Environment-neutral contract implemented by host tooltip extensions | @@ -263,7 +279,7 @@ See [DOM host](./dom-host.md) and | `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 | +| `ChartTooltipContentContext` | Pinned state, axis labels, and value formatters for tooltip callbacks | | `ChartTooltipBodyContext` | Focused points, content, pinned state, and dismissal | | `ChartTooltipBodyTarget` | Renderer-adapter body mount element plus body context | @@ -324,7 +340,7 @@ subpath: - `TickXOptions`, `TickYOptions` - `TextOptions`, `TextAnchor` - `FrameOptions` -- `FacetOptions`, `FacetAxes` +- `FacetOptions`, `FacetAxes`, `FacetChartContext` - `ColorLegendOptions`, `ColorGradientLegendOptions` Their public fields and defaults are owned by the diff --git a/packages/charts-core/src/area-x.ts b/packages/charts-core/src/area-x.ts index beaeec79..cd3bcfd7 100644 --- a/packages/charts-core/src/area-x.ts +++ b/packages/charts-core/src/area-x.ts @@ -75,7 +75,7 @@ export function areaX( : channelValues(data, rawX, (datum) => typeof datum === 'number' ? datum : undefined, ) - const yValues = channelValues(data, options.y, (_datum, index) => index) + const yValues = channelValues(data, options.y, (_datum, { index }) => index) const zValues = channelValues(data, options.z, () => null) const colorValues = options.color === undefined diff --git a/packages/charts-core/src/area.ts b/packages/charts-core/src/area.ts index 7d204d07..8f638722 100644 --- a/packages/charts-core/src/area.ts +++ b/packages/charts-core/src/area.ts @@ -62,7 +62,7 @@ export function areaY( return createMark(({ markIndex }) => { const id = options.id ?? `area-y-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const rawY = options.y ?? options.y2 const rawYValues = typeof rawY === 'number' diff --git a/packages/charts-core/src/band.ts b/packages/charts-core/src/band.ts index edced3a5..f90b27ad 100644 --- a/packages/charts-core/src/band.ts +++ b/packages/charts-core/src/band.ts @@ -63,7 +63,7 @@ export function bandX< return createMarkWithScaleValues(({ markIndex }) => { const id = resolved.id ?? `band-x-${markIndex}` - const values = channelValues(data, resolved.x, (_datum, index) => index) + const values = channelValues(data, resolved.x, (_datum, { index }) => index) const zValues = channelValues(data, resolved.z, () => null) const colorValues = resolved.color === undefined @@ -168,7 +168,7 @@ export function bandY< return createMark(({ markIndex }) => { const id = resolved.id ?? `band-y-${markIndex}` - const values = channelValues(data, resolved.y, (_datum, index) => index) + const values = channelValues(data, resolved.y, (_datum, { index }) => index) const zValues = channelValues(data, resolved.z, () => null) const colorValues = resolved.color === undefined diff --git a/packages/charts-core/src/bar.ts b/packages/charts-core/src/bar.ts index 9663ff95..a0a24620 100644 --- a/packages/charts-core/src/bar.ts +++ b/packages/charts-core/src/bar.ts @@ -14,6 +14,7 @@ import { valueKey } from './scales' import { stackValues } from './stack-internal' import type { Channel, + ChannelAccessor, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -85,7 +86,7 @@ export function barY( return createMark(({ markIndex }) => { const id = options.id ?? `bar-y-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const rawYValues = numericChannelValues( data, options.y ?? options.y2, @@ -270,7 +271,7 @@ export function barX( options.x ?? options.x2, (datum) => (typeof datum === 'number' ? datum : undefined), ) - const yValues = channelValues(data, options.y, (_datum, index) => index) + const yValues = channelValues(data, options.y, (_datum, { index }) => index) const zValues = channelValues(data, options.z, () => null) const colorValues = options.color === undefined @@ -502,11 +503,7 @@ function inferBandwidth( function numericChannelValues( data: readonly TDatum[], channel: number | Channel | undefined, - fallback: ( - datum: TDatum, - index: number, - data: readonly TDatum[], - ) => number | null | undefined, + fallback: ChannelAccessor, ) { return typeof channel === 'number' ? data.map(() => channel) diff --git a/packages/charts-core/src/dot.ts b/packages/charts-core/src/dot.ts index dce31f7c..0362ad01 100644 --- a/packages/charts-core/src/dot.ts +++ b/packages/charts-core/src/dot.ts @@ -63,7 +63,7 @@ export function dot( return createMark(({ markIndex }) => { const id = options.id ?? `dot-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const yValues = channelValues(data, options.y, (datum) => typeof datum === 'number' ? datum : undefined, ) diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index 7048c4e7..1cbcba31 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -539,7 +539,7 @@ describe('facets', () => { by: 'group', columns: 2, axes, - chart: (group, key) => ({ + chart: (group, { key }) => ({ marks: [lineY(group, { y: 'value' })], x: { scale: scaleLinear().domain([0, 1]) }, y: { diff --git a/packages/charts-core/src/facet.ts b/packages/charts-core/src/facet.ts index da7c8188..94a6fdeb 100644 --- a/packages/charts-core/src/facet.ts +++ b/packages/charts-core/src/facet.ts @@ -24,10 +24,14 @@ import type { export type FacetAxes = 'outer' | 'cell' +export interface FacetChartContext { + key: ChartKey +} + export interface FacetOptions extends ChartMarkMotionOptions { id?: string by: Channel - chart: (data: readonly TDatum[], key: ChartKey) => ChartSpec + chart: (data: readonly TDatum[], context: FacetChartContext) => ChartSpec columns?: number minWidth?: number gap?: number @@ -88,7 +92,10 @@ export function facet( const showLabel = options.label !== false const labelHeight = showLabel ? 22 : 0 const definitions = entries.map((entry) => - mergeTheme(options.chart(entry.data, entry.key), theme), + mergeTheme( + options.chart(entry.data, { key: entry.key }), + theme, + ), ) if ( diff --git a/packages/charts-core/src/focus-disabled.ts b/packages/charts-core/src/focus-disabled.ts index af09cd33..2e4a6ca1 100644 --- a/packages/charts-core/src/focus-disabled.ts +++ b/packages/charts-core/src/focus-disabled.ts @@ -1,4 +1,9 @@ -import type { ChartPoint, ChartValue } from './types' +import type { + ChartFocusGroupContext, + ChartFocusResolveContext, + ChartPoint, + ChartValue, +} from './types' export const focusDisabled: UniversalChartFocusStrategy = { resolve: () => [], @@ -9,13 +14,11 @@ export const focusDisabled: UniversalChartFocusStrategy = { interface UniversalChartFocusStrategy { resolve: ( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ) => readonly ChartPoint[] group: ( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ) => readonly ChartPoint[] navigation: ( points: readonly ChartPoint[], diff --git a/packages/charts-core/src/focus-mark.test.ts b/packages/charts-core/src/focus-mark.test.ts index 521e7cda..2bd05eef 100644 --- a/packages/charts-core/src/focus-mark.test.ts +++ b/packages/charts-core/src/focus-mark.test.ts @@ -5,6 +5,7 @@ import { barX, barY } from './bar' import { dot } from './dot' import { whenFocused } from './focus-mark' import { lineY } from './line' +import { ruleX, ruleY } from './rule' import { createChartScene, defineChart } from './scene' import { svgChartRenderer } from './svg-surface' import type { ChartScene, ChartValue } from './types' @@ -115,6 +116,143 @@ describe('focus-filtered marks', () => { surface.destroy() }) + it('reveals the focused ruleX without adding rules to interaction points', () => { + const guides = [{ category: 'A' }, { category: 'B' }] + const resolved = createChartScene( + defineChart({ + marks: [ + whenFocused( + ruleX(guides, { + id: 'focused-x', + x: 'category', + stroke: '#334155', + }), + { match: 'x' }, + ), + dot(rows, { + id: 'points', + x: 'category', + y: 'value', + key: 'id', + }), + ], + x: { scale: scaleBand().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 20]) }, + }), + { width: 480, height: 260 }, + ) + const marks = resolved.nodes.find((node) => node.key === 'marks') + if (marks?.kind !== 'group') throw new Error('Expected mark group') + const layer = marks.children[0] + if (layer?.kind !== 'group' || !layer.focus) { + throw new Error('Expected rule focus layer') + } + const ruleGroup = layer.children[0] + if (ruleGroup?.kind !== 'group') throw new Error('Expected rule group') + + expect(layer.focus.points).toHaveLength(guides.length) + expect(resolved.points).toHaveLength(rows.length) + expect(resolved.points.every((point) => point.markId === 'points')).toBe( + true, + ) + expect( + ruleGroup.children.every( + (node) => node.kind === 'rule' && node.interaction === undefined, + ), + ).toBe(true) + + const primary = resolved.points.find((point) => point.xValue === 'B') + if (!primary) throw new Error('Expected a B point') + const container = document.createElement('div') + const surface = svgChartRenderer.mount(container, () => {}) + surface.render(resolved, { ariaLabel: 'Focused x rule' }) + surface.paintFocus({ + primary, + group: resolved.points.filter((point) => point.xValue === primary.xValue), + source: 'pointer', + pinned: false, + }) + + const focusLayer = container.querySelector( + '.ts-chart__marks [data-ts-focus-layer]', + ) + const visibleRules = [ + ...(focusLayer?.querySelectorAll('line') ?? []), + ].filter((rule) => rule.getAttribute('visibility') === 'visible') + expect(focusLayer?.getAttribute('visibility')).toBe('visible') + expect(visibleRules).toHaveLength(1) + expect(Number(visibleRules[0]?.getAttribute('x1'))).toBeCloseTo(primary.x) + expect(visibleRules[0]?.getAttribute('x2')).toBe( + visibleRules[0]?.getAttribute('x1'), + ) + expect(Number(visibleRules[0]?.getAttribute('y1'))).toBeCloseTo( + resolved.chart.y, + ) + expect(Number(visibleRules[0]?.getAttribute('y2'))).toBeCloseTo( + resolved.chart.y + resolved.chart.height, + ) + + surface.paintFocus(null) + expect(focusLayer?.getAttribute('visibility')).toBe('hidden') + surface.destroy() + }) + + it('reveals the focused ruleY across the chart width', () => { + const values = [ + { id: 'low', x: 1, y: 4 }, + { id: 'high', x: 2, y: 8 }, + ] + const resolved = createChartScene( + defineChart({ + marks: [ + whenFocused( + ruleY(values, { + id: 'focused-y', + y: 'y', + stroke: '#334155', + }), + { match: 'y' }, + ), + dot(values, { id: 'points', x: 'x', y: 'y', key: 'id' }), + ], + x: { scale: scaleLinear().domain([0, 3]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }), + { width: 360, height: 220 }, + ) + const primary = resolved.points.find((point) => point.datum.id === 'high') + if (!primary) throw new Error('Expected a high point') + const container = document.createElement('div') + const surface = svgChartRenderer.mount(container, () => {}) + surface.render(resolved, { ariaLabel: 'Focused y rule' }) + surface.paintFocus({ + primary, + group: [primary], + source: 'pointer', + pinned: false, + }) + + const focusLayer = container.querySelector( + '.ts-chart__marks [data-ts-focus-layer]', + ) + const visibleRules = [ + ...(focusLayer?.querySelectorAll('line') ?? []), + ].filter((rule) => rule.getAttribute('visibility') === 'visible') + expect(focusLayer?.getAttribute('visibility')).toBe('visible') + expect(visibleRules).toHaveLength(1) + expect(Number(visibleRules[0]?.getAttribute('y1'))).toBeCloseTo(primary.y) + expect(visibleRules[0]?.getAttribute('y2')).toBe( + visibleRules[0]?.getAttribute('y1'), + ) + expect(Number(visibleRules[0]?.getAttribute('x1'))).toBeCloseTo( + resolved.chart.x, + ) + expect(Number(visibleRules[0]?.getAttribute('x2'))).toBeCloseTo( + resolved.chart.x + resolved.chart.width, + ) + surface.destroy() + }) + it('allows the built-in primary-point ring to be disabled explicitly', () => { const rows = [{ category: 'A', value: 12 }] const resolved = createChartScene( diff --git a/packages/charts-core/src/focus.test.ts b/packages/charts-core/src/focus.test.ts index db369919..6a403bf8 100644 --- a/packages/charts-core/src/focus.test.ts +++ b/packages/charts-core/src/focus.test.ts @@ -12,26 +12,38 @@ const points: ChartPoint[] = [ describe('axis focus strategies', () => { it('selects one nearest point while prioritizing x distance', () => { - const focused = focusNearestX.resolve(points, 12, 70, 100) + const focused = focusNearestX.resolve(points, { + x: 12, + y: 70, + maxDistance: 100, + }) expect(focused.map((candidate) => candidate.key)).toEqual(['b']) - expect(focusNearestX.group(points, focused[0]!)).toEqual(focused) + expect(focusNearestX.group(points, { point: focused[0]! })).toEqual(focused) expect(focusNearestX.navigation(points)).toEqual(points) }) it('selects one nearest point while prioritizing y distance', () => { - const focused = focusNearestY.resolve(points, 55, 23, 100) + const focused = focusNearestY.resolve(points, { + x: 55, + y: 23, + maxDistance: 100, + }) expect(focused.map((candidate) => candidate.key)).toEqual(['c']) - expect(focusNearestY.group(points, focused[0]!)).toEqual(focused) + expect(focusNearestY.group(points, { point: focused[0]! })).toEqual(focused) }) it('retains grouped x and y focus as separate modes', () => { expect( - focusX.resolve(points, 12, 70, 100).map((candidate) => candidate.key), + focusX + .resolve(points, { x: 12, y: 70, maxDistance: 100 }) + .map((candidate) => candidate.key), ).toEqual(['b', 'a']) expect( - focusY.resolve(points, 55, 23, 100).map((candidate) => candidate.key), + focusY + .resolve(points, { x: 55, y: 23, maxDistance: 100 }) + .map((candidate) => candidate.key), ).toEqual(['c', 'a']) }) @@ -44,14 +56,16 @@ describe('axis focus strategies', () => { expect( focusX - .group(groupedPoints, groupedPoints[1]!) + .group(groupedPoints, { point: groupedPoints[1]! }) .map((candidate) => candidate.key), ).toEqual(['a-focused', 'b']) }) it('can disable native datum focus without a case-local strategy', () => { - expect(focusDisabled.resolve(points, 10, 20, 100)).toEqual([]) - expect(focusDisabled.group(points, points[0]!)).toEqual([]) + expect( + focusDisabled.resolve(points, { x: 10, y: 20, maxDistance: 100 }), + ).toEqual([]) + expect(focusDisabled.group(points, { point: points[0]! })).toEqual([]) expect(focusDisabled.navigation(points)).toEqual([]) }) }) diff --git a/packages/charts-core/src/focus.ts b/packages/charts-core/src/focus.ts index 93740706..0b1229f9 100644 --- a/packages/charts-core/src/focus.ts +++ b/packages/charts-core/src/focus.ts @@ -1,5 +1,10 @@ import { valueKey } from './scales' -import type { ChartPoint, ChartValue } from './types' +import type { + ChartFocusGroupContext, + ChartFocusResolveContext, + ChartPoint, + ChartValue, +} from './types' export const focusX = axisFocus('x', true) export const focusY = axisFocus('y', true) @@ -15,10 +20,9 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { return { resolve( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ) { + const { x, y, maxDistance } = context const target = axis === 'x' ? x : y let nearest: (typeof points)[number] | undefined let distance = maxDistance @@ -45,8 +49,9 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { }, group( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ) { + const { point } = context return grouped ? groupPoints(points, point, value) : [point] }, navigation( @@ -69,13 +74,11 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { interface UniversalChartFocusStrategy { resolve: ( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ) => readonly ChartPoint[] group: ( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ) => readonly ChartPoint[] navigation: ( points: readonly ChartPoint[], diff --git a/packages/charts-core/src/geo.test.ts b/packages/charts-core/src/geo.test.ts index e1cb4fde..e3628fdc 100644 --- a/packages/charts-core/src/geo.test.ts +++ b/packages/charts-core/src/geo.test.ts @@ -103,8 +103,11 @@ describe('geoShape', () => { strokeWidth: 1, strokeDasharray: '3 2', opacity: 0.9, - anchor: (region) => - region.properties.id === 'west' ? [20, 20] : [75, 20], + anchor: (region, { index, data }) => { + expect(data).toBe(regions) + expect(data[index]).toBe(region) + return region.properties.id === 'west' ? [20, 20] : [75, 20] + }, }), ], margin: 10, diff --git a/packages/charts-core/src/geo.ts b/packages/charts-core/src/geo.ts index 2d14c3e8..61c1a013 100644 --- a/packages/charts-core/src/geo.ts +++ b/packages/charts-core/src/geo.ts @@ -19,6 +19,7 @@ import { resolveNumericScale } from './scale-input' import { valueKey } from './scales' import type { Channel, + ChannelAccessor, ChartBounds, ChartKey, ChartMark, @@ -69,11 +70,10 @@ export interface GeoShapeOptions< strokeWidth?: number strokeDasharray?: string opacity?: number - anchor?: ( - datum: TDatum, - index: number, - data: readonly TDatum[], - ) => readonly [longitude: number, latitude: number] + anchor?: ChannelAccessor< + TDatum, + readonly [longitude: number, latitude: number] + > } /** @@ -168,7 +168,8 @@ export function geoShape( const [x, y] = path.centroid(datum) const [longitude, latitude] = - options.anchor?.(datum, datumIndex, data) ?? geoCentroid(datum) + options.anchor?.(datum, { index: datumIndex, data }) ?? + geoCentroid(datum) if ( !Number.isFinite(x) || !Number.isFinite(y) || diff --git a/packages/charts-core/src/hexagon.ts b/packages/charts-core/src/hexagon.ts index 005f13ef..0c36a947 100644 --- a/packages/charts-core/src/hexagon.ts +++ b/packages/charts-core/src/hexagon.ts @@ -61,7 +61,7 @@ export function hexagon( return createMark(({ markIndex }) => { const id = options.id ?? `hexagon-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const yValues = channelValues(data, options.y, (datum) => typeof datum === 'number' ? datum : undefined, ) diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 31dacfdd..fb7cbfb0 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -17,7 +17,7 @@ export { d3Curve } from './d3-shape' export { dot } from './dot' export type { DotOptions } from './dot' export { facet, facetChart } from './facet' -export type { FacetAxes, FacetOptions } from './facet' +export type { FacetAxes, FacetChartContext, FacetOptions } from './facet' export { frame } from './frame' export type { FrameOptions } from './frame' export { whenFocused } from './focus-mark' @@ -156,6 +156,7 @@ export type { export type { Channel, ChannelAccessor, + ChannelAccessorContext, ChannelField, ChannelOutput, DynamicChartDefinition, @@ -190,8 +191,10 @@ export type { ChartFocusMode, ChartFocusFilter, ChartFocusAffinity, + ChartFocusGroupContext, ChartFocusMatch, ChartFocusPreset, + ChartFocusResolveContext, ChartFocusSource, ChartFocusState, ChartFocusStrategy, @@ -241,6 +244,7 @@ export type { ChartSize, ChartSpatialIndex, ChartSpatialIndexFactory, + ChartSpatialIndexFactoryContext, ChartSpecDatum, ChartSpecXValue, ChartSpecYValue, diff --git a/packages/charts-core/src/legend.ts b/packages/charts-core/src/legend.ts index f69b16a1..7640b36e 100644 --- a/packages/charts-core/src/legend.ts +++ b/packages/charts-core/src/legend.ts @@ -30,10 +30,12 @@ export function colorLegend( const minimumItemWidth = Math.max(64, options.itemWidth ?? 110) const labelOffset = options.label ? 13 : 0 return { - height(itemCount, width, colors) { - if (colors && isQuantitativeLegend(colors.kind)) { - return gradient.height(itemCount, width, colors) + height(itemCount, context) { + const { chart, colors } = context + if (isQuantitativeLegend(colors.kind)) { + return gradient.height(itemCount, context) } + const width = chart.width const columns = Math.max(1, Math.floor(width / minimumItemWidth)) return 18 + labelOffset + Math.ceil(itemCount / columns) * 19 }, diff --git a/packages/charts-core/src/line.ts b/packages/charts-core/src/line.ts index fdcab8d9..12c8d007 100644 --- a/packages/charts-core/src/line.ts +++ b/packages/charts-core/src/line.ts @@ -67,7 +67,7 @@ export function lineY( return createMark(({ markIndex }) => { const id = options.id ?? `line-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const yValues = channelValues(data, options.y, (datum) => typeof datum === 'number' ? datum : undefined, ) diff --git a/packages/charts-core/src/link.test.ts b/packages/charts-core/src/link.test.ts index 830c8c9e..62efa852 100644 --- a/packages/charts-core/src/link.test.ts +++ b/packages/charts-core/src/link.test.ts @@ -21,7 +21,8 @@ describe('link and tick marks', () => { y2: 'y2', key: 'id', stroke: '#2563eb', - strokeOpacity: (_datum, index) => 0.25 + index * 0.25, + strokeOpacity: (_datum, { index, data: rows }) => + 0.25 + index * (0.5 / rows.length), strokeWidth: (datum) => datum.weight, lineCap: 'butt', }), diff --git a/packages/charts-core/src/mark.ts b/packages/charts-core/src/mark.ts index fa0b577a..bbf54c51 100644 --- a/packages/charts-core/src/mark.ts +++ b/packages/charts-core/src/mark.ts @@ -68,17 +68,17 @@ export function visualValue( fallback: TValue, ): TValue { return typeof channel === 'function' - ? (channel as ChannelAccessor)(datum, index, data) + ? (channel as ChannelAccessor)(datum, { index, data }) : (channel ?? fallback) } export function channelValues( data: readonly TDatum[], channel: Channel | undefined, - fallback: (datum: TDatum, index: number, data: readonly TDatum[]) => TValue, + fallback: ChannelAccessor, ): TValue[] { if (typeof channel === 'function') { - return data.map((datum, index) => channel(datum, index, data)) + return data.map((datum, index) => channel(datum, { index, data })) } if (channel !== undefined) { return data.map((datum) => @@ -87,7 +87,7 @@ export function channelValues( : undefined, ) as TValue[] } - return data.map(fallback) + return data.map((datum, index) => fallback(datum, { index, data })) } export function inferredKeyValues( @@ -101,7 +101,7 @@ export function inferredKeyValues( } = {}, ): ChartKey[] { if (key !== undefined) { - return channelValues(data, key, (_datum, index) => index) + return channelValues(data, key, (_datum, { index }) => index) } const candidates = [ diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index 9c2730a8..8810800f 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -495,7 +495,7 @@ export function radialLine( const angleValues = typeof options.angle === 'number' ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, index) => index) + : channelValues(data, options.angle, (_datum, { index }) => index) const radiusValues = typeof options.radius === 'number' ? data.map(() => options.radius as number) @@ -667,7 +667,7 @@ export function radialArea( const angleValues = typeof options.angle === 'number' ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, index) => index) + : channelValues(data, options.angle, (_datum, { index }) => index) const radiusValues = typeof options.radius === 'number' ? data.map(() => options.radius as number) @@ -855,7 +855,7 @@ export function radialText( const angleValues = typeof options.angle === 'number' ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, index) => index) + : channelValues(data, options.angle, (_datum, { index }) => index) const radiusValues = typeof options.radius === 'number' ? data.map(() => options.radius as number) @@ -1017,7 +1017,7 @@ export function radialRule( const angleValues = typeof options.angle === 'number' ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, index) => index) + : channelValues(data, options.angle, (_datum, { index }) => index) const radius1Values = typeof options.radius1 === 'number' ? data.map(() => options.radius1 as number) @@ -1145,7 +1145,7 @@ export function radialDot( const angleValues = typeof options.angle === 'number' ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, index) => index) + : channelValues(data, options.angle, (_datum, { index }) => index) const radiusValues = typeof options.radius === 'number' ? data.map(() => options.radius as number) diff --git a/packages/charts-core/src/rect.ts b/packages/charts-core/src/rect.ts index 7d91b907..f1b6c70b 100644 --- a/packages/charts-core/src/rect.ts +++ b/packages/charts-core/src/rect.ts @@ -102,14 +102,14 @@ export function rect( return createMark(({ markIndex }) => { const id = options.id ?? `rect-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) - const x1Values = channelValues(data, options.x1, (_datum, index) => + const xValues = channelValues(data, options.x, (_datum, { index }) => index) + const x1Values = channelValues(data, options.x1, (_datum, { index }) => options.x === undefined ? index : xValues[index], ) const x2Values = channelValues( data, options.x2, - (_datum, index) => xValues[index], + (_datum, { index }) => xValues[index], ) const yValues = channelValues(data, options.y, (datum) => typeof datum === 'number' ? datum : undefined, @@ -117,12 +117,12 @@ export function rect( const y1Values = channelValues( data, options.y1, - (_datum, index) => yValues[index], + (_datum, { index }) => yValues[index], ) const y2Values = channelValues( data, options.y2, - (_datum, index) => yValues[index], + (_datum, { index }) => yValues[index], ) const zValues = channelValues(data, options.z, () => null) const colorValues = diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index d68bd740..3fabcb1e 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -168,10 +168,9 @@ describe('renderer-neutral chart host', () => { ) const firstPoint = host.getScene().points[0]! - expect(spatialIndex).toHaveBeenCalledWith( - host.getScene().points, - host.getScene(), - ) + expect(spatialIndex).toHaveBeenCalledWith(host.getScene().points, { + scene: host.getScene(), + }) expect(findNearest).toHaveBeenCalledWith(firstPoint.x, firstPoint.y, 48) expect(onFocusChange).toHaveBeenLastCalledWith(host.getScene().points[1]) host.destroy() @@ -473,6 +472,67 @@ describe('renderer-neutral chart host', () => { host.destroy() }) + it('does not repin when dismissing a tooltip unmounts the click target', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const onFocusChange = vi.fn() + let closeButton: HTMLButtonElement | undefined + const host = mountChartRenderer(container, { + definition: defineChart(definition, { + maxFocusDistance: 1_000, + tooltip: tooltipExtension, + }), + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Dismissible composed tooltip', + onFocusChange, + onTooltipBodyChange(target) { + if (!target) { + closeButton?.remove() + closeButton = undefined + return + } + if (closeButton) return + closeButton = document.createElement('button') + closeButton.addEventListener('click', () => target.dismiss()) + target.element.append(closeButton) + }, + }) + + fake.element.dispatchEvent( + new MouseEvent('pointermove', { + bubbles: true, + clientX: 20, + clientY: 20, + }), + ) + fake.element.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + clientX: 20, + clientY: 20, + }), + ) + + const tooltip = container.querySelector('.ts-chart-tooltip') + expect(tooltip?.dataset.sticky).toBe('true') + if (!closeButton) throw new Error('Expected composed tooltip close button') + + closeButton.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + clientX: 20, + clientY: 20, + }), + ) + + expect(tooltip?.hidden).toBe(true) + expect(onFocusChange).toHaveBeenLastCalledWith(null) + expect(fake.paintFocus.mock.calls.at(-1)?.[0]).toBeNull() + host.destroy() + }) + it('uses the tooltip as a top-layer popover, retains ancestry, and reopens it while active', () => { const popover = installPopoverMock(window) const viewport = installVisualViewport(window, { diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index 9ff92689..109bb107 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -106,7 +106,7 @@ export function mountChartRenderer< spatialIndex = options.definition.focus === false ? undefined - : options.definition.spatialIndex?.(scene.points, scene) + : options.definition.spatialIndex?.(scene.points, { scene }) const nextFocusedPoint = options.definition.focus === false ? null @@ -272,7 +272,18 @@ export function mountChartRenderer< } } const handleClick = (event: MouseEvent) => { - if (tooltipInstance?.contains(event.target)) { + const activeTooltip = tooltipInstance + const NodeConstructor = container.ownerDocument.defaultView?.Node + const originatedInTooltip = NodeConstructor + ? event + .composedPath() + .some( + (target) => + target instanceof NodeConstructor && + activeTooltip?.contains(target), + ) + : activeTooltip?.contains(event.target) + if (activeTooltip && originatedInTooltip) { return } const points = pointsAtPointer(event.clientX, event.clientY) @@ -445,7 +456,7 @@ export function mountChartRenderer< const points = interactionPoints() const focus = resolveFocusStrategy(options.definition.focus) if (focus) { - return focus.resolve(points, x, y, maxDistance) + return focus.resolve(points, { x, y, maxDistance }) } const point = points !== interactionScene.points @@ -471,7 +482,7 @@ export function mountChartRenderer< return ( resolveFocusStrategy(options.definition.focus)?.group( interactionPoints(), - point, + { point }, ) ?? [point] ) } diff --git a/packages/charts-core/src/rule.ts b/packages/charts-core/src/rule.ts index def05dbf..9f6793ec 100644 --- a/packages/charts-core/src/rule.ts +++ b/packages/charts-core/src/rule.ts @@ -11,6 +11,7 @@ import type { ChartKey, ChartMark, ChartMarkMotionOptions, + ChartPoint, ChartValue, OptionChannelOutput, SceneNode, @@ -79,45 +80,67 @@ export function ruleY( values: colorValues.filter(isChartKey), }, }, - render: ({ scales, chart, theme, color: resolveColor }) => ({ - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__rule ts-chart__rule-y', - ariaHidden: true, - children: data.flatMap((datum, index): SceneNode[] => { - const value = values[index] - return isChartValue(value) - ? [ - { - kind: 'rule', - key: `${id}:${valueKey(value)}:${index}`, - x1: chart.x, - x2: chart.x + chart.width, - y1: scales.y.map(value), - y2: scales.y.map(value), - style: { - stroke: visualValue( - options.stroke, - datum, - index, - data, - colorValues[index] == null - ? theme.foreground - : resolveColor(colorValues[index]), - ), - strokeOpacity: options.strokeOpacity ?? 0.5, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - }, - }, - ] - : [] - }), - }, - ], - }), + render: ({ scales, chart, theme, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const focusPoints: ChartPoint[] = [] + + data.forEach((datum, index) => { + const value = values[index] + if (!isChartValue(value)) return + + const y = scales.y.map(value) + const color = visualValue( + options.stroke, + datum, + index, + data, + colorValues[index] == null + ? theme.foreground + : resolveColor(colorValues[index]), + ) + const key = `${id}:${valueKey(value)}:${index}` + nodes.push({ + kind: 'rule', + key, + x1: chart.x, + x2: chart.x + chart.width, + y1: y, + y2: y, + style: { + stroke: color, + strokeOpacity: options.strokeOpacity ?? 0.5, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + }, + }) + focusPoints.push({ + key, + markId: id, + group: null, + groupLabel: id, + datum, + datumIndex: index, + xValue: 0, + yValue: value, + x: chart.x + chart.width / 2, + y, + color, + }) + }) + + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__rule ts-chart__rule-y', + ariaHidden: true, + children: nodes, + }, + ], + focusPoints, + } + }, } }, options.motion) } @@ -157,45 +180,67 @@ export function ruleX( values: colorValues.filter(isChartKey), }, }, - render: ({ scales, chart, theme, color: resolveColor }) => ({ - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__rule ts-chart__rule-x', - ariaHidden: true, - children: data.flatMap((datum, index): SceneNode[] => { - const value = values[index] - return isChartValue(value) - ? [ - { - kind: 'rule', - key: `${id}:${valueKey(value)}:${index}`, - x1: scales.x.map(value), - x2: scales.x.map(value), - y1: chart.y, - y2: chart.y + chart.height, - style: { - stroke: visualValue( - options.stroke, - datum, - index, - data, - colorValues[index] == null - ? theme.foreground - : resolveColor(colorValues[index]), - ), - strokeOpacity: options.strokeOpacity ?? 0.5, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - }, - }, - ] - : [] - }), - }, - ], - }), + render: ({ scales, chart, theme, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const focusPoints: ChartPoint[] = [] + + data.forEach((datum, index) => { + const value = values[index] + if (!isChartValue(value)) return + + const x = scales.x.map(value) + const color = visualValue( + options.stroke, + datum, + index, + data, + colorValues[index] == null + ? theme.foreground + : resolveColor(colorValues[index]), + ) + const key = `${id}:${valueKey(value)}:${index}` + nodes.push({ + kind: 'rule', + key, + x1: x, + x2: x, + y1: chart.y, + y2: chart.y + chart.height, + style: { + stroke: color, + strokeOpacity: options.strokeOpacity ?? 0.5, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + }, + }) + focusPoints.push({ + key, + markId: id, + group: null, + groupLabel: id, + datum, + datumIndex: index, + xValue: value, + yValue: 0, + x, + y: chart.y + chart.height / 2, + color, + }) + }) + + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__rule ts-chart__rule-x', + ariaHidden: true, + children: nodes, + }, + ], + focusPoints, + } + }, } }, options.motion) } diff --git a/packages/charts-core/src/runtime.test.ts b/packages/charts-core/src/runtime.test.ts index d5e5f879..5370851c 100644 --- a/packages/charts-core/src/runtime.test.ts +++ b/packages/charts-core/src/runtime.test.ts @@ -15,6 +15,7 @@ import type { ChartDefinitionOptions, ChartPoint, ChartTextMeasurer, + ChartTooltipContentContext, ChartValue, } from './types' @@ -763,7 +764,8 @@ describe('dynamic chart runtime', () => { stackHost.destroy() }) - it('accepts application fields through structured tooltip content', () => { + it('expands structured tooltip content when pinned', () => { + const contentPinned = vi.fn() const container = document.createElement('div') const definition = defineChart({ marks: [lineY([{ x: 0, y: 4, note: 'Released' }], { x: 'x', y: 'y' })], @@ -777,22 +779,29 @@ describe('dynamic chart runtime', () => { definition: withChartOptions(definition, { tooltip: { use: tooltipExtension, - content: ([point], context) => ({ - title: point ? context.formatX(point.xValue) : undefined, - rows: point - ? [ - { - label: 'Status', - value: point.datum.note, - color: point.color, - }, - { - label: 'Downloads', - value: `${point.yValue}k`, - }, - ] - : [], - }), + content: ([point], context) => { + contentPinned(context.pinned) + return { + title: point ? context.formatX(point.xValue) : undefined, + rows: point + ? [ + { + label: 'Status', + value: point.datum.note, + color: point.color, + }, + ...(context.pinned + ? [ + { + label: 'Downloads', + value: `${point.yValue}k`, + }, + ] + : []), + ] + : [], + } + }, }, }), width: 480, @@ -804,13 +813,86 @@ describe('dynamic chart runtime', () => { svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) const tooltip = container.querySelector('.ts-chart-tooltip') - expect(tooltip?.querySelectorAll('.ts-chart-tooltip__row')).toHaveLength(2) + expect(tooltip?.querySelectorAll('.ts-chart-tooltip__row')).toHaveLength(1) expect(tooltip?.textContent).toContain('Released') + expect(tooltip?.textContent).not.toContain('4k') + expect(contentPinned).toHaveBeenLastCalledWith(false) + + svg.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }), + ) + expect(contentPinned).toHaveBeenLastCalledWith(true) + expect(tooltip?.querySelectorAll('.ts-chart-tooltip__row')).toHaveLength(2) expect(tooltip?.textContent).toContain('4k') host.destroy() }) + it.each(['format', 'formatGroup'] as const)( + 'supplies shared tooltip context to %s while pinning', + (formatter) => { + const contexts: ChartTooltipContentContext[] = [] + const data = [{ id: 'released', x: 1, y: 4 }] + const definition = defineChart({ + marks: [lineY(data, { x: 'x', y: 'y', key: 'id' })], + x: { ...linearAxes([0, 2], [0, 4]).x, axis: { label: 'Week' } }, + y: { ...linearAxes([0, 2], [0, 4]).y, axis: { label: 'Downloads' } }, + }) + const resolveText = (context: ChartTooltipContentContext) => { + contexts.push(context) + return context.pinned ? 'Pinned' : 'Transient' + } + const format = ( + _point: ChartPoint<(typeof data)[number], number, number>, + context: ChartTooltipContentContext, + ) => resolveText(context) + const formatGroup = ( + _points: readonly ChartPoint<(typeof data)[number], number, number>[], + context: ChartTooltipContentContext, + ) => resolveText(context) + const container = document.createElement('div') + const host = mountChart(container, { + definition: withChartOptions(definition, { + tooltip: + formatter === 'format' + ? { use: tooltipExtension, format } + : { use: tooltipExtension, formatGroup }, + }), + width: 480, + height: 260, + ariaLabel: `${formatter} context chart`, + }) + const svg = container.querySelector('svg') + if (!svg) throw new Error('Expected SVG') + + svg.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + expect(contexts.at(-1)).toMatchObject({ + pinned: false, + xLabel: 'Week', + yLabel: 'Downloads', + }) + expect(container.querySelector('.ts-chart-tooltip')?.textContent).toBe( + 'Transient', + ) + + svg.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }), + ) + expect(contexts.at(-1)).toMatchObject({ + pinned: true, + xLabel: 'Week', + yLabel: 'Downloads', + }) + expect(contexts.at(-1)?.formatX(1)).toBe('1') + expect(contexts.at(-1)?.formatY(4)).toBe('4') + expect(container.querySelector('.ts-chart-tooltip')?.textContent).toBe( + 'Pinned', + ) + host.destroy() + }, + ) + it('orders automatic point items and formats datum fields', () => { + const itemPinned = vi.fn() const data = [ { id: 'a', @@ -850,7 +932,10 @@ describe('dynamic chart runtime', () => { { id: 'change', label: 'Change', - text: (point) => `${point.datum.change * 100}%`, + text: (point, context) => { + itemPinned(context.pinned) + return context.pinned ? `${point.datum.change * 100}%` : null + }, }, { id: 'empty', @@ -878,11 +963,19 @@ describe('dynamic chart runtime', () => { expect(rows.map((row) => row.textContent)).toEqual([ 'Revenue4.0', 'Volume1.2k', - 'Change25%', 'PeriodA', 'GroupAtlas', ]) expect(container.querySelector('.ts-chart-tooltip__title')).toBeNull() + expect(itemPinned).toHaveBeenLastCalledWith(false) + + svg.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }), + ) + expect(itemPinned).toHaveBeenLastCalledWith(true) + expect(container.querySelector('.ts-chart-tooltip')?.textContent).toContain( + 'Change25%', + ) host.destroy() }) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index e9e768ab..a06efb00 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -231,7 +231,7 @@ function createChartSceneWithScaleResolver< ariaHidden: true, focus: { match: mark.focus.match ?? 'primary', - points: renderedPoints, + points: rendered.focusPoints ?? renderedPoints, placement: firstBaseMarkIndex < 0 || markIndex < firstBaseMarkIndex ? 'under' @@ -511,7 +511,12 @@ function resolveSceneLayout( if (legend && locks.top === undefined) { automatic.top = Math.max( automatic.top, - legend.height(colors.domain.length, resolved.chart.width, colors), + legend.height(colors.domain.length, { + colors, + chart: resolved.chart, + theme, + width, + }), ) } if (!definition.clip) { diff --git a/packages/charts-core/src/text.ts b/packages/charts-core/src/text.ts index 9089eb19..fce4f750 100644 --- a/packages/charts-core/src/text.ts +++ b/packages/charts-core/src/text.ts @@ -67,7 +67,7 @@ export function text( return createMark(({ markIndex }) => { const id = options.id ?? `text-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, index) => index) + const xValues = channelValues(data, options.x, (_datum, { index }) => index) const yValues = channelValues(data, options.y, (datum) => typeof datum === 'number' ? datum : undefined, ) diff --git a/packages/charts-core/src/tooltip.ts b/packages/charts-core/src/tooltip.ts index 3d89e712..25efd87e 100644 --- a/packages/charts-core/src/tooltip.ts +++ b/packages/charts-core/src/tooltip.ts @@ -99,17 +99,19 @@ function createTooltipExtension< ) const contentContext = createTooltipContentContext( nextContext.scene, + nextContext.pinned, options, ) const content = options.content?.(points, contentContext) const text = content === undefined - ? (options.formatGroup?.(points) ?? options.format?.(nextContext.point)) + ? (options.formatGroup?.(points, contentContext) ?? + options.format?.(nextContext.point, contentContext)) : undefined const resolvedContent = content ?? text ?? - defaultTooltipContent(points, nextContext.scene, options) + defaultTooltipContent(points, nextContext.scene, options, contentContext) const custom = renderTooltipBody( tooltipElement, points, @@ -382,11 +384,13 @@ function createTooltip(document: Document) { function createTooltipContentContext( scene: ChartScene, + pinned: boolean, options?: ChartTooltipOptions, ): ChartTooltipContentContext { const x = findTooltipChannelItem(options?.items, 'x') const y = findTooltipChannelItem(options?.items, 'y') return { + pinned, xLabel: x?.label ?? findSceneLabel(scene, 'x-label') ?? 'x', yLabel: y?.label ?? findSceneLabel(scene, 'y-label') ?? 'y', formatX: formatValue, @@ -397,11 +401,11 @@ function createTooltipContentContext( function defaultTooltipContent( points: readonly ChartPoint[], scene: ChartScene, - options?: ChartTooltipOptions, + options: ChartTooltipOptions | undefined, + context: ChartTooltipContentContext, ): 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') diff --git a/packages/charts-core/src/type-contract.test.ts b/packages/charts-core/src/type-contract.test.ts index c1670a4e..d4430e9e 100644 --- a/packages/charts-core/src/type-contract.test.ts +++ b/packages/charts-core/src/type-contract.test.ts @@ -17,6 +17,7 @@ import { createChartScene, defineChart } from './scene' import { tooltip } from './tooltip' import { portal } from './tooltip-portal' import type { + ChartColorLegend, ChartDefinition, ChartFocusStrategy, ChartMark, @@ -248,7 +249,11 @@ const categoricalRectDefinition = defineChart({ }) const facetedMark = facet(rows, { by: 'category', - chart: () => categoricalSpec, + chart: (data, { key }) => { + expectTypeOf(data).toEqualTypeOf() + expectTypeOf(key).toEqualTypeOf() + return categoricalSpec + }, }) const customMark = createMark(() => ({ id: 'custom', @@ -344,8 +349,27 @@ if (false) { }) const container = document.createElement('div') + const customLegend: ChartColorLegend = { + height(itemCount, context) { + expectTypeOf(itemCount).toEqualTypeOf() + expectTypeOf(context.width).toEqualTypeOf() + expectTypeOf(context.colors.domain).toEqualTypeOf< + readonly (string | number)[] + >() + expectTypeOf(context.chart.width).toEqualTypeOf() + return itemCount + }, + render(context) { + expectTypeOf(context.theme.foreground).toEqualTypeOf() + return { kind: 'group', key: 'legend', children: [] } + }, + } + void customLegend const categoricalFocus: ChartFocusStrategy = { - resolve(points) { + resolve(points, context) { + expectTypeOf(context.x).toEqualTypeOf() + expectTypeOf(context.y).toEqualTypeOf() + expectTypeOf(context.maxDistance).toEqualTypeOf() return points.filter( (point) => point.datum.enabled && @@ -353,7 +377,7 @@ if (false) { point.yValue > 0, ) }, - group(points, point) { + group(points, { point }) { return points.filter( (candidate) => candidate.datum.category === point.datum.category && @@ -368,7 +392,7 @@ if (false) { } const numericFocus: ChartFocusStrategy = { resolve: (points) => points, - group: (_points, point) => [point], + group: (_points, { point }) => [point], navigation: (points) => points, } const numericRenderer: ChartSvgRenderer = () => '' @@ -400,6 +424,7 @@ if (false) { text(point, context) { expectTypeOf(point.datum).toEqualTypeOf() expectTypeOf(context.formatY).toBeFunction() + expectTypeOf(context.pinned).toEqualTypeOf() return point.datum.enabled ? 'enabled' : null }, }, @@ -423,18 +448,24 @@ if (false) { }, placement: ['top', 'bottom-right'], offset: 12, - format(point) { + format(point, context) { expectTypeOf(point.datum).toEqualTypeOf() expectTypeOf(point.xValue).toEqualTypeOf() expectTypeOf(point.yValue).toEqualTypeOf() + expectTypeOf(context.pinned).toEqualTypeOf() + expectTypeOf(context.xLabel).toEqualTypeOf() + expectTypeOf(context.formatX).toBeFunction() return point.xValue }, - formatGroup(points) { + formatGroup(points, context) { expectTypeOf(points).items.toMatchTypeOf<{ datum: Row xValue: string yValue: number }>() + expectTypeOf(context.pinned).toEqualTypeOf() + expectTypeOf(context.yLabel).toEqualTypeOf() + expectTypeOf(context.formatY).toBeFunction() return points.map((point) => point.xValue).join(', ') }, content(points, context) { @@ -444,6 +475,7 @@ if (false) { yValue: number }>() expectTypeOf(context.xLabel).toEqualTypeOf() + expectTypeOf(context.pinned).toEqualTypeOf() return { rows: points.map((point) => ({ label: point.datum.category, @@ -452,7 +484,7 @@ if (false) { } }, }, - spatialIndex(points, scene) { + spatialIndex(points, { scene }) { expectTypeOf(points).items.toMatchTypeOf<{ datum: Row xValue: string diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index ed58cd0b..531019ea 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -64,10 +64,14 @@ export type ChartScaleResolver = ( context: ChartScaleResolveContext, ) => ResolvedScale +export interface ChannelAccessorContext { + index: number + data: readonly TDatum[] +} + export type ChannelAccessor = ( datum: TDatum, - index: number, - data: readonly TDatum[], + context: ChannelAccessorContext, ) => TValue export type ChannelField = { @@ -394,11 +398,7 @@ export interface ChartColorLegendContext { } export interface ChartColorLegend { - height: ( - itemCount: number, - width: number, - colors?: ResolvedColorScale, - ) => number + height: (itemCount: number, context: ChartColorLegendContext) => number render: (context: ChartColorLegendContext) => SceneNode } @@ -787,6 +787,8 @@ export interface MarkScene< > { nodes: readonly SceneNode[] points?: readonly ChartPoint[] + /** Presentation-only candidates used when the mark is wrapped by `whenFocused`. */ + focusPoints?: readonly ChartPoint[] } export type ChartFocusAffinity = 'x' | 'y' | 'xy' | 'geometry' @@ -1002,9 +1004,13 @@ export interface ChartTooltipOptions< points: readonly ChartPoint[], context: ChartTooltipContentContext, ) => ChartTooltipContent - format?: (point: ChartPoint) => string + format?: ( + point: ChartPoint, + context: ChartTooltipContentContext, + ) => string formatGroup?: ( points: readonly ChartPoint[], + context: ChartTooltipContentContext, ) => string sticky?: boolean } @@ -1203,6 +1209,7 @@ export interface ChartTooltipContent { } export interface ChartTooltipContentContext { + pinned: boolean xLabel: string yLabel: string formatX: (value: ChartValue) => string @@ -1233,19 +1240,31 @@ export interface ChartFocusStrategy< > { resolve: ( points: readonly ChartPoint[], - x: number, - y: number, - maxDistance: number, + context: ChartFocusResolveContext, ) => readonly ChartPoint[] group: ( points: readonly ChartPoint[], - point: ChartPoint, + context: ChartFocusGroupContext, ) => readonly ChartPoint[] navigation: ( points: readonly ChartPoint[], ) => readonly ChartPoint[] } +export interface ChartFocusResolveContext { + x: number + y: number + maxDistance: number +} + +export interface ChartFocusGroupContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + point: ChartPoint +} + export type ChartFocusPreset = 'nearest' | 'nearest-x' | 'nearest-y' | 'group-x' | 'group-y' @@ -1267,13 +1286,21 @@ export interface ChartSpatialIndex< ) => ChartPoint | null } +export interface ChartSpatialIndexFactoryContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + scene: ChartScene +} + export type ChartSpatialIndexFactory< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, > = ( points: readonly ChartPoint[], - scene: ChartScene, + context: ChartSpatialIndexFactoryContext, ) => ChartSpatialIndex export interface ChartRuntime< diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index d2296470..2b66130d 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -4,7 +4,7 @@ export type { ArrowOptions } from './arrow' export type { BarXOptions, BarYOptions } from './bar' export type { BandXOptions, BandYOptions } from './band' export type { DotOptions } from './dot' -export type { FacetAxes, FacetOptions } from './facet' +export type { FacetAxes, FacetChartContext, FacetOptions } from './facet' export type { FrameOptions } from './frame' export type { GroupLayout, GroupOptions } from './group' export type { HexagonOptions } from './hexagon' @@ -78,6 +78,7 @@ export type { export type { Channel, ChannelAccessor, + ChannelAccessorContext, ChannelField, ChannelOutput, DynamicChartDefinition, @@ -110,10 +111,12 @@ export type { ChartDefinitionOptions, ChartFocusFilter, ChartFocusAffinity, + ChartFocusGroupContext, ChartFocusMatch, ChartExtensionInput, ChartFocusMode, ChartFocusPreset, + ChartFocusResolveContext, ChartFocusSource, ChartFocusState, ChartFocusStrategy, @@ -163,6 +166,7 @@ export type { ChartSize, ChartSpatialIndex, ChartSpatialIndexFactory, + ChartSpatialIndexFactoryContext, ChartSpecDatum, ChartSpecXValue, ChartSpecYValue, diff --git a/packages/octane-charts/src/types.type-test.ts b/packages/octane-charts/src/types.type-test.ts index 1932c63e..af7c388c 100644 --- a/packages/octane-charts/src/types.type-test.ts +++ b/packages/octane-charts/src/types.type-test.ts @@ -55,7 +55,7 @@ if (false) { }>() return points }, - group(_points, point) { + group(_points, { point }) { expectTypeOf(point.xValue).toEqualTypeOf() return [point] }, diff --git a/packages/react-charts-catalog/src/catalog.test.tsx b/packages/react-charts-catalog/src/catalog.test.tsx index 6ae315d4..36115529 100644 --- a/packages/react-charts-catalog/src/catalog.test.tsx +++ b/packages/react-charts-catalog/src/catalog.test.tsx @@ -315,7 +315,12 @@ describe('@tanstack/react-charts-catalog', () => { idPrefix={`responsive-${id}`} />, ) - expect(html, id).toContain('viewBox="0 0 360 ') + const expectedViewportWidth = + id === '84-pinned-nested-chart-tooltip' ? 336 : 360 + expect(html, id).toContain(`viewBox="0 0 ${expectedViewportWidth} `) + if (id === '84-pinned-nested-chart-tooltip') { + expect(html, id).toContain('width:360px') + } } }, 30_000) }) diff --git a/packages/react-charts-catalog/src/index.ts b/packages/react-charts-catalog/src/index.ts index 2d7447a8..fb92c1b6 100644 --- a/packages/react-charts-catalog/src/index.ts +++ b/packages/react-charts-catalog/src/index.ts @@ -442,7 +442,7 @@ export const catalogCases = [ { id: '84-pinned-nested-chart-tooltip', order: 840, - title: 'Pinned penguin tooltip with a nested chart', + title: 'Expanding pinned energy tooltip', family: 'interaction', }, { diff --git a/packages/react-charts/src/Chart.test.tsx b/packages/react-charts/src/Chart.test.tsx index 685dba23..63dc3b71 100644 --- a/packages/react-charts/src/Chart.test.tsx +++ b/packages/react-charts/src/Chart.test.tsx @@ -78,7 +78,7 @@ if (false) { }>() return points }, - group(_points, point) { + group(_points, { point }) { expectTypeOf(point.xValue).toEqualTypeOf() return [point] }, diff --git a/packages/react-native-charts/src/Tooltip.test.ts b/packages/react-native-charts/src/Tooltip.test.ts index 7c5f3d42..32acd618 100644 --- a/packages/react-native-charts/src/Tooltip.test.ts +++ b/packages/react-native-charts/src/Tooltip.test.ts @@ -6,6 +6,8 @@ import type { ChartPoint, ChartScene, ChartTooltipAnchorContext, + ChartTooltipContentContext, + ChartTooltipOptions, } from '@tanstack/charts/types' import { createNativeTooltipContent, @@ -83,6 +85,93 @@ describe('native tooltip model', () => { expect(markup).not.toContain(' { + const points = [point('alpha', 'Alpha', 3)] + const pinnedStates: boolean[] = [] + + const renderPinned = (pinned: boolean) => + renderToStaticMarkup( + React.createElement(NativeChartTooltip, { + scene: scene(points), + width: 100, + height: 60, + points, + pointer: null, + focusSource: 'programmatic', + options: { + content: (_focusedPoints, context) => { + pinnedStates.push(context.pinned) + return { rows: [] } + }, + }, + pinned, + color: '#111827', + resolvePaint: (value) => value, + dismiss: vi.fn(), + }), + ) + + renderPinned(false) + renderPinned(true) + + expect(pinnedStates).toEqual([false, true]) + }) + + it.each(['format', 'formatGroup'] as const)( + 'supplies shared tooltip context to native %s while pinning', + (formatter) => { + const points = [point('alpha', 'Alpha', 3)] + const currentScene = scene(points) + const contexts: ChartTooltipContentContext[] = [] + const resolveText = (context: ChartTooltipContentContext) => { + contexts.push(context) + return context.pinned ? 'Pinned' : 'Transient' + } + const format = ( + _point: ChartPoint, + context: ChartTooltipContentContext, + ) => resolveText(context) + const formatGroup = ( + _points: readonly ChartPoint[], + context: ChartTooltipContentContext, + ) => resolveText(context) + const items: NonNullable< + ChartTooltipOptions['items'] + > = [ + { channel: 'x', label: 'Horizontal' }, + { channel: 'y', label: 'Vertical' }, + ] + const options: ChartTooltipOptions = + formatter === 'format' ? { format, items } : { formatGroup, items } + + expect( + createNativeTooltipContent( + points, + currentScene, + false, + options, + points[0], + ), + ).toBe('Transient') + expect( + createNativeTooltipContent( + points, + currentScene, + true, + options, + points[0], + ), + ).toBe('Pinned') + expect(contexts.map((context) => context.pinned)).toEqual([false, true]) + expect(contexts[1]).toMatchObject({ + xLabel: 'Horizontal', + yLabel: 'Vertical', + }) + expect(contexts[1]?.formatX(1)).toBe('1') + expect(contexts[1]?.formatY(3)).toBe('3') + }, + ) + it('resolves axis anchors and supplies focus context to custom anchors', () => { const points = [point('alpha', 'Alpha', 3), point('beta', 'Beta', 7)] const currentScene = scene(points) @@ -136,7 +225,10 @@ describe('native tooltip model', () => { const focusedPoints = [beta, alpha] const currentScene = scene(focusedPoints) const format = vi.fn( - (focused: ChartPoint) => focused.key, + ( + focused: ChartPoint, + _context: ChartTooltipContentContext, + ) => focused.key, ) const anchor = vi.fn( ( @@ -168,7 +260,10 @@ describe('native tooltip model', () => { ) expect(renderedPoints).toEqual([alpha, beta]) - expect(format).toHaveBeenCalledWith(beta) + expect(format).toHaveBeenCalledWith( + beta, + expect.objectContaining({ pinned: true }), + ) expect(anchor).toHaveBeenCalledWith([alpha, beta], { focus: { primary: beta, diff --git a/packages/react-native-charts/src/Tooltip.tsx b/packages/react-native-charts/src/Tooltip.tsx index ba79ce0a..ae6b96bf 100644 --- a/packages/react-native-charts/src/Tooltip.tsx +++ b/packages/react-native-charts/src/Tooltip.tsx @@ -10,9 +10,11 @@ import type { ChartPoint, ChartFocusSource, ChartScene, + ChartTooltipChannelItem, ChartTooltipContent, ChartTooltipContentContext, ChartTooltipExtensionToken, + ChartTooltipItem, ChartTooltipOptions, ChartTooltipPlacement, ChartTooltipPosition, @@ -80,7 +82,13 @@ export function NativeChartTooltip< ) const point = unorderedPoints[0] if (!point) return null - const content = createNativeTooltipContent(points, scene, options, point) + const content = createNativeTooltipContent( + points, + scene, + pinned, + options, + point, + ) const sceneAnchor = resolveNativeTooltipAnchor( point, points, @@ -214,16 +222,18 @@ export function createNativeTooltipContent< >( points: readonly ChartPoint[], scene: ChartScene, + pinned = false, options?: ChartTooltipOptions, primaryPoint?: ChartPoint, ): ChartTooltipContent | string { const point = points[0] if (!point) return { rows: [] } - const context = createTooltipContentContext(scene) + const context = createTooltipContentContext(scene, pinned, options) const content = options?.content?.(points, context) if (content !== undefined) return content const formatted = - options?.formatGroup?.(points) ?? options?.format?.(primaryPoint ?? point) + options?.formatGroup?.(points, context) ?? + options?.format?.(primaryPoint ?? point, context) if (formatted !== undefined) return formatted const sharedX = @@ -252,15 +262,40 @@ export function createNativeTooltipContent< function createTooltipContentContext( scene: ChartScene, + pinned: boolean, + options?: ChartTooltipOptions, ): ChartTooltipContentContext { + const x = findTooltipChannelItem(options?.items, 'x') + const y = findTooltipChannelItem(options?.items, 'y') return { - xLabel: findSceneLabel(scene, 'x-label') ?? 'x', - yLabel: findSceneLabel(scene, 'y-label') ?? 'y', + pinned, + xLabel: x?.label ?? findSceneLabel(scene, 'x-label') ?? 'x', + yLabel: y?.label ?? findSceneLabel(scene, 'y-label') ?? 'y', formatX: formatValue, formatY: formatValue, } } +function findTooltipChannelItem( + items: readonly ChartTooltipItem[] | undefined, + channel: 'x' | 'y', +): 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 orderTooltipPoints< TDatum, TXValue extends ChartValue, diff --git a/packages/react-native-charts/src/interaction.ts b/packages/react-native-charts/src/interaction.ts index 6f1ae59d..337ca3f5 100644 --- a/packages/react-native-charts/src/interaction.ts +++ b/packages/react-native-charts/src/interaction.ts @@ -45,7 +45,7 @@ export function createNativeChartFocusModel< const spatialIndex = definition.focus === false ? undefined - : definition.spatialIndex?.(scene.points, scene) + : definition.spatialIndex?.(scene.points, { scene }) const maxDistance = definition.maxFocusDistance ?? 48 const navigation = strategy?.navigation(scene.points) ?? sceneOrder(scene.points) @@ -53,7 +53,7 @@ export function createNativeChartFocusModel< return { resolve(x, y) { if (strategy) { - return strategy.resolve(scene.points, x, y, maxDistance) + return strategy.resolve(scene.points, { x, y, maxDistance }) } const point = spatialIndex ? spatialIndex.findNearest(x, y, maxDistance) @@ -61,7 +61,7 @@ export function createNativeChartFocusModel< return point ? [point] : [] }, group(point) { - return strategy?.group(scene.points, point) ?? [point] + return strategy?.group(scene.points, { point }) ?? [point] }, navigation, restore(point) { diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index a8fc5527..d4aafb8b 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -817,15 +817,24 @@ async function verifyEsmRuntime() { assert.ok(Object.keys(module).length > 0, specifier) } if (specifier.startsWith('@tanstack/react-charts-catalog/cases/')) { + const caseId = specifier.slice(specifier.lastIndexOf('/') + 1) const html = renderToStaticMarkup( createElement(module.default, { initialWidth: 480, height: 270, - idPrefix: specifier.slice(specifier.lastIndexOf('/') + 1), + idPrefix: caseId, }), ) + const expectedViewportWidth = + caseId === '84-pinned-nested-chart-tooltip' ? 456 : 480 assert.match(html, / context.formatY(groupPoint.yValue)) + .join(', ') + }, }, }) compactScalePoint().domain(rows.map((row) => row.category)) @@ -1350,7 +1368,7 @@ async function verifyDeclarations() { }) const numericFocus: ChartFocusStrategy = { resolve: (points) => points, - group: (_points, point) => [point], + group: (_points, { point }) => [point], navigation: (points) => points, } const numericRenderer: ChartSvgRenderer = () => '' diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 8c8ad161..9fedb322 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -1959,16 +1959,17 @@ async function compareVisuals( const style = getComputedStyle(element) if (element.localName === 'line') return style.stroke if (style.fill && style.fill !== 'none') { - const patternId = style.fill.match( + const referenceId = style.fill.match( /url\((?:["'])?#([^"')]+)(?:["'])?\)/, )?.[1] - const patternPaint = patternId - ? element.ownerDocument - .getElementById(patternId) - ?.querySelector('rect, path') + const referencedElement = referenceId + ? element.ownerDocument.getElementById(referenceId) : undefined - return patternPaint - ? getComputedStyle(patternPaint).fill + const patternPaint = referencedElement?.querySelector('rect, path') + if (patternPaint) return getComputedStyle(patternPaint).fill + const gradientStop = referencedElement?.querySelector('stop') + return gradientStop + ? getComputedStyle(gradientStop).getPropertyValue('stop-color') : style.fill } return style.stroke diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index efdb0564..5b2b674e 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -451,7 +451,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 18.6, + 18.7, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -595,7 +595,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 42.3, + 42.5, ), locked( 'Custom-scale line scene', @@ -646,12 +646,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 26.5, + 26.7, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 31.8, + 31.9, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs new file mode 100644 index 00000000..45310c1f --- /dev/null +++ b/scripts/public-callback-contract.mjs @@ -0,0 +1,1035 @@ +import { readFile, readdir } from 'node:fs/promises' +import { relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +export const callbackCategories = Object.freeze({ + callback: 'callback', + comparator: 'comparator', + pairedGeometry: 'paired-geometry', + upstreamProtocol: 'upstream-protocol', + serviceMethod: 'service-method', +}) + +const callbackInventory = { + // Application callbacks: zero or one primary value, plus an optional object + // named `context` or `options`. + callback: [ + ['@tanstack/alpine-charts:src/index.ts:ChartOptions', 'renderTooltipBody'], + [ + '@tanstack/charts:src/canvas.ts:CanvasChartRenderer', + 'mount.requestRender', + ], + [ + '@tanstack/charts:src/canvas.ts:UniversalCanvasChartRenderer', + 'mount.requestRender', + ], + [ + '@tanstack/charts:src/area-x.ts:AreaXOptions', + 'color fill key stroke x x1 x2 y z', + ], + [ + '@tanstack/charts:src/area.ts:AreaYOptions', + 'color fill key stroke x y y1 y2 z', + ], + [ + '@tanstack/charts:src/arrow.ts:ArrowOptions', + 'color key stroke x1 x2 y1 y2 z', + ], + ['@tanstack/charts:src/band.ts:BandXOptions', 'color fill key x z'], + ['@tanstack/charts:src/band.ts:BandYOptions', 'color fill key y z'], + ['@tanstack/charts:src/bar.ts:BarXOptions', 'color fill key x x1 x2 y z'], + ['@tanstack/charts:src/bar.ts:BarYOptions', 'color fill key x y y1 y2 z'], + [ + '@tanstack/charts:src/dom-types.ts:ChartHostCommonOptions', + 'measureText onFocusChange onFocusGroupChange onRender onSelect renderSvg', + ], + [ + '@tanstack/charts:src/dom-types.ts:ChartRendererHostCommonOptions', + 'measureText onFocusChange onFocusGroupChange onRender onSelect onTooltipBodyChange', + ], + ['@tanstack/charts:src/dom-types.ts:ChartRenderer', 'mount.requestRender'], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipExtensionContext', + 'bodyChange.$return', + ], + ['@tanstack/charts:src/dot.ts:DotOptions', 'color key r x y z'], + ['@tanstack/charts:src/facet.ts:FacetOptions', 'by chart label'], + [ + '@tanstack/charts:src/focus-disabled.ts:UniversalChartFocusStrategy', + 'group navigation resolve', + ], + ['@tanstack/charts:src/focus.ts:axisFocus', 'group navigation resolve'], + ['@tanstack/charts:src/geo.ts:GeoProjectionDescriptor', 'type'], + ['@tanstack/charts:src/geo.ts:GeoProjectionInput', '$call'], + [ + '@tanstack/charts:src/geo.ts:GeoShapeOptions', + 'anchor color fill key projection r stroke', + ], + [ + '@tanstack/charts:src/hexagon.ts:HexagonOptions', + 'color fill key r stroke x y z', + ], + ['@tanstack/charts:src/legend.ts:ColorGradientLegendOptions', 'format'], + ['@tanstack/charts:src/legend.ts:ColorLegendOptions', 'format'], + ['@tanstack/charts:src/line.ts:LineYOptions', 'color key stroke x y z'], + [ + '@tanstack/charts:src/link.ts:LinkOptions', + 'color key stroke strokeOpacity strokeWidth x1 x2 y1 y2 z', + ], + ['@tanstack/charts:src/polar.ts:AngleGridOptions', 'format'], + ['@tanstack/charts:src/polar.ts:PolarGuide', 'render'], + ['@tanstack/charts:src/polar.ts:PolarGuideLabelOption', '$call'], + [ + '@tanstack/charts:src/polar.ts:PolarGuideStyle', + 'labelAnchor labelBaseline labelDx labelDy labelRotate', + ], + ['@tanstack/charts:src/polar.ts:PolarLength', '$call'], + ['@tanstack/charts:src/polar.ts:PolarMark', 'initialize motion'], + ['@tanstack/charts:src/polar.ts:InitializedPolarMark', 'motion render'], + [ + '@tanstack/charts:src/polar.ts:RadialArcOptions', + 'color cornerRadius endAngle fill generator innerRadius key outerRadius padAngle padRadius startAngle stroke z', + ], + [ + '@tanstack/charts:src/polar.ts:RadialAreaOptions', + 'curve fill radius1 stroke', + ], + ['@tanstack/charts:src/polar.ts:RadialDotOptions', 'fill r'], + ['@tanstack/charts:src/polar.ts:RadialGridOptions', 'format'], + ['@tanstack/charts:src/polar.ts:RadialLineOptions', 'curve stroke'], + [ + '@tanstack/charts:src/polar.ts:RadialPathOptions', + 'angle color key radius z', + ], + [ + '@tanstack/charts:src/polar.ts:RadialRuleOptions', + 'angle color key radius1 radius2 stroke z', + ], + [ + '@tanstack/charts:src/polar.ts:RadialTextOptions', + 'anchor baseline dx dy fill rotate text', + ], + ['@tanstack/charts:src/rect.ts:RectOptions', 'color key x x1 x2 y y1 y2 z'], + ['@tanstack/charts:src/rule.ts:RuleXOptions', 'color stroke x'], + ['@tanstack/charts:src/rule.ts:RuleYOptions', 'color stroke y'], + [ + '@tanstack/charts:src/text.ts:TextOptions', + 'anchor color dx dy fill key rotate text x y z', + ], + ['@tanstack/charts:src/tick.ts:TickXOptions', 'color key stroke x y z'], + ['@tanstack/charts:src/tick.ts:TickYOptions', 'color key stroke x y z'], + ['@tanstack/charts:src/transform-bin-time.ts:BinTimeOptions', 'value'], + ['@tanstack/charts:src/transform-bin-xy.ts:BinXYOptions', 'x y'], + ['@tanstack/charts:src/transform-bin.ts:BinOptionsBase', 'value'], + ['@tanstack/charts:src/transform-normalize.ts:NormalizeOptions', 'basis'], + ['@tanstack/charts:src/transform-rank.ts:RankOptions', 'value'], + ['@tanstack/charts:src/transform-reduce.ts:quantile', '$call.$return'], + ['@tanstack/charts:src/transform-reduce.ts:TransformOutputSpec', 'reduce'], + ['@tanstack/charts:src/transform-reduce.ts:TransformOutputSpec', 'value'], + ['@tanstack/charts:src/transform-reduce.ts:TransformReducer', '$call'], + ['@tanstack/charts:src/transform-select.ts:SelectOptions', 'select value'], + ['@tanstack/charts:src/transform.ts:TransformAccessor', '$call'], + ['@tanstack/charts:src/transform.ts:TransformOrderOptions', 'orderBy'], + ['@tanstack/charts:src/types.ts:ChannelAccessor', '$call'], + ['@tanstack/charts:src/types.ts:ChartAnimationOptions', 'easing'], + ['@tanstack/charts:src/types.ts:ChartAxisLabelOptions', 'motion'], + ['@tanstack/charts:src/types.ts:ChartAxisPresentationOptions', 'motion'], + ['@tanstack/charts:src/types.ts:ChartAxisTickLabelOptions', 'motion'], + ['@tanstack/charts:src/types.ts:ChartAxisTickOptions', 'format motion'], + ['@tanstack/charts:src/types.ts:ChartColorLegend', 'height render'], + ['@tanstack/charts:src/types.ts:ChartColorScale', 'resolve'], + [ + '@tanstack/charts:src/types.ts:ChartDefinitionOptions', + 'motion spatialIndex', + ], + [ + '@tanstack/charts:src/types.ts:ChartFocusStrategy', + 'group navigation resolve', + ], + ['@tanstack/charts:src/types.ts:ChartLayoutOptions', 'measureText'], + ['@tanstack/charts:src/types.ts:ChartMark', 'initialize motion'], + ['@tanstack/charts:src/types.ts:ChartMarkMotionOptions', 'motion'], + ['@tanstack/charts:src/types.ts:ChartMarkState', 'when'], + [ + '@tanstack/charts:src/types.ts:ChartMarkStateStyle', + 'dx dy fill fillOpacity fontSize fontWeight inset opacity r radius rotate stroke strokeDasharray strokeOpacity strokeWidth', + ], + ['@tanstack/charts:src/types.ts:ChartMarkStateValue', '$call'], + ['@tanstack/charts:src/types.ts:ChartMotionDefinition', '$call'], + ['@tanstack/charts:src/types.ts:ChartMotionTweenTransition', 'easing'], + ['@tanstack/charts:src/types.ts:ChartScale', 'resolve'], + ['@tanstack/charts:src/types.ts:ChartScaleResolver', '$call'], + ['@tanstack/charts:src/types.ts:ChartSpatialIndexFactory', '$call'], + ['@tanstack/charts:src/types.ts:ChartSvgRenderer', '$call'], + ['@tanstack/charts:src/types.ts:ChartTextMeasurer', '$call'], + ['@tanstack/charts:src/types.ts:ChartTooltipAnchor', '$call'], + ['@tanstack/charts:src/types.ts:ChartTooltipDerivedItem', 'text'], + ['@tanstack/charts:src/types.ts:ChartTooltipItemBase', 'text'], + [ + '@tanstack/charts:src/types.ts:ChartTooltipOptions', + 'anchor content format formatGroup', + ], + ['@tanstack/charts:src/types.ts:DynamicChartConfig', 'chart'], + ['@tanstack/charts:src/types.ts:DynamicChartDefinition', 'chart'], + ['@tanstack/charts:src/types.ts:InitializedMark', 'layoutLabels render'], + [ + '@tanstack/charts:src/mark-with-scale-values.ts:createMarkWithScaleValues', + '$call.initialize $call.motion', + ], + [ + '@tanstack/charts:src/mark.ts:createMark', + '$call.initialize $call.motion', + ], + ['@tanstack/charts:src/reconcile.ts:reconcileChartSvg', '$call.$return'], + ['@tanstack/charts:src/scene.ts:defineChart', '$call.chart'], + [ + '@tanstack/charts:src/svg-surface.ts:createSvgChartRenderer', + '$call.renderSvg', + ], + [ + '@tanstack/charts:src/types.ts:StoredChartDefinitionOptions', + 'motion spatialIndex', + ], + [ + '@tanstack/charts:src/vector.ts:VectorOptions', + 'color key length rotate stroke x y z', + ], + [ + '@tanstack/lit-charts:src/types.ts:ChartPresentationProps', + 'renderTooltipBody', + ], + [ + '@tanstack/octane-charts:src/renderer-types.ts:RendererChartCommonProps', + 'measureText onFocusChange onFocusGroupChange onRender onSelect renderTooltipBody', + ], + [ + '@tanstack/octane-charts:src/types.ts:ChartCommonProps', + 'measureText onFocusChange onFocusGroupChange onRender onSelect renderSvg renderTooltipBody', + ], + [ + '@tanstack/preact-charts:src/types.ts:ChartTooltipBodyRenderProps', + 'renderTooltipBody', + ], + [ + '@tanstack/react-charts:src/Chart.tsx:ChartCommonProps', + 'measureText onFocusChange onFocusGroupChange onRender onSelect renderSvg', + ], + [ + '@tanstack/react-charts:src/RendererChart.tsx:RendererChartCommonProps', + 'measureText onFocusChange onFocusGroupChange onRender onSelect', + ], + [ + '@tanstack/react-charts:src/tooltip.tsx:ChartTooltipBodyRenderProps', + 'renderTooltipBody', + ], + [ + '@tanstack/react-native-charts:src/Chart.tsx:ChartProps', + 'measureText onFocusChange onFocusGroupChange onRender onSelect renderTooltip resolvePaint', + ], + ['@tanstack/react-native-charts:src/paint.ts:NativePaintResolver', '$call'], + [ + '@tanstack/react-native-charts:src/Tooltip.tsx:NativeChartTooltipProps', + 'render resolvePaint', + ], + [ + '@tanstack/solid-charts:src/types.ts:ChartTooltipBodyRenderProps', + 'renderTooltipBody', + ], + ['@tanstack/svelte-charts:src/types.ts:ChartCommonProps', 'tooltipBody'], + ['@tanstack/svelte-charts:src/types.ts:ChartProps', 'tooltipBody'], + ['@tanstack/vue-charts:src/Chart.ts:ChartComponent', 'tooltipBody'], + ], + // Standard comparators inherently receive the two values being compared. + comparator: [ + ['@tanstack/charts:src/types.ts:ChartTooltipOptions', 'sort'], + ['@tanstack/charts:src/types.ts:ChartTooltipSort', '$call'], + ], + // Curve protocols inherently receive paired geometry collections. + pairedGeometry: [ + ['@tanstack/charts:src/area-x.ts:AreaXCurve', 'areaX'], + ['@tanstack/charts:src/types.ts:ChartCurve', 'area line'], + ], + // Exact D3-compatible protocols retain their upstream positional shape. + upstreamProtocol: [ + [ + '@tanstack/alpine-charts:src/index.ts:AlpineDirectiveUtilities', + 'cleanup cleanup.callback effect effect.callback evaluateLater evaluateLater.$return evaluateLater.receiver', + ], + [ + '@tanstack/alpine-charts:src/index.ts:AlpineLike', + 'directive directive.callback', + ], + [ + '@tanstack/charts-scales:src/band-kernel.ts:BandScale', + '$call align align.$return bandwidth copy copy.$return domain domain.$return padding padding.$return paddingInner paddingInner.$return paddingOuter paddingOuter.$return range range.$return rangeRound rangeRound.$return round round.$return step', + ], + [ + '@tanstack/charts-scales:src/band-kernel.ts:PointScale', + '$call align align.$return bandwidth copy copy.$return domain domain.$return padding padding.$return range range.$return rangeRound rangeRound.$return round round.$return step', + ], + [ + '@tanstack/charts-scales:src/linear.ts:LinearScale', + '$call clamp clamp.$return copy copy.$return domain domain.$return invert nice nice.$return range range.$return tickFormat tickFormat.$return ticks', + ], + [ + '@tanstack/charts-scales:src/ordinal.ts:OrdinalScale', + '$call copy copy.$return domain domain.$return range range.$return unknown unknown.$return', + ], + ['@tanstack/charts-scales:src/band.ts:scaleBand', '$call.$return'], + ['@tanstack/charts-scales:src/linear.ts:scaleLinear', '$call.$return'], + ['@tanstack/charts-scales:src/ordinal.ts:scaleOrdinal', '$call.$return'], + ['@tanstack/charts-scales:src/point.ts:scalePoint', '$call.$return'], + ['@tanstack/charts:src/d3-area-x.ts:d3AreaXCurve', '$call.curve'], + ['@tanstack/charts:src/d3-shape.ts:d3Curve', '$call.curve'], + ['@tanstack/charts:src/geo.ts:GeoProjectionDescriptor', 'type.$return'], + ['@tanstack/charts:src/geo.ts:GeoProjectionInput', '$call.$return'], + [ + '@tanstack/charts:src/transform-bin-time.ts:TimeIntervalLike', + 'floor offset range', + ], + ['@tanstack/charts:src/transform-bin.ts:BinOptionsBase', 'thresholds'], + ['@tanstack/charts:src/dot.ts:DotOptions', 'rScale'], + ['@tanstack/charts:src/geo.ts:GeoShapeOptions', 'rScale'], + ['@tanstack/charts:src/group.ts:GroupLayout', 'scale'], + ['@tanstack/charts:src/group.ts:GroupOptions', 'scale'], + ['@tanstack/charts:src/hexagon.ts:HexagonOptions', 'rScale'], + ['@tanstack/charts:src/polar.ts:RadialDotOptions', 'rScale'], + ['@tanstack/charts:src/polar.ts:RadialArcOptions', 'generator.$return'], + ['@tanstack/charts:src/types.ts:ChartAxisOptions', 'scale'], + ['@tanstack/charts:src/types.ts:ChartColorOptions', 'scale'], + ['@tanstack/charts:src/types.ts:ChartNumericScale', '$call'], + ['@tanstack/charts:src/types.ts:ChartNumericScaleOptions', 'scale'], + [ + '@tanstack/charts:src/types.ts:ConfiguredColorScaleLike', + '$call copy copy.$return domain range', + ], + [ + '@tanstack/charts:src/types.ts:ConfiguredScaleLike', + '$call bandwidth copy copy.$return domain range range.$return tickFormat tickFormat.$return ticks', + ], + [ + '@tanstack/charts:src/types.ts:InferableColorScaleLike', + '$call.$return domain invertExtent nice nice.$return quantiles range thresholds ticks', + ], + [ + '@tanstack/charts:src/types.ts:InferableScaleLike', + '$call.$return domain', + ], + ], + // Consumer-called handles and lifecycle/service protocols are methods, not + // application callbacks. + serviceMethod: [ + [ + '@tanstack/angular-charts:src/Chart.ts:Chart', + 'ngAfterViewInit ngOnChanges ngOnDestroy', + ], + [ + '@tanstack/angular-charts:src/ChartTooltipBody.ts:ChartTooltipBodyDirective', + 'ngTemplateContextGuard', + ], + [ + '@tanstack/charts:src/adapter-shared.ts:ChartAdapter', + 'destroy getScene mount prerender update', + ], + [ + '@tanstack/charts:src/canvas.ts:CanvasChartHost', + 'destroy getScene update', + ], + ['@tanstack/charts:src/canvas.ts:CanvasChartRenderer', 'mount'], + [ + '@tanstack/charts:src/canvas.ts:UniversalCanvasChartRenderer', + 'mount prerender', + ], + ['@tanstack/charts:src/dom-types.ts:ChartHost', 'destroy getScene update'], + ['@tanstack/charts:src/dom-types.ts:ChartRenderer', 'mount prerender'], + [ + '@tanstack/charts:src/dom-types.ts:ChartRendererHost', + 'destroy getScene update', + ], + [ + '@tanstack/charts:src/dom-types.ts:ChartSurface', + 'clientToScene destroy getPresentationPoints paintFocus render', + ], + ['@tanstack/charts:src/dom-types.ts:ChartTooltipExtension', 'create'], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipExtensionContext', + 'bodyChange dismiss', + ], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipExtensionInstance', + 'contains destroy hide paint update', + ], + ['@tanstack/charts:src/dom-types.ts:ChartTooltipPortalExtension', 'create'], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipPortalExtensionContext', + 'schedulePosition', + ], + [ + '@tanstack/charts:src/dom-types.ts:ChartTooltipPortalExtensionInstance', + 'destroy hide position update', + ], + ['@tanstack/charts:src/polar.ts:PolarResolvedScale', 'map ticks'], + ['@tanstack/charts:src/polar.ts:PolarMarkRenderContext', 'color'], + ['@tanstack/charts:src/spring.ts:ChartSpring', 'sample'], + ['@tanstack/charts:src/types.ts:ChartMarkStateContext', 'matches'], + ['@tanstack/charts:src/types.ts:ChartRuntime', 'destroy render'], + ['@tanstack/charts:src/types.ts:ChartSpatialIndex', 'findNearest'], + ['@tanstack/charts:src/types.ts:ChartTooltipBodyContext', 'dismiss'], + [ + '@tanstack/charts:src/types.ts:ChartTooltipContentContext', + 'formatX formatY', + ], + ['@tanstack/charts:src/types.ts:MarkRenderContext', 'color'], + ['@tanstack/charts:src/types.ts:ResolvedColorScale', 'map'], + ['@tanstack/charts:src/types.ts:ResolvedScale', 'map'], + [ + '@tanstack/lit-charts:src/Chart.ts:Chart', + 'connectedCallback disconnectedCallback', + ], + [ + '@tanstack/react-native-charts:src/Tooltip.tsx:NativeChartTooltipComponent', + '$call', + ], + [ + '@tanstack/react-native-charts:src/Tooltip.tsx:NativeChartTooltipExtension', + 'create create.$return', + ], + [ + '@tanstack/react-native-charts:src/Tooltip.tsx:NativeChartTooltipProps', + 'dismiss', + ], + [ + '@tanstack/react-native-charts:src/Tooltip.tsx:NativeChartTooltipRenderContext', + 'dismiss', + ], + [ + '@tanstack/svelte-charts:src/types.ts:ChartTooltipBodySnippetContext', + 'defaultBody', + ], + [ + '@tanstack/vue-charts:src/types.ts:ChartTooltipBodySlotContext', + 'defaultBody', + ], + ], +} + +export const publicCallbackClassifications = + createClassifications(callbackInventory) + +/** + * Inspect function-valued members reachable through every source export of + * every published workspace package. Properties inherited from another + * package are owned and checked by the package that declares them. + */ +export async function inspectPublicCallableSurfaces(repositoryRoot) { + const packagesRoot = resolve(repositoryRoot, 'packages') + const packages = [] + + for (const entry of await readdir(packagesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const root = resolve(packagesRoot, entry.name) + const manifestPath = resolve(root, 'package.json') + let manifest + try { + manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') continue + throw error + } + if (manifest.private === true || !manifest.exports) continue + packages.push({ + name: manifest.name, + root, + entries: [...new Set(exportSourcePaths(root, manifest.exports))], + }) + } + + const configPath = resolve(repositoryRoot, 'tsconfig.json') + const config = ts.readConfigFile(configPath, ts.sys.readFile) + if (config.error) { + throw new Error(formatDiagnostic(config.error)) + } + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + repositoryRoot, + undefined, + configPath, + ) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(formatDiagnostic).join('\n')) + } + + const program = ts.createProgram({ + rootNames: [ + ...new Set([ + ...parsed.fileNames, + ...packages.flatMap((workspacePackage) => workspacePackage.entries), + ]), + ], + options: parsed.options, + }) + const checker = program.getTypeChecker() + const surfaces = [] + + for (const workspacePackage of packages) { + const exportedSymbols = new Map() + for (const entryPath of workspacePackage.entries) { + const sourceFile = program.getSourceFile(entryPath) + const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile) + if (!moduleSymbol) continue + for (const exportedSymbol of checker.getExportsOfModule(moduleSymbol)) { + const symbol = resolveAlias(checker, exportedSymbol) + const names = exportedSymbols.get(symbol) ?? new Set() + names.add(exportedSymbol.name) + exportedSymbols.set(symbol, names) + } + } + + const packageSurfaces = new Map() + for (const [symbol, exportedNames] of exportedSymbols) { + const exportedThrough = [...exportedNames].sort() + const seenTypes = new Set() + for (const { publicType, recordDirectCall } of exportedSymbolTypes( + checker, + symbol, + )) { + const fallbackOwner = symbol.name + + if (recordDirectCall) { + for (const signature of localCallableSignatures( + publicType, + workspacePackage.root, + )) { + const declaration = signature.getDeclaration() + addSurface(packageSurfaces, { + checker, + declaration, + exportedThrough, + member: '$call', + owner: declarationOwner(declaration, fallbackOwner), + package: workspacePackage, + repositoryRoot, + signature, + }) + } + } + + visitReachableType({ + checker, + exportedThrough, + fallbackOwner, + package: workspacePackage, + repositoryRoot, + seenTypes, + surfaces: packageSurfaces, + type: publicType, + }) + } + } + surfaces.push(...packageSurfaces.values()) + } + + return surfaces.sort((left, right) => left.id.localeCompare(right.id)) +} + +function exportedSymbolTypes(checker, symbol) { + const types = new Map() + if (symbol.flags & ts.SymbolFlags.Type) { + try { + const type = checker.getDeclaredTypeOfSymbol(symbol) + types.set(type, { + publicType: type, + recordDirectCall: Boolean( + symbol.flags & (ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias), + ), + }) + } catch { + // Some merged or synthetic symbols do not have a declared type side. + } + } + if (symbol.flags & ts.SymbolFlags.Value) { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] + if (declaration) { + try { + const type = checker.getTypeOfSymbolAtLocation(symbol, declaration) + const existing = types.get(type) + types.set(type, { + publicType: type, + recordDirectCall: existing?.recordDirectCall ?? false, + }) + } catch { + // A type-only export can have value flags through declaration merging. + } + } + } + return types.values() +} + +function visitReachableType(state) { + const { + checker, + exportedThrough, + fallbackOwner, + package: workspacePackage, + repositoryRoot, + seenTypes, + surfaces, + type, + } = state + if (!type || seenTypes.has(type)) return + seenTypes.add(type) + + for (const signature of localCallableSignatures( + type, + workspacePackage.root, + )) { + visitSignatureTypes(signature, state) + } + for (const signature of type.getConstructSignatures()) { + const declaration = signature.getDeclaration() + if (!declaration || !isLocalDeclaration(declaration, workspacePackage)) { + continue + } + visitSignatureTypes(signature, state) + } + + for (const property of checker.getPropertiesOfType(type)) { + const declaration = property.valueDeclaration ?? property.declarations?.[0] + if ( + !declaration || + !isLocalDeclaration(declaration, workspacePackage) || + !isPublicDeclaration(declaration) + ) { + continue + } + let propertyType + try { + propertyType = checker.getTypeOfSymbolAtLocation(property, declaration) + } catch { + continue + } + for (const signature of callableSignatures(propertyType)) { + addSurface(surfaces, { + checker, + declaration, + exportedThrough, + member: property.name, + owner: declarationOwner(declaration, fallbackOwner), + package: workspacePackage, + repositoryRoot, + signature, + }) + } + visitReachableType({ ...state, type: propertyType }) + } + + if (type.isUnionOrIntersection()) { + for (const constituent of type.types) { + visitReachableType({ ...state, type: constituent }) + } + } +} + +function visitSignatureTypes(signature, state) { + const { + checker, + exportedThrough, + fallbackOwner, + package: workspacePackage, + repositoryRoot, + surfaces, + } = state + const declaration = signature.getDeclaration() + if (!declaration) return + + for (const parameter of signature.parameters) { + const parameterDeclaration = + parameter.valueDeclaration ?? parameter.declarations?.[0] ?? declaration + let parameterType + try { + parameterType = checker.getTypeOfSymbolAtLocation( + parameter, + parameterDeclaration, + ) + } catch { + continue + } + const nestedSignatures = callableSignatures(parameterType) + if (nestedSignatures.length > 0) { + const member = `${callableMemberPath(declaration)}.${parameter.name}` + for (const nestedSignature of nestedSignatures) { + addSurface(surfaces, { + checker, + declaration: parameterDeclaration, + exportedThrough, + member, + owner: declarationOwner(parameterDeclaration, fallbackOwner), + package: workspacePackage, + repositoryRoot, + signature: nestedSignature, + }) + } + } + visitReachableType({ ...state, type: parameterType }) + } + + const returnType = checker.getReturnTypeOfSignature(signature) + const returnedSignatures = callableSignatures(returnType) + if (returnedSignatures.length > 0) { + const member = `${callableMemberPath(declaration)}.$return` + for (const returnedSignature of returnedSignatures) { + addSurface(surfaces, { + checker, + declaration, + exportedThrough, + member, + owner: declarationOwner(declaration, fallbackOwner), + package: workspacePackage, + repositoryRoot, + signature: returnedSignature, + }) + } + } + visitReachableType({ ...state, type: returnType }) +} + +function localCallableSignatures(type, packageRoot) { + return callableSignatures(type).filter((signature) => { + const declaration = signature.getDeclaration() + return ( + declaration && isInside(declaration.getSourceFile().fileName, packageRoot) + ) + }) +} + +function isLocalDeclaration(declaration, workspacePackage) { + return isInside(declaration.getSourceFile().fileName, workspacePackage.root) +} + +function isPublicDeclaration(declaration) { + const modifiers = ts.getCombinedModifierFlags(declaration) + return !(modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) +} + +function callableMemberPath(declaration) { + if ( + ts.isPropertySignature(declaration) || + ts.isPropertyDeclaration(declaration) || + ts.isPropertyAssignment(declaration) || + ts.isMethodSignature(declaration) || + ts.isMethodDeclaration(declaration) + ) { + return declarationName(declaration.name) ?? '$call' + } + if ( + ts.isCallSignatureDeclaration(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isArrowFunction(declaration) + ) { + return '$call' + } + if (ts.isConstructSignatureDeclaration(declaration)) return '$construct' + if (ts.isFunctionTypeNode(declaration)) { + for (let current = declaration.parent; current; current = current.parent) { + if ( + ts.isPropertySignature(current) || + ts.isPropertyDeclaration(current) || + ts.isMethodSignature(current) || + ts.isMethodDeclaration(current) + ) { + return declarationName(current.name) ?? '$call' + } + if ( + ts.isCallSignatureDeclaration(current) || + ts.isFunctionDeclaration(current) || + ts.isTypeAliasDeclaration(current) + ) { + return '$call' + } + } + } + return '$call' +} + +function declarationName(name) { + if (!name) return undefined + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text + if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) { + return name.text + } + return undefined +} + +export function validatePublicCallableSurfaces( + surfaces, + classifications = publicCallbackClassifications, +) { + const failures = [] + const observed = new Set() + + for (const surface of surfaces) { + const category = classifications.get(surface.id) + observed.add(surface.id) + if (!category) { + failures.push( + `${surface.id} is an unclassified public callable surface (${surface.location})`, + ) + continue + } + + if ( + category === callbackCategories.upstreamProtocol || + category === callbackCategories.serviceMethod + ) { + continue + } + + for (const parameters of surface.signatures) { + if (parameters.length > 2) { + failures.push( + `${surface.id} has ${parameters.length} positional arguments; public callbacks accept at most two`, + ) + continue + } + if ( + parameters.length === 2 && + category !== callbackCategories.comparator && + category !== callbackCategories.pairedGeometry + ) { + const context = parameters[1] + if (!['context', 'options'].includes(context.name)) { + failures.push( + `${surface.id} names its second argument ${context.name}; use context or options`, + ) + } + if (!context.objectBag) { + failures.push( + `${surface.id} must use an object context/options bag for its second argument`, + ) + } + } + } + } + + for (const id of classifications.keys()) { + if (!observed.has(id)) { + failures.push(`${id} is classified but no longer exported`) + } + } + + return failures.sort() +} + +export async function publicCallbackContractFailures(repositoryRoot) { + return validatePublicCallableSurfaces( + await inspectPublicCallableSurfaces(repositoryRoot), + ) +} + +export function createClassifications(inventory) { + const classifications = new Map() + for (const [categoryName, groups] of Object.entries(inventory)) { + const category = callbackCategories[categoryName] + if (!category) + throw new TypeError(`Unknown callback category ${categoryName}`) + for (const [prefix, members] of groups) { + for (const member of members.split(/\s+/).filter(Boolean)) { + const id = `${prefix}.${member}` + if (classifications.has(id)) { + throw new TypeError(`Duplicate callback classification ${id}`) + } + classifications.set(id, category) + } + } + } + return classifications +} + +function exportSourcePaths(packageRoot, exports) { + const paths = [] + for (const target of Object.values(exports)) { + const source = resolveExportSource(target) + if (source && /\.[cm]?[jt]sx?$/u.test(source)) { + paths.push(resolve(packageRoot, source)) + } + } + return paths +} + +function resolveExportSource(target) { + if (typeof target === 'string') return target + if (!target || typeof target !== 'object') return undefined + for (const condition of [ + 'types', + 'react-native', + 'svelte', + 'solid', + 'import', + 'default', + ]) { + if (typeof target[condition] === 'string') return target[condition] + } + return undefined +} + +function resolveAlias(checker, symbol) { + if (!(symbol.flags & ts.SymbolFlags.Alias)) return symbol + try { + return checker.getAliasedSymbol(symbol) + } catch { + return symbol + } +} + +function callableSignatures(type, seen = new Set()) { + if (!type || seen.has(type)) return [] + seen.add(type) + const signatures = [...type.getCallSignatures()] + if (type.isUnionOrIntersection()) { + for (const constituent of type.types) { + signatures.push(...callableSignatures(constituent, seen)) + } + } + return uniqueSignatures(signatures) +} + +function uniqueSignatures(signatures) { + const seen = new Set() + return signatures.filter((signature) => { + const declaration = signature.getDeclaration() + const key = declaration + ? `${declaration.getSourceFile().fileName}:${declaration.pos}:${declaration.end}` + : signature.parameters.map((parameter) => parameter.name).join(',') + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function addSurface( + surfaces, + { + checker, + declaration, + exportedThrough, + member, + owner, + package: workspacePackage, + repositoryRoot, + signature, + }, +) { + const sourceFile = declaration.getSourceFile() + const source = slash(relative(workspacePackage.root, sourceFile.fileName)) + const id = `${workspacePackage.name}:${source}:${owner}.${member}` + const position = sourceFile.getLineAndCharacterOfPosition( + declaration.getStart(sourceFile), + ) + const parameters = signature.parameters.map((parameter) => { + const parameterDeclaration = + parameter.valueDeclaration ?? parameter.declarations?.[0] ?? declaration + let type + try { + type = checker.getTypeOfSymbolAtLocation(parameter, parameterDeclaration) + } catch { + return { name: parameter.name, objectBag: false } + } + return { + name: parameter.name, + objectBag: isObjectBag(checker, type), + } + }) + const existing = surfaces.get(id) + if (existing) { + if (!sameParameterListIn(existing.signatures, parameters)) { + existing.signatures.push(parameters) + } + for (const name of exportedThrough) { + if (!existing.exportedThrough.includes(name)) { + existing.exportedThrough.push(name) + } + } + existing.exportedThrough.sort() + return + } + surfaces.set(id, { + id, + packageName: workspacePackage.name, + source, + owner, + member, + exportedThrough, + location: `${slash(relative(repositoryRoot, sourceFile.fileName))}:${position.line + 1}`, + signatures: [parameters], + }) +} + +function sameParameterListIn(signatures, candidate) { + return signatures.some( + (parameters) => + parameters.length === candidate.length && + parameters.every( + (parameter, index) => + parameter.name === candidate[index].name && + parameter.objectBag === candidate[index].objectBag, + ), + ) +} + +function isObjectBag(checker, type, seen = new Set()) { + if (seen.has(type)) return true + seen.add(type) + if (type.isUnion()) { + const values = type.types.filter( + (constituent) => + !(constituent.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)), + ) + return ( + values.length > 0 && + values.every((constituent) => isObjectBag(checker, constituent, seen)) + ) + } + if (type.isIntersection()) { + return type.types.every((constituent) => + isObjectBag(checker, constituent, seen), + ) + } + if (type.flags & ts.TypeFlags.TypeParameter) { + const constraint = checker.getBaseConstraintOfType(type) + return constraint ? isObjectBag(checker, constraint, seen) : false + } + if (!(type.flags & ts.TypeFlags.Object)) return false + if (checker.isArrayType(type) || checker.isTupleType(type)) return false + return type.getCallSignatures().length === 0 +} + +function declarationOwner(declaration, fallback) { + for (let current = declaration.parent; current; current = current.parent) { + if ( + (ts.isInterfaceDeclaration(current) || + ts.isTypeAliasDeclaration(current) || + ts.isClassDeclaration(current) || + ts.isFunctionDeclaration(current)) && + current.name + ) { + return current.name.text + } + } + return fallback +} + +function isInside(file, directory) { + const path = resolve(file) + const root = resolve(directory) + return path === root || path.startsWith(`${root}${sep}`) +} + +function formatDiagnostic(diagnostic) { + return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') +} + +function slash(path) { + return path.split(sep).join('/') +} diff --git a/scripts/public-callback-contract.test.mjs b/scripts/public-callback-contract.test.mjs new file mode 100644 index 00000000..506376b6 --- /dev/null +++ b/scripts/public-callback-contract.test.mjs @@ -0,0 +1,133 @@ +import { resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { + createClassifications, + inspectPublicCallableSurfaces, + validatePublicCallableSurfaces, +} from './public-callback-contract.mjs' + +const repositoryRoot = resolve(import.meta.dirname, '..') + +describe('public callback contract', () => { + let inventory + + beforeAll(async () => { + inventory = await inspectPublicCallableSurfaces(repositoryRoot) + }, 30_000) + + it('classifies every callable surface exported by published packages', () => { + expect(validatePublicCallableSurfaces(inventory)).toEqual([]) + }) + + it('follows exported values into private nested callback types', () => { + const surfaces = new Map(inventory.map((surface) => [surface.id, surface])) + + expect( + surfaces + .get( + '@tanstack/alpine-charts:src/index.ts:AlpineLike.directive.callback', + ) + ?.signatures[0]?.map((parameter) => parameter.name), + ).toEqual(['element', 'directive', 'utilities']) + expect( + surfaces + .get('@tanstack/vue-charts:src/Chart.ts:ChartComponent.tooltipBody') + ?.signatures[0]?.map((parameter) => parameter.name), + ).toEqual(['context']) + }) + + it('rejects unclassified additions and stale classifications', () => { + const classifications = createClassifications({ + callback: [['@scope/pkg:src/types.ts:KnownOptions', 'format missing']], + }) + expect( + validatePublicCallableSurfaces( + [surface('@scope/pkg:src/types.ts:AddedOptions.render', ['value'])], + classifications, + ), + ).toEqual([ + '@scope/pkg:src/types.ts:AddedOptions.render is an unclassified public callable surface (src/types.ts:1)', + '@scope/pkg:src/types.ts:KnownOptions.format is classified but no longer exported', + '@scope/pkg:src/types.ts:KnownOptions.missing is classified but no longer exported', + ]) + }) + + it('limits callbacks to primary data and one object context bag', () => { + const threeArguments = '@scope/pkg:src/types.ts:Options.resolve' + const primitiveContext = '@scope/pkg:src/types.ts:Options.format' + const misnamedContext = '@scope/pkg:src/types.ts:Options.content' + const classifications = createClassifications({ + callback: [['@scope/pkg:src/types.ts:Options', 'resolve format content']], + }) + + expect( + validatePublicCallableSurfaces( + [ + surface(threeArguments, ['points', 'x', 'y']), + surface(primitiveContext, ['value', ['context', false]]), + surface(misnamedContext, ['points', ['state', true]]), + ], + classifications, + ), + ).toEqual([ + `${misnamedContext} names its second argument state; use context or options`, + `${primitiveContext} must use an object context/options bag for its second argument`, + `${threeArguments} has 3 positional arguments; public callbacks accept at most two`, + ]) + }) + + it('accepts context bags and the reviewed exception categories', () => { + const classifications = createClassifications({ + callback: [ + ['@scope/pkg:src/types.ts:Options', 'content format contextOnly'], + ], + comparator: [['@scope/pkg:src/types.ts:Sort', '$call']], + pairedGeometry: [['@scope/pkg:src/types.ts:Curve', 'area']], + upstreamProtocol: [['@scope/pkg:src/types.ts:Interval', 'range']], + serviceMethod: [['@scope/pkg:src/types.ts:Runtime', 'render']], + }) + const surfaces = [ + surface('@scope/pkg:src/types.ts:Options.content', [ + 'points', + ['context', true], + ]), + surface('@scope/pkg:src/types.ts:Options.format', [ + 'point', + ['options', true], + ]), + surface('@scope/pkg:src/types.ts:Options.contextOnly', [ + ['context', true], + ]), + surface('@scope/pkg:src/types.ts:Sort.$call', ['left', 'right']), + surface('@scope/pkg:src/types.ts:Curve.area', ['top', 'bottom']), + surface('@scope/pkg:src/types.ts:Interval.range', [ + 'start', + 'stop', + 'step', + ]), + surface('@scope/pkg:src/types.ts:Runtime.render', [ + 'definition', + 'size', + 'layout', + ]), + ] + + expect(validatePublicCallableSurfaces(surfaces, classifications)).toEqual( + [], + ) + }) +}) + +function surface(id, parameters) { + return { + id, + location: 'src/types.ts:1', + signatures: [ + parameters.map((parameter) => + Array.isArray(parameter) + ? { name: parameter[0], objectBag: parameter[1] } + : { name: parameter, objectBag: false }, + ), + ], + } +}