From b9d8c541ef6a94afdc1812555ee0e4459c81d140 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 16:53:21 -0600 Subject: [PATCH 1/3] Add angular focus for radial charts --- .changeset/calm-radial-focus.md | 5 + API-FRICTION.md | 32 ++- docs/guides/tooltips-and-focus.md | 23 ++ docs/reference/focus-and-interaction.md | 6 + docs/reference/index.md | 2 +- docs/reference/marks/polar.md | 28 ++ .../docs/guides/tooltips-and-focus.md | 23 ++ .../docs/reference/focus-and-interaction.md | 6 + packages/charts-core/docs/reference/index.md | 2 +- .../charts-core/docs/reference/marks/polar.md | 28 ++ packages/charts-core/src/exports.test.ts | 12 + .../charts-core/src/polar-focus-internal.ts | 187 +++++++++++++ .../charts-core/src/polar-sector-internal.ts | 15 +- packages/charts-core/src/polar.test.ts | 63 ++++- packages/charts-core/src/polar.ts | 260 +++++++++++------- scripts/measure-bundles.mjs | 10 +- scripts/public-callback-contract.mjs | 4 + 17 files changed, 592 insertions(+), 114 deletions(-) create mode 100644 .changeset/calm-radial-focus.md create mode 100644 packages/charts-core/src/polar-focus-internal.ts diff --git a/.changeset/calm-radial-focus.md b/.changeset/calm-radial-focus.md new file mode 100644 index 00000000..93ed093f --- /dev/null +++ b/.changeset/calm-radial-focus.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add angular grouped focus for radial charts and geometry-backed arc tooltips. diff --git a/API-FRICTION.md b/API-FRICTION.md index a424e447..7cebda2f 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-11 +Last updated: 2026-08-12 ## Triage rule @@ -307,6 +307,7 @@ Each entry records: | F-268 | Animated arc flags became invalid fractional path values | API | resolved | | F-269 | Angular mounted its browser host during server rendering | API | resolved | | F-270 | Catalog migration left generated release evidence stale | Tooling | resolved | +| F-271 | Radial focus collapsed angular cross-sections to centroids | API | resolved | ## Findings @@ -7954,3 +7955,32 @@ Each entry records: - Verification: the focused roadmap test and catalog preview check pass. The only rendered asset change normalizes synchronized-cursor point keys from timestamp labels to the migrated ISO date labels; geometry is unchanged. + +### F-271 — Radial focus collapsed angular cross-sections to centroids + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: radial tooltip interaction review +- Friction: polar lines, areas, and dots exposed only Cartesian centroid + anchors to the default nearest-point resolver. Moving around one semantic + angle could select a different radius or adjacent angle, and grouped radial + tooltips had no equivalent of `group-x`. `radialArc` also emitted a centroid + without attaching its already available painted boundary, so pie and donut + focus could disagree with the visible slice. +- Decision: export `focusGroupAngle` from the exact polar subpath. It selects + the nearest bounded radial ray, uses radius distance as the primary-point + tie-breaker, groups one point per series at the same semantic angle, and + orders keyboard tasks angularly. Keep the strategy out of ordinary and + universal barrels. Attach the existing D3-replayed interaction boundary to + every `radialArc`, including authored generators. +- Verification: focused polar tests resolve two series from a one-pixel + angular ray while both anchors are farther away, preserve the closest radius + as primary, and reduce keyboard navigation to one task per semantic angle. + Arc tests hit the painted annulus at zero fallback distance, reject its hole, + and reuse the exact point attached to the scene geometry. Polar, radial-bar, + sunburst, callable-surface, documentation, and root TypeScript checks pass. + The measured polar arc is 15.06 KiB gzip, gauge 24.08, radial labels 20.66, + radial bars 24.17, and polar line/scatter 25.28; their isolated ceilings now + record those reviewed interaction costs while the locked representative-mark + bundle remains unchanged at 25.59 KiB gzip. diff --git a/docs/guides/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index 8ebc524c..aa66960b 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -48,6 +48,29 @@ category. A sparse snapped cursor can opt into `maxFocusDistance: Number.POSITIVE_INFINITY`; keep the finite default when empty space should mean no focus. +## Angular focus + +Use `focusGroupAngle` for the radial equivalent of `group-x`: + +```ts +import { defineChart, type ChartDefinition } from '@tanstack/charts' +import { focusGroupAngle } from '@tanstack/charts/polar' +import { tooltip } from '@tanstack/charts/tooltip' + +declare const radialDefinition: ChartDefinition + +const interactiveDefinition = defineChart(radialDefinition, { + focus: focusGroupAngle, + tooltip, +}) +``` + +The nearest radial ray selects the semantic angle, the closest radius becomes +primary, and the tooltip receives one point per series at that angle. The +strategy uses the same finite `maxFocusDistance` policy as axis grouping. +Ordinary pie and donut charts can keep default nearest focus: `radialArc` +attaches the exact painted slice geometry, including the donut hole. + Default `primary` and `group` presentation follows the canonical focused scene points. Equal x/y/series values in another facet do not implicitly paint a second focus marker. To synchronize a visual cursor across facets without diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 09f9ce00..373430bf 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -139,6 +139,12 @@ or direct strategy use. The exact exported objects receive the same host-level containment behavior as their presets. A strategy that wraps or copies one of them is custom and owns its complete pointer resolution. +`focusGroupAngle` is available from `@tanstack/charts/polar`. It resolves the +nearest radial ray, groups points with the same semantic angle value, and +orders keyboard tasks by angle. Use it for grouped radar, polar-line, and +radial-dot tooltips. Painted `radialArc` geometry already participates in +default nearest focus. + ## Crosshair guides `crosshair` is a data-less presentation mark. It follows the chart's resolved diff --git a/docs/reference/index.md b/docs/reference/index.md index dc801264..42259a6f 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -159,7 +159,7 @@ aggregate `/scales` export. | `@tanstack/charts/motion` | `motion`, `ChartMotionOptions`, and renderer-neutral motion types | | `@tanstack/charts/network/force` | `forceLayout`, built-in descriptors, named D3-compatible force factories, private working-clone context, settled node/link result, and lineage types | | `@tanstack/charts/network/sankey` | `sankeyDiagram`, shorthand and callable alignment types, responsive layout options, immutable node/link values, comparator contexts, and lineage types | -| `@tanstack/charts/polar` | `pie`, `polar`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | +| `@tanstack/charts/polar` | `pie`, `polar`, `focusGroupAngle`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | | `@tanstack/charts/rect` | `rect`, `cell` | diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index dd0bb8da..560234bf 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -8,6 +8,7 @@ Polar marks are available only from the capability subpath: ```ts import { angleGrid, + focusGroupAngle, pie, polar, radialArc, @@ -82,6 +83,28 @@ resize. The outer chart omits `x` and `y`. Cartesian axes do not participate in the internal polar scales. +## `focusGroupAngle` + +```ts +import { defineChart, type ChartDefinition } from '@tanstack/charts' +import { focusGroupAngle } from '@tanstack/charts/polar' +import { tooltip } from '@tanstack/charts/tooltip' + +declare const definition: ChartDefinition + +const interactiveDefinition = defineChart(definition, { + focus: focusGroupAngle, + tooltip, +}) +``` + +`focusGroupAngle` is the polar equivalent of `group-x`. Pointer resolution +uses the nearest radial ray instead of the nearest point anchor, then returns +one point per series with the same semantic angle value. The closest radius is +primary. Keyboard navigation visits one representative per angle in angular +order. `maxFocusDistance` is the scene-pixel distance from the pointer to the +ray; set it to `Number.POSITIVE_INFINITY` for continuous angular snapping. + ## `pie` ```ts @@ -154,6 +177,11 @@ function radialArc( | `strokeDasharray` | Boundary dash array | | `opacity` | Whole-arc opacity | +Each arc attaches its sampled painted boundary to its interaction point. +Default nearest focus therefore follows the visible slice, including holes, +rounded corners, reversed sweeps, and custom D3 generators, instead of using +only the centroid anchor. + Use the native `pie` transform for flat typed rows with source lineage. D3 `pie` output remains valid interoperability input because its `startAngle`, `endAngle`, and `padAngle` fields are also the channels this mark needs. A pie, diff --git a/packages/charts-core/docs/guides/tooltips-and-focus.md b/packages/charts-core/docs/guides/tooltips-and-focus.md index 8ebc524c..aa66960b 100644 --- a/packages/charts-core/docs/guides/tooltips-and-focus.md +++ b/packages/charts-core/docs/guides/tooltips-and-focus.md @@ -48,6 +48,29 @@ category. A sparse snapped cursor can opt into `maxFocusDistance: Number.POSITIVE_INFINITY`; keep the finite default when empty space should mean no focus. +## Angular focus + +Use `focusGroupAngle` for the radial equivalent of `group-x`: + +```ts +import { defineChart, type ChartDefinition } from '@tanstack/charts' +import { focusGroupAngle } from '@tanstack/charts/polar' +import { tooltip } from '@tanstack/charts/tooltip' + +declare const radialDefinition: ChartDefinition + +const interactiveDefinition = defineChart(radialDefinition, { + focus: focusGroupAngle, + tooltip, +}) +``` + +The nearest radial ray selects the semantic angle, the closest radius becomes +primary, and the tooltip receives one point per series at that angle. The +strategy uses the same finite `maxFocusDistance` policy as axis grouping. +Ordinary pie and donut charts can keep default nearest focus: `radialArc` +attaches the exact painted slice geometry, including the donut hole. + Default `primary` and `group` presentation follows the canonical focused scene points. Equal x/y/series values in another facet do not implicitly paint a second focus marker. To synchronize a visual cursor across facets without diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 09f9ce00..373430bf 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -139,6 +139,12 @@ or direct strategy use. The exact exported objects receive the same host-level containment behavior as their presets. A strategy that wraps or copies one of them is custom and owns its complete pointer resolution. +`focusGroupAngle` is available from `@tanstack/charts/polar`. It resolves the +nearest radial ray, groups points with the same semantic angle value, and +orders keyboard tasks by angle. Use it for grouped radar, polar-line, and +radial-dot tooltips. Painted `radialArc` geometry already participates in +default nearest focus. + ## Crosshair guides `crosshair` is a data-less presentation mark. It follows the chart's resolved diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index dc801264..42259a6f 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -159,7 +159,7 @@ aggregate `/scales` export. | `@tanstack/charts/motion` | `motion`, `ChartMotionOptions`, and renderer-neutral motion types | | `@tanstack/charts/network/force` | `forceLayout`, built-in descriptors, named D3-compatible force factories, private working-clone context, settled node/link result, and lineage types | | `@tanstack/charts/network/sankey` | `sankeyDiagram`, shorthand and callable alignment types, responsive layout options, immutable node/link values, comparator contexts, and lineage types | -| `@tanstack/charts/polar` | `pie`, `polar`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | +| `@tanstack/charts/polar` | `pie`, `polar`, `focusGroupAngle`, `radialArc`, `radialBarRadius`, `radialBarAngle`, other radial marks, and radial/angle guides | | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | | `@tanstack/charts/rect` | `rect`, `cell` | diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index dd0bb8da..560234bf 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -8,6 +8,7 @@ Polar marks are available only from the capability subpath: ```ts import { angleGrid, + focusGroupAngle, pie, polar, radialArc, @@ -82,6 +83,28 @@ resize. The outer chart omits `x` and `y`. Cartesian axes do not participate in the internal polar scales. +## `focusGroupAngle` + +```ts +import { defineChart, type ChartDefinition } from '@tanstack/charts' +import { focusGroupAngle } from '@tanstack/charts/polar' +import { tooltip } from '@tanstack/charts/tooltip' + +declare const definition: ChartDefinition + +const interactiveDefinition = defineChart(definition, { + focus: focusGroupAngle, + tooltip, +}) +``` + +`focusGroupAngle` is the polar equivalent of `group-x`. Pointer resolution +uses the nearest radial ray instead of the nearest point anchor, then returns +one point per series with the same semantic angle value. The closest radius is +primary. Keyboard navigation visits one representative per angle in angular +order. `maxFocusDistance` is the scene-pixel distance from the pointer to the +ray; set it to `Number.POSITIVE_INFINITY` for continuous angular snapping. + ## `pie` ```ts @@ -154,6 +177,11 @@ function radialArc( | `strokeDasharray` | Boundary dash array | | `opacity` | Whole-arc opacity | +Each arc attaches its sampled painted boundary to its interaction point. +Default nearest focus therefore follows the visible slice, including holes, +rounded corners, reversed sweeps, and custom D3 generators, instead of using +only the centroid anchor. + Use the native `pie` transform for flat typed rows with source lineage. D3 `pie` output remains valid interoperability input because its `startAngle`, `endAngle`, and `padAngle` fields are also the channels this mark needs. A pie, diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 2d9997b3..25ed4202 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -245,6 +245,18 @@ describe('public package exports', () => { } }) + it('keeps angular focus on the polar subpath', async () => { + const [root, universal, polar] = await Promise.all([ + import('@tanstack/charts'), + import('@tanstack/charts/universal'), + import('@tanstack/charts/polar'), + ]) + + expect(root).not.toHaveProperty('focusGroupAngle') + expect(universal).not.toHaveProperty('focusGroupAngle') + expect(polar).toHaveProperty('focusGroupAngle') + }) + it('keeps the optional hexbin algorithm on its exact spatial subpath', async () => { const [root, universal, spatial] = await Promise.all([ import('@tanstack/charts'), diff --git a/packages/charts-core/src/polar-focus-internal.ts b/packages/charts-core/src/polar-focus-internal.ts new file mode 100644 index 00000000..42f39ea1 --- /dev/null +++ b/packages/charts-core/src/polar-focus-internal.ts @@ -0,0 +1,187 @@ +import { valueKey } from './scales' +import type { PolarLayoutContext } from './polar-mark-internal' +import type { + ChartFocusGroupContext, + ChartFocusResolveContext, + ChartPoint, + ChartValue, +} from './types' + +const polarFocusGeometry = Symbol('tanstack-charts-polar-focus') + +type PolarFocusGeometry = readonly [ + scope: PolarLayoutContext, + angle: number, + radius: number, + offsetX: number, + offsetY: number, +] + +const enum Geometry { + Scope, + Angle, + Radius, + OffsetX, + OffsetY, +} + +type PolarFocusPoint = ChartPoint & { + [polarFocusGeometry]?: PolarFocusGeometry +} + +/** Attaches renderer-neutral polar coordinates without widening ChartPoint. */ +export function withPolarFocusGeometry< + TPoint extends ChartPoint, +>( + point: TPoint, + layout: PolarLayoutContext, + angle: number, + radius: number, + offsetX: number, + offsetY: number, +): TPoint { + return Object.assign(point, { + [polarFocusGeometry]: [layout, angle, radius, offsetX, offsetY], + }) +} + +/** Groups the nearest angular ray across radial series. */ +export const focusGroupAngle = { + resolve( + points: readonly ChartPoint[], + context: ChartFocusResolveContext, + ) { + const maximum = Math.max(0, context.maxDistance) + let nearest: (typeof points)[number] | undefined + let nearestGeometry: PolarFocusGeometry | undefined + let primaryDistance = maximum + let secondaryDistance = Infinity + + for (const point of points) { + const geometry = geometryForPoint(point) + if (!geometry) continue + const centerX = point.x - geometry[Geometry.OffsetX] + const centerY = point.y - geometry[Geometry.OffsetY] + const nextPrimary = distanceToPolarRay( + context.x, + context.y, + centerX, + centerY, + geometry[Geometry.Angle], + geometry[Geometry.Scope].radius, + ) + if (nextPrimary > primaryDistance) continue + const nextSecondary = Math.hypot(point.x - context.x, point.y - context.y) + if (nextPrimary < primaryDistance || nextSecondary < secondaryDistance) { + nearest = point + nearestGeometry = geometry + primaryDistance = nextPrimary + secondaryDistance = nextSecondary + } + } + + return nearest && nearestGeometry + ? groupAnglePoints(points, nearest, nearestGeometry) + : [] + }, + group( + points: readonly ChartPoint[], + context: ChartFocusGroupContext, + ) { + const geometry = geometryForPoint(context.point) + return geometry + ? groupAnglePoints(points, context.point, geometry) + : [context.point] + }, + navigation( + points: readonly ChartPoint[], + ) { + const sorted = points + .flatMap((point) => { + const geometry = geometryForPoint(point) + return geometry ? [{ point, geometry }] : [] + }) + .sort( + (left, right) => + left.geometry[Geometry.Angle] - right.geometry[Geometry.Angle] || + left.geometry[Geometry.Radius] - right.geometry[Geometry.Radius], + ) + const scopes = new Map>() + return sorted.flatMap(({ point, geometry }) => { + let values = scopes.get(geometry[Geometry.Scope]) + if (!values) scopes.set(geometry[Geometry.Scope], (values = new Set())) + const key = valueKey(point.xValue) + if (values.has(key)) return [] + values.add(key) + return [point] + }) + }, +} satisfies UniversalPolarFocusStrategy + +interface UniversalPolarFocusStrategy { + resolve: ( + points: readonly ChartPoint[], + context: ChartFocusResolveContext, + ) => readonly ChartPoint[] + group: ( + points: readonly ChartPoint[], + context: ChartFocusGroupContext, + ) => readonly ChartPoint[] + navigation: ( + points: readonly ChartPoint[], + ) => readonly ChartPoint[] +} + +function geometryForPoint(point: ChartPoint): PolarFocusGeometry | undefined { + return (point as PolarFocusPoint)[polarFocusGeometry] +} + +function groupAnglePoints< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + point: ChartPoint, + geometry: PolarFocusGeometry, +) { + const value = valueKey(point.xValue) + const unique = new Map>() + unique.set(valueKey(point.group), point) + for (const candidate of points) { + const candidateGeometry = geometryForPoint(candidate) + if ( + candidateGeometry?.[Geometry.Scope] !== geometry[Geometry.Scope] || + valueKey(candidate.xValue) !== value + ) { + continue + } + const group = valueKey(candidate.group) + if (!unique.has(group)) unique.set(group, candidate) + } + const sorted = [...unique.values()].sort( + (left, right) => + (geometryForPoint(left)?.[Geometry.Radius] ?? 0) - + (geometryForPoint(right)?.[Geometry.Radius] ?? 0), + ) + return [point, ...sorted.filter((candidate) => candidate !== point)] +} + +function distanceToPolarRay( + x: number, + y: number, + centerX: number, + centerY: number, + angle: number, + radius: number, +) { + const unitX = Math.sin(angle) + const unitY = -Math.cos(angle) + const deltaX = x - centerX + const deltaY = y - centerY + const projection = Math.min( + Math.max(deltaX * unitX + deltaY * unitY, 0), + Math.max(0, radius), + ) + return Math.hypot(deltaX - projection * unitX, deltaY - projection * unitY) +} diff --git a/packages/charts-core/src/polar-sector-internal.ts b/packages/charts-core/src/polar-sector-internal.ts index 3cc7e3b0..e32e7f9e 100644 --- a/packages/charts-core/src/polar-sector-internal.ts +++ b/packages/charts-core/src/polar-sector-internal.ts @@ -40,12 +40,17 @@ export function resolvePolarSector( .cornerRadius((value) => value.cornerRadius)) const path = generator(sector) if (typeof path !== 'string' || !path) return undefined - return { path, points: polarSectorBoundary(generator, sector) } + return { + path, + points: tracePolarArcBoundary(generator, sector, 0, [sector]), + } } -function polarSectorBoundary( - generator: Arc, - sector: PolarSectorGeometry, +export function tracePolarArcBoundary( + generator: Arc, + datum: TDatum, + index: number, + data: readonly TDatum[], ): readonly (readonly [number, number])[] { // Replay D3's own path commands so the interaction polygon samples rounded, // reversed, and full-circle geometry without parsing SVG or using a DOM API. @@ -98,7 +103,7 @@ function polarSectorBoundary( // the complete Canvas context. generator.context(context as unknown as CanvasRenderingContext2D) try { - generator(sector) + generator(datum, index, data) } finally { generator.context(previousContext) } diff --git a/packages/charts-core/src/polar.test.ts b/packages/charts-core/src/polar.test.ts index fe56e502..bea063b8 100644 --- a/packages/charts-core/src/polar.test.ts +++ b/packages/charts-core/src/polar.test.ts @@ -1,9 +1,17 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { scaleBand, scaleLinear, scaleOrdinal, scalePoint } from 'd3-scale' -import { arc, areaRadial, curveLinearClosed, lineRadial } from 'd3-shape' +import { + arc, + areaRadial, + curveLinearClosed, + lineRadial, + pointRadial, +} from 'd3-shape' import * as rootExports from './index' +import { resolveChartPointerFocus } from './interaction' import { angleGrid, + focusGroupAngle, pie, polar, radialArc, @@ -14,7 +22,7 @@ import { radialRule, radialText, } from './polar' -import { createChartScene, defineChart } from './scene' +import { createChartScene, defineChart, findNearestPoint } from './scene' import type { ChartDefinition, ChartMotionContext, @@ -95,6 +103,17 @@ describe('polar marks', () => { datum: arcs[0], }) expect(small.points[0]?.datum).not.toHaveProperty('data') + expect( + smallPaths[0]?.kind === 'area' ? smallPaths[0].interaction : null, + ).toMatchObject({ point: small.points[0], affinity: 'geometry' }) + const interior = pointRadial( + (arcs[0]!.startAngle + arcs[0]!.endAngle) / 2, + expectedRadius * 0.8, + ) + expect( + findNearestPoint(small, 100 + interior[0], 80 + interior[1], 0), + ).toBe(small.points[0]) + expect(findNearestPoint(small, 100, 80, 0)).toBeNull() }) it('renders materialized pie gaps without applying arc padding twice', () => { @@ -993,6 +1012,46 @@ describe('polar marks', () => { ]) }) + it('groups radial series by the nearest angular ray', () => { + const rows = [ + { id: 'a', direction: 'east', series: 'A', value: 0.3 }, + { id: 'b', direction: 'east', series: 'B', value: 0.8 }, + ] + const scene = createChartScene( + defineChart({ + marks: [ + polar({ + angle: { + scale: scalePoint().domain([ + 'north', + 'east', + 'south', + 'west', + ]), + }, + radius: { scale: scaleLinear().domain([0, 1]) }, + marks: [ + radialLine(rows, { + angle: 'direction', + radius: 'value', + z: 'series', + key: 'id', + }), + ], + }), + ], + margin: 0, + focus: focusGroupAngle, + }), + { width: 200, height: 200 }, + ) + + expect( + resolveChartPointerFocus(scene, focusGroupAngle, 165, 100, 1), + ).toEqual([scene.points[1], scene.points[0]]) + expect(focusGroupAngle.navigation(scene.points)).toEqual([scene.points[0]]) + }) + it('preserves partial point-scale endpoints unless wrapping is explicit', () => { const data = ['left', 'top', 'right'].map((direction) => ({ direction, diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index a426da8f..e5dc3462 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -17,7 +17,11 @@ import { import { resolveCompositeChildMotion } from './composite-motion-internal' import { createMarkWithScaleValues } from './mark-with-scale-values' import { createPolarMark } from './polar-mark-internal' -import { resolvePolarSector } from './polar-sector-internal' +import { focusGroupAngle, withPolarFocusGeometry } from './polar-focus-internal' +import { + resolvePolarSector, + tracePolarArcBoundary, +} from './polar-sector-internal' import { resolveNumericScale, resolveScaleInput } from './scale-input' import { valueKey } from './scales' import type { Arc, CurveFactory, CurveFactoryLineOnly } from 'd3-shape' @@ -48,6 +52,7 @@ import type { } from './polar-mark-internal' export { pie } from './polar-pie' +export { focusGroupAngle } export type { PieDatum, PieOptions } from './polar-pie' export type { PolarLayoutContext, @@ -347,11 +352,32 @@ export function radialArc( const centroid = generator.centroid(datum, datumIndex, data) const angleValue = (generatedStart + generatedEnd) / 2 const radiusValue = (generatedInner + generatedOuter) / 2 + const point = withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + centroid[0], + y: layout.centerY + centroid[1], + color: fill, + }, + layout, + angleValue, + radiusValue, + centroid[0], + centroid[1], + ) nodes.push({ kind: 'area', key, - points: [], + points: tracePolarArcBoundary(generator, datum, datumIndex, data), path, + interaction: { point, affinity: 'geometry' }, style: { fill, fillOpacity: options.fillOpacity, @@ -363,19 +389,7 @@ export function radialArc( lineJoin: 'round', }, }) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + centroid[0], - y: layout.centerY + centroid[1], - color: fill, - }) + points.push(point) }) return { @@ -545,22 +559,29 @@ export function radialBarRadius( : visualValue(options.stroke, datum, datumIndex, data, fallback) const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` const [pointX, pointY] = pointRadial(angleBand.center, mappedRadius2) - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - y1Value: radius1Value, - y2Value: radius2Value, - yInterval: 'difference', - x: layout.centerX + pointX, - y: layout.centerY + pointY, - color: fill, - } + const point = withPolarFocusGeometry>( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + y1Value: radius1Value, + y2Value: radius2Value, + yInterval: 'difference', + x: layout.centerX + pointX, + y: layout.centerY + pointY, + color: fill, + }, + layout, + angleBand.center, + mappedRadius2, + pointX, + pointY, + ) nodes.push({ kind: 'area', key, @@ -732,22 +753,29 @@ export function radialBarAngle( : visualValue(options.stroke, datum, datumIndex, data, fallback) const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` const [pointX, pointY] = pointRadial(mappedAngle2, radiusBand.center) - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x1Value: angle1Value, - x2Value: angle2Value, - xInterval: 'difference', - x: layout.centerX + pointX, - y: layout.centerY + pointY, - color: fill, - } + const point = withPolarFocusGeometry>( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x1Value: angle1Value, + x2Value: angle2Value, + xInterval: 'difference', + x: layout.centerX + pointX, + y: layout.centerY + pointY, + color: fill, + }, + layout, + mappedAngle2, + radiusBand.center, + pointX, + pointY, + ) nodes.push({ kind: 'area', key, @@ -923,19 +951,26 @@ export function radialLine( } const [x, y] = pointRadial(row.angle, row.radius) const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[row.datumIndex], - datumIndex: row.datumIndex, - xValue: row.angleValue, - yValue: row.radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: stroke, - } + const point = withPolarFocusGeometry>( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[row.datumIndex], + datumIndex: row.datumIndex, + xValue: row.angleValue, + yValue: row.radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: stroke, + }, + layout, + row.angle, + row.radius, + x, + y, + ) points.push(point) if (options.points) { nodes.push({ @@ -1115,19 +1150,28 @@ export function radialArea( } const [x, y] = pointRadial(row.angle, row.radius) const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[row.datumIndex], - datumIndex: row.datumIndex, - xValue: row.angleValue, - yValue: row.radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, - }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[row.datumIndex], + datumIndex: row.datumIndex, + xValue: row.angleValue, + yValue: row.radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + row.angle, + row.radius, + x, + y, + ), + ) } } return { @@ -1300,19 +1344,28 @@ export function radialText( fontWeight: options.fontWeight, style: { fill }, }) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, - }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + anglePosition, + projectedRadius, + x, + y, + ), + ) }) return { nodes: [ @@ -1609,19 +1662,28 @@ export function radialDot( opacity: options.opacity, }, }) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, - }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + anglePosition, + radiusPosition, + x, + y, + ), + ) }) return { nodes: [ diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 396dc789..2c89c779 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1154,7 +1154,7 @@ const entries = [ budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 14.7, + 15.1, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1173,18 +1173,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 23.75, + 24.1, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 20.6, + 20.7, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 24.1, + 24.2, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1195,7 +1195,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 25.25, + 25.3, ), locked( 'Representative marks', diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 8eb7496a..d099f264 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -82,6 +82,10 @@ const callbackInventory = { 'group navigation resolve', ], ['@tanstack/charts:src/focus.ts:axisFocus', 'group navigation resolve'], + [ + '@tanstack/charts:src/polar-focus-internal.ts:focusGroupAngle', + 'group navigation resolve', + ], ['@tanstack/charts:src/geo.ts:GeoProjectionDescriptor', 'type'], ['@tanstack/charts:src/geo.ts:GeoProjectionInput', '$call'], [ From 948a884bdfe745e032ab6f109636c20e7546e7ed Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 17:03:18 -0600 Subject: [PATCH 2/3] Update radial focus release evidence --- benchmarks/comparison/bundle-baseline.json | 8 +-- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 6 +-- packages/charts-core/docs/comparison.md | 6 +-- .../references/interaction-state.md | 50 ++++++++++--------- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index fed1b2cf..2173d00c 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-11T23:49:36.793Z", + "generatedAt": "2026-08-12T23:00:29.524Z", "packageVersions": { - "tanstack": "0.11.0", + "tanstack": "0.11.2", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "02e24867f1699fa918fd49eaedfad64bc3f4e9e5", - "inputDigest": "sha256:790fc8c0e954afbba943d637d43b665739d2438d2cc6c341e81613baad327508" + "revision": "b9d8c541ef6a94afdc1812555ee0e4459c81d140", + "inputDigest": "sha256:52b55a904317f891ec664992193770fd9468bddddeef1e8f9fe10ec113051b05" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 29269508..343e2358 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "e51640f5fc66d37a982722a282685167d084940132c429f7a2515f16f28714f3", + "sourceHash": "93452895c364c1dd0cc6b824137d35b8c43e0a022dca544e38dccea52b94fb88", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index cfd8844f..7fffecf3 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `02e2486` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b9d8c54` | | [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 `02e2486`. +at page render time. The measured TanStack workspace revision is `b9d8c54`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-11`. +Baseline date: `2026-08-12`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index cfd8844f..7fffecf3 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `02e2486` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `b9d8c54` | | [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 `02e2486`. +at page render time. The measured TanStack workspace revision is `b9d8c54`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-11`. +Baseline date: `2026-08-12`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/packages/charts-core/skills/build-chart-interactions/references/interaction-state.md b/packages/charts-core/skills/build-chart-interactions/references/interaction-state.md index e82f359c..d8f6604c 100644 --- a/packages/charts-core/skills/build-chart-interactions/references/interaction-state.md +++ b/packages/charts-core/skills/build-chart-interactions/references/interaction-state.md @@ -2,20 +2,21 @@ ## Primitive Matrix -| User task | Primitive | Semantic state | Presentation | Non-pointer requirement | -| ----------------------------- | ------------------------------------- | --------------------------------------- | --------------------------- | ---------------------------------------- | -| Inspect nearest datum | focus strategy | chart focus point/group | focus ring, tooltip | native point navigation | -| Compare series at one x/y | `group-x` / `group-y` focus | focused group | grouped tooltip/crosshair | same grouping by keyboard | -| Mirror focused value | `whenFocused`, focus guide, crosshair | existing focus | authored or data-less guide | does not add a stop | -| Synchronize charts | `createChartCursor` + `cursorHost` | shared semantic x/y and optional origin | local crosshair per chart | programmatic/keyboard source retained | -| Track arbitrary plot position | free cursor / `continuousCursor` | numeric/temporal position | crosshair and labels | pair with labeled inputs or status | -| Select a datum | `keyedSelection` | stable datum key | `whenSelected` overlay | Enter/Space and clear path | -| Toggle series | `interactiveColorLegend` | visible color-domain values | pressed legend controls | native buttons | -| Choose one scale value | `handleX` | one semantic value | handle/control fallback | labeled input or keyboard control | -| Choose a range | `brushX` | semantic domain | brush control/fallback | application range controls when required | -| Navigate a window | `zoomX` | semantic viewport domain | pan/zoom host behavior | buttons/inputs for critical navigation | -| Show compact details | built-in `tooltip` | current/pinned focus | native text surface | focus and Escape/pin behavior | -| Show rich details | adapter tooltip body | current/pinned focus | framework UI | focus containment and action labels | +| User task | Primitive | Semantic state | Presentation | Non-pointer requirement | +| ---------------------------------- | ----------------------------------------------- | --------------------------------------- | --------------------------- | ---------------------------------------- | +| Inspect nearest datum | focus strategy | chart focus point/group | focus ring, tooltip | native point navigation | +| Compare series at one x/y | `group-x` / `group-y` focus | focused group | grouped tooltip/crosshair | same grouping by keyboard | +| Compare radial series at one angle | `focusGroupAngle` from `@tanstack/charts/polar` | focused angular group | grouped tooltip | one stop per semantic angle | +| Mirror focused value | `whenFocused`, focus guide, crosshair | existing focus | authored or data-less guide | does not add a stop | +| Synchronize charts | `createChartCursor` + `cursorHost` | shared semantic x/y and optional origin | local crosshair per chart | programmatic/keyboard source retained | +| Track arbitrary plot position | free cursor / `continuousCursor` | numeric/temporal position | crosshair and labels | pair with labeled inputs or status | +| Select a datum | `keyedSelection` | stable datum key | `whenSelected` overlay | Enter/Space and clear path | +| Toggle series | `interactiveColorLegend` | visible color-domain values | pressed legend controls | native buttons | +| Choose one scale value | `handleX` | one semantic value | handle/control fallback | labeled input or keyboard control | +| Choose a range | `brushX` | semantic domain | brush control/fallback | application range controls when required | +| Navigate a window | `zoomX` | semantic viewport domain | pan/zoom host behavior | buttons/inputs for critical navigation | +| Show compact details | built-in `tooltip` | current/pinned focus | native text surface | focus and Escape/pin behavior | +| Show rich details | adapter tooltip body | current/pinned focus | framework UI | focus containment and action labels | ## Controlled-State Contract @@ -28,16 +29,17 @@ Do not treat a controlled signal as a subscription store. Do not let a DOM gestu ## Focus Decision -| Question | Choice | -| ----------------------------------------------- | -------------------------------------- | -| One nearest painted point | default focus | -| Prioritize one axis without grouping | `nearest-x` / `nearest-y` | -| Compare every series at one x/y | `group-x` / `group-y` | -| Snap through sparse points across empty plot | explicitly infinite `maxFocusDistance` | -| Empty space should clear inspection | finite default distance | -| Paint one native guide at current focus | `crosshair` | -| Reveal authored geometry matching focused value | `whenFocused` | -| Need a rule plus point identity | focus-guide mark, not a bare rule | +| Question | Choice | +| ----------------------------------------------- | ----------------------------------------------- | +| One nearest painted point | default focus | +| Prioritize one axis without grouping | `nearest-x` / `nearest-y` | +| Compare every series at one x/y | `group-x` / `group-y` | +| Compare radial series at one angle | `focusGroupAngle` from `@tanstack/charts/polar` | +| Snap through sparse points across empty plot | explicitly infinite `maxFocusDistance` | +| Empty space should clear inspection | finite default distance | +| Paint one native guide at current focus | `crosshair` | +| Reveal authored geometry matching focused value | `whenFocused` | +| Need a rule plus point identity | focus-guide mark, not a bare rule | ## Verification Scenarios From acb27229787cff079dd508aa135e6e175334251e Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 12 Aug 2026 17:10:36 -0600 Subject: [PATCH 3/3] Stabilize stress pointer activation --- API-FRICTION.md | 21 +++++++++++++++++++++ benchmarks/comparison/stress/README.md | 3 ++- scripts/stress-chart-libraries.mjs | 7 ++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 7cebda2f..f3c75724 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -308,6 +308,7 @@ Each entry records: | F-269 | Angular mounted its browser host during server rendering | API | resolved | | F-270 | Catalog migration left generated release evidence stale | Tooling | resolved | | F-271 | Radial focus collapsed angular cross-sections to centroids | API | resolved | +| F-272 | Pointer probes armed between transient inactive frames | Tooling | resolved | ## Findings @@ -7984,3 +7985,23 @@ Each entry records: radial bars 24.17, and polar line/scatter 25.28; their isolated ceilings now record those reviewed interaction costs while the locked representative-mark bundle remains unchanged at 25.59 KiB gzip. + +### F-272 — Pointer probes armed between transient inactive frames + +- Status: resolved +- Severity: high +- Owner: Tooling +- Observed in: radial-focus release PR stress partition 3 +- Friction: the Chart.js grouped-pointer cell moved outside the chart and + accepted its first inactive frame as settled. Its activation timer then + observed the tooltip active again and failed before measuring the target. + The exact cell passed locally, confirming a scheduling race rather than a + radial-focus regression, but the correctness failure was intentionally not + retryable. +- Decision: require two consecutive inactive animation frames before arming + pointer activation timing. Keep renderer and correctness failures + non-retryable; this stabilizes the measured precondition instead of hiding a + failed sample with another attempt. +- Verification: the exact Chart.js grouped-pointer cell and the complete quick + partition 3 pass with trusted activation, exact grouped series values, and + zero recovered retries. diff --git a/benchmarks/comparison/stress/README.md b/benchmarks/comparison/stress/README.md index 28d0f5c6..4b75fe3a 100644 --- a/benchmarks/comparison/stress/README.md +++ b/benchmarks/comparison/stress/README.md @@ -146,7 +146,8 @@ and streaming inputs are source-accounted independently. Multi-series output gates exact ordered series identity, stable color ownership, explicit x-domain changes, and per-series vertex counts. Trusted pointer checks validate the exact focused x and every series value on the -initial chart and again after reorder, append, and visibility updates. +initial chart and again after reorder, append, and visibility updates. Pointer +activation timing starts only after two consecutive inactive animation frames. Cross-library timing rank is informational. Browser timing, long tasks, and retained-memory deltas are meaningful only within the same run, machine, and diff --git a/scripts/stress-chart-libraries.mjs b/scripts/stress-chart-libraries.mjs index a208b549..0a83a3dd 100644 --- a/scripts/stress-chart-libraries.mjs +++ b/scripts/stress-chart-libraries.mjs @@ -891,8 +891,13 @@ async function runTimingCell( }) } globalThis.__stressPointerWaitInactive = async () => { + let inactiveFrames = 0 for (let frame = 0; frame < 120; frame++) { - if (!globalThis.__stressPointerActive()) return true + if (globalThis.__stressPointerActive()) { + inactiveFrames = 0 + } else if (++inactiveFrames >= 2) { + return true + } await nextFrame() } throw new Error('Pointer tooltip did not return to an inactive state.')