From 681df8eb8a39fdfc68452beb04b3f754d0fa98a8 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 23:29:05 -0600 Subject: [PATCH 1/5] Add geometry-aware point selection --- .changeset/bright-hit-regions.md | 8 + API-FRICTION.md | 62 + README.md | 6 + .../entries/charts-pointer-anchor-kernel.ts | 30 + .../entries/charts-pointer-geometry-kernel.ts | 1 + benchmarks/interaction/README.md | 89 ++ benchmarks/interaction/nearest.ts | 864 +++++++++++++ docs/guides/custom-marks-and-renderers.md | 31 + docs/reference/focus-and-interaction.md | 35 + docs/reference/runtime-and-scene.md | 5 +- docs/reference/types.md | 5 + examples/sandbox/README.md | 19 + examples/sandbox/package.json | 3 +- .../src/InteractionGeometryLab.test.ts | 114 ++ .../sandbox/src/InteractionGeometryLab.tsx | 1110 +++++++++++++++++ examples/sandbox/src/main.tsx | 11 +- examples/sandbox/src/styles.css | 353 ++++++ package.json | 2 + .../docs/guides/custom-marks-and-renderers.md | 31 + .../docs/reference/focus-and-interaction.md | 35 + .../docs/reference/runtime-and-scene.md | 5 +- packages/charts-core/docs/reference/types.md | 5 + packages/charts-core/src/area.ts | 1 + packages/charts-core/src/band.ts | 28 +- packages/charts-core/src/bar.ts | 19 +- packages/charts-core/src/dot.ts | 2 + packages/charts-core/src/facet.ts | 20 + packages/charts-core/src/hexagon.ts | 2 + packages/charts-core/src/index.ts | 5 + packages/charts-core/src/line.ts | 1 + packages/charts-core/src/nearest.test.ts | 293 +++++ packages/charts-core/src/nearest.ts | 195 ++- packages/charts-core/src/rect.ts | 20 +- packages/charts-core/src/renderer.test.ts | 36 +- packages/charts-core/src/scene.ts | 15 +- packages/charts-core/src/types.ts | 27 + packages/charts-core/src/universal-types.ts | 5 + scripts/measure-bundles.mjs | 9 + scripts/measure-pointer-resolution.mjs | 22 + 39 files changed, 3487 insertions(+), 37 deletions(-) create mode 100644 .changeset/bright-hit-regions.md create mode 100644 benchmarks/entries/charts-pointer-anchor-kernel.ts create mode 100644 benchmarks/entries/charts-pointer-geometry-kernel.ts create mode 100644 benchmarks/interaction/README.md create mode 100644 benchmarks/interaction/nearest.ts create mode 100644 examples/sandbox/README.md create mode 100644 examples/sandbox/src/InteractionGeometryLab.test.ts create mode 100644 examples/sandbox/src/InteractionGeometryLab.tsx create mode 100644 packages/charts-core/src/nearest.test.ts create mode 100644 scripts/measure-pointer-resolution.mjs diff --git a/.changeset/bright-hit-regions.md b/.changeset/bright-hit-regions.md new file mode 100644 index 00000000..8236b259 --- /dev/null +++ b/.changeset/bright-hit-regions.md @@ -0,0 +1,8 @@ +--- +'@tanstack/charts': patch +--- + +Resolve default pointer focus against painted mark geometry before applying a +mark's natural x, y, or two-dimensional fallback. Built-in bars, bands, dots, +rectangles, and hexagons now expose their hit regions, while custom marks can +provide rectangle, circle, or polygon regions directly on scene points. diff --git a/API-FRICTION.md b/API-FRICTION.md index 67f48f09..8d09d76f 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -211,6 +211,7 @@ Each entry records: | F-173 | Metro retained the complete universal barrel | API/Tooling | monitoring | | F-174 | OIDC release cannot claim a new npm package name | Tooling | monitoring | | F-175 | Native SVG resource normalization collapsed authored IDs | Application | resolved | +| F-176 | Large marks were focused by distant anchor points | API | monitoring | ## Findings @@ -4257,3 +4258,64 @@ Each entry records: - Verification: the native scene regression renders the formerly colliding IDs plus empty and delimiter-containing IDs, and checks matching definition IDs and paint references. + +### F-176 — Large marks were focused by distant anchor points + +- Status: monitoring +- Severity: high +- Owner: API +- Observed in: stacked-bar tooltip report and interaction-geometry lab +- Friction: vertical bars emit their value endpoint as the interaction anchor, + and the default resolver measured `maxFocusDistance` only from that anchor. + A pointer inside a tall bar could therefore select an adjacent endpoint less + than 48 pixels away. Raising the threshold retained the wrong two-dimensional + ranking, while chart-wide nearest-x made off-bar selection too permissive. + Pure x fallback also tied every segment in one stack and selected the bottom + segment when the pointer was above the stack. +- Current decision: use a two-stage mark contract rather than infer a strategy + from chart composition. Interaction points may expose their painted + rectangle, circle, or polygon, while the mark declares its natural off-shape + fallback as `x`, `y`, `xy`, or `geometry`. Exact containment wins across all + marks before fallback ranking. Axis fallback compares its declared axis + first, then uses full painted-geometry distance to break ties. Overlapping + contained shapes resolve topmost-first in paint order. Points without either + field retain legacy point-distance behavior. Explicit focus strategies and + custom spatial indexes continue to own their complete search semantics. +- Verification: focused tests cover containment priority, x/y/xy/geometry + fallback, rectangle/circle/polygon shapes, built-in bar affinity, reversed + rectangles, paint-order overlap, stack-edge selection, spatial-index + ownership, and legacy tie order. The twelve-case sandbox compares before and + after behavior for stacked and horizontal bars, line/area, bubbles, cells, + financial intervals, timelines, pie and annular sectors, radar, maps, and + Sankey geometry. The full unit matrix passes 675 tests across 123 files; + typecheck, documentation, packed-consumer, framework-adapter, formatting, and + sandbox production-build gates also pass. + + On Node 24 arm64 on an Apple M4 Pro, the optimized resolver improves the + original POC's median + query time from 116.7 to 23.8 microseconds for 10k ordinary points, 62.2 to + 27.1 for contained rectangles, 210.5 to 127.0 for stacked fallback, 64.5 to + 25.7 for circles, and 132.9 to 79.5 for 2k polygons. On an exact-target point + fixture, production, optimized geometry, Observable Plot 0.6.17, D3 quadtree, + cold D3 Delaunay, and coherent Delaunay take 13.9, 25.2, 41.4, 2.8, 8.6, and + 2.8 microseconds. Quadtree and Delaunay construction take 1.76 and 2.47 + milliseconds for 10k points. A source-equivalent Vega cached-bounds pass + takes 9.8 microseconds versus 23.8 for the generic rectangle resolver, but + deliberately excludes Vega's subsequent Canvas path test. + + The isolated resolver grows from 157 minified / 153 gzip bytes to 1,955 / 869 + bytes, a 716-byte gzip feature cost beneath a new 1 kB ceiling. The D3 + quadtree and Delaunay kernels are 1,971 and 7,308 gzip bytes before geometry + refinement. The complete DOM host remains 685 gzip bytes above the + pre-feature lock rather than silently accepting a new baseline. + [Observable Plot](https://observablehq.com/plot/interactions/pointer) + documents point-only dead spots and dominant-axis modes; + [D3 quadtree](https://d3js.org/d3-quadtree#quadtree_find) and + [D3 Delaunay](https://d3js.org/d3-delaunay/delaunay#delaunay_find) establish + indexed point lookup and its build/rebuild tradeoff; Vega's + [reverse visitor](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/visit.js) + and [Canvas picker](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/canvas/pick.js) + establish topmost traversal, cached-bounds rejection, and exact path tests. + +- Follow-up: verify rounded corners and true paths, negative bars, animation, + and SVG/Canvas parity before resolving this entry. diff --git a/README.md b/README.md index 67bfc53b..311b8303 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,14 @@ Run a local example: pnpm dev:charts-react pnpm dev:charts-octane pnpm dev:sandbox +pnpm dev:interaction-geometry pnpm dev:conformance ``` +The sandbox opens its existing integrated data dashboard by default. The +interaction-geometry command opens a before-and-after lab for exercising +painted hit regions and mark-level focus fallback across chart families. + The repository includes three complementary benchmark suites: - [`benchmarks/bundle-size`](./benchmarks/bundle-size) locks ordinary consumer @@ -309,6 +314,7 @@ The repository includes three complementary benchmark suites: ```sh pnpm bundle:check pnpm performance +pnpm performance:pointer pnpm benchmark:check pnpm benchmark:stress:quick pnpm conformance:quick diff --git a/benchmarks/entries/charts-pointer-anchor-kernel.ts b/benchmarks/entries/charts-pointer-anchor-kernel.ts new file mode 100644 index 00000000..061287ef --- /dev/null +++ b/benchmarks/entries/charts-pointer-anchor-kernel.ts @@ -0,0 +1,30 @@ +import type { + ChartPoint, + ChartValue, +} from '../../packages/charts-core/src/types' + +// Historical production baseline retained for isolated bundle comparison. +export function nearestPoint< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +): ChartPoint | null { + let result: ChartPoint | undefined + let resultDistance = Infinity + for (const point of points) { + const dx = point.x - x + const dy = point.y - y + const distance = dx * dx + dy * dy + if (distance < resultDistance) { + result = point + resultDistance = distance + } + } + if (!result) return null + return resultDistance <= Math.max(0, maxDistance) ** 2 ? result : null +} diff --git a/benchmarks/entries/charts-pointer-geometry-kernel.ts b/benchmarks/entries/charts-pointer-geometry-kernel.ts new file mode 100644 index 00000000..ed752e44 --- /dev/null +++ b/benchmarks/entries/charts-pointer-geometry-kernel.ts @@ -0,0 +1 @@ +export { nearestPoint } from '../../packages/charts-core/src/nearest' diff --git a/benchmarks/interaction/README.md b/benchmarks/interaction/README.md new file mode 100644 index 00000000..37c57c7e --- /dev/null +++ b/benchmarks/interaction/README.md @@ -0,0 +1,89 @@ +# Pointer-resolution benchmark + +The algorithm change is intentionally visible as two different selection +pipelines: + +```text +CURRENT PRODUCTION (anchor-only) + +pointer (x, y) + | + v +scan every point anchor ---------------- O(n), one pass + | + v +nearest dx^2 + dy^2 + | + v +anchor within max distance? ----------- yes -> point + | + `--------------------------------- no -> null + +NEW OPTIMIZED GEOMETRY + +pointer (x, y) + | + v +scan hit regions, topmost-first -------- O(n), allocation-free + | exact containment + `--------------------------------- yes -> first painted hit + | + | no exact hit + v +all targets are ordinary points? ------- yes -> reuse nearest anchor from pass + | + | no (geometry or axis affinity exists) + v +rank declared x / y / xy fallback ------ O(n), second pass only on miss + | + v +break axis ties by boundary distance + | + v +region within max distance? ------------ yes -> point; no -> null +``` + +Run the focused interaction benchmark with: + +```sh +pnpm performance:pointer +``` + +It measures median and p95 time per pointer query for 10,000 point, rectangle, +stacked-rectangle, and circle targets plus 2,000 polygons. The benchmark runs +the former allocation-heavy geometry POC beside the optimized resolver and an +anchor-only production baseline. Before timing, it verifies that the POC and +optimized resolver select the same target for every non-overlap probe. The +anchor-only resolver is a speed baseline, not a correctness baseline for the +geometry rows: it cannot return the intended large-mark behavior. + +The point-only section compares identical nearest-anchor targets on a +deterministic fixture against the selection loop from the installed Observable +Plot 0.6.17 pointer transform, D3 quadtree 3.0.1, and D3 Delaunay 6.0.4. Plot +timings intentionally exclude DOM coordinate conversion, mark rendering, +pooling, and event dispatch. D3 query timings exclude index construction, which +is reported separately; those indexes also do not reproduce hit-region +containment or mark affinity without additional candidate refinement. + +The rectangle section additionally measures a source-equivalent lower bound +for Vega 5.2.1's first picking stage. Vega's Canvas picker traverses +topmost-first, rejects against stored bounds, and then runs mark-specific path +tests. The benchmark stops before that exact Canvas path test, so it is useful +for learning from cached bounds but is not reported as end-to-end Vega +performance. + +The comparison follows the primary implementations and contracts documented by +[Observable Plot's pointer transform](https://observablehq.com/plot/interactions/pointer), +[D3 quadtree](https://d3js.org/d3-quadtree#quadtree_find), +[D3 Delaunay](https://d3js.org/d3-delaunay/delaunay#delaunay_find), and Vega's +[reverse visitor](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/visit.js) +and [Canvas picker](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/canvas/pick.js). + +`pnpm bundle:check` reports isolated anchor-only and geometry resolver entries +beside the existing D3 quadtree and Delaunay kernels. The geometry resolver has +a 1 kB gzip ceiling; the historical anchor-only entry stays unbudgeted so the +comparison remains visible without treating removed behavior as a product. + +The command exposes explicit garbage collection and rotates implementation +order between samples to reduce allocation and thermal bias. Results are +machine-specific evidence, not a portable CI timing threshold. diff --git a/benchmarks/interaction/nearest.ts b/benchmarks/interaction/nearest.ts new file mode 100644 index 00000000..06337e91 --- /dev/null +++ b/benchmarks/interaction/nearest.ts @@ -0,0 +1,864 @@ +import { performance } from 'node:perf_hooks' +import { Delaunay } from 'd3-delaunay' +import { quadtree } from 'd3-quadtree' +import { nearestPoint } from '../../packages/charts-core/src/nearest' +import type { + ChartPoint, + ChartValue, +} from '../../packages/charts-core/src/types' + +interface Datum { + index: number +} + +interface PointerQuery { + x: number + y: number + maxDistance: number +} + +interface PointerCase { + label: string + points: readonly ChartPoint[] + queries: readonly PointerQuery[] + repetitions: number +} + +interface VegaRectItem { + point: ChartPoint + bounds: { + x1: number + y1: number + x2: number + y2: number + contains: (x: number, y: number) => boolean + } +} + +type Resolver = ( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) => ChartPoint | null + +type Implementation = readonly [label: string, resolver: Resolver] + +const densePoints = Array.from({ length: 10_000 }, (_, index) => + point(index, index % 1_000, Math.floor(index / 1_000) * 20), +) +const rectangles = Array.from({ length: 10_000 }, (_, index) => { + const x = (index % 200) * 6 + const y = Math.floor(index / 200) * 10 + return point(index, x + 2.5, y, { + kind: 'rect', + x, + y, + width: 5, + height: 9, + }) +}) +const stackedRectangles = Array.from({ length: 10_000 }, (_, index) => { + const category = Math.floor(index / 10) + const segment = index % 10 + const x = category * 12 + const y = 200 - (segment + 1) * 20 + return point( + index, + x + 5, + y, + { kind: 'rect', x, y, width: 10, height: 20 }, + 'x', + ) +}) +const circles = Array.from({ length: 10_000 }, (_, index) => { + const x = (index % 200) * 8 + const y = Math.floor(index / 200) * 8 + return point(index, x, y, { kind: 'circle', x, y, radius: 3.5 }) +}) +const hexagons = Array.from({ length: 2_000 }, (_, index) => { + const x = (index % 100) * 14 + const y = Math.floor(index / 100) * 14 + const radius = 6 + const vertices = Array.from({ length: 6 }, (__, vertex) => { + const angle = (Math.PI / 3) * vertex + return [x + Math.cos(angle) * radius, y + Math.sin(angle) * radius] as const + }) + return point(index, x, y, { kind: 'polygon', points: vertices }) +}) + +const cases: readonly PointerCase[] = [ + { + label: '10k points · ordinary nearest', + points: densePoints, + queries: queries(16, (index) => ({ + x: 23 + index * 57, + y: 89, + maxDistance: 48, + })), + repetitions: 4, + }, + { + label: '10k rectangles · containment', + points: rectangles, + queries: queries(16, (index) => { + const target = rectangles[(index * 613) % rectangles.length]! + return { x: target.x, y: target.y + 4, maxDistance: 48 } + }), + repetitions: 4, + }, + { + label: '10k stacked rectangles · x fallback', + points: stackedRectangles, + queries: queries(16, (index) => ({ + x: ((index * 61) % 1_000) * 12 + 5, + y: index % 2 ? -12 : 212, + maxDistance: 48, + })), + repetitions: 4, + }, + { + label: '10k circles · containment', + points: circles, + queries: queries(16, (index) => { + const target = circles[(index * 613) % circles.length]! + return { x: target.x + 2, y: target.y, maxDistance: 48 } + }), + repetitions: 4, + }, + { + label: '2k polygons · off-shape fallback', + points: hexagons, + queries: queries(16, (index) => ({ + x: index * 83 + 7, + y: 300, + maxDistance: 48, + })), + repetitions: 8, + }, +] + +const implementations: readonly Implementation[] = [ + ['anchor-only baseline', anchorNearestPoint], + ['unoptimized POC', pocNearestPoint], + ['optimized geometry', nearestPoint], +] as const + +const comparisonCase: PointerCase = { + label: '10k point-only targets', + points: densePoints, + queries: queries(128, (index) => ({ + x: ((index * 83) % 997) + 0.37, + y: 90 + Math.sin((index / 128) * Math.PI * 2) * 82 + 0.19, + maxDistance: 48, + })), + repetitions: 16, +} +const rectangleComparisonCase: PointerCase = { + label: '10k contained rectangles', + points: rectangles, + queries: queries(128, (index) => { + const target = rectangles[(index * 613) % rectangles.length]! + return { x: target.x, y: target.y + 4, maxDistance: 48 } + }), + repetitions: 16, +} +const vegaRectItems: readonly VegaRectItem[] = rectangles.map((point) => { + if (point.hitRegion?.kind !== 'rect') { + throw new Error('Expected rectangle benchmark geometry') + } + const { x, y, width, height } = point.hitRegion + const bounds = { + x1: Math.min(x, x + width), + y1: Math.min(y, y + height), + x2: Math.max(x, x + width), + y2: Math.max(y, y + height), + contains(px: number, py: number) { + return !(px < this.x1 || px > this.x2 || py < this.y1 || py > this.y2) + }, + } + return { point, bounds } +}) +const plotIndexes = Uint32Array.from(densePoints, (_point, index) => index) +const plotX = Float64Array.from(densePoints, (point) => point.x) +const plotY = Float64Array.from(densePoints, (point) => point.y) +const plotPx = (index: number) => plotX[index]! +const plotPy = (index: number) => plotY[index]! +const quadtreeIndex = quadtree>() + .x((point) => point.x) + .y((point) => point.y) + .addAll([...densePoints]) +const delaunayIndex = Delaunay.from( + densePoints, + (point) => point.x, + (point) => point.y, +) +let delaunayCursor = 0 +const comparisonImplementations: readonly Implementation[] = [ + ['current prod · linear anchor', anchorNearestPoint], + ['new geometry · point-only', nearestPoint], + ['Observable Plot 0.6.17 · pointer kernel', plotPointerNearestPoint], + ['D3 quadtree 3.0.1 · indexed', quadtreeNearestPoint], + ['D3 Delaunay 6.0.4 · cold start', delaunayNearestPoint], + ['D3 Delaunay 6.0.4 · coherent start', coherentDelaunayNearestPoint], +] as const +const rectangleComparisonImplementations: readonly Implementation[] = [ + ['new geometry · generic rect', nearestPoint], + ['Vega 5.2.1 · bounds-only lower bound', vegaBoundsNearestPoint], +] as const +const collectGarbage = (globalThis as { gc?: () => void }).gc + +verifyEquivalentResults() +verifyComparisonResults() +verifyRectangleComparisonResults() +console.log(`Pointer resolution · ${process.version} · ${process.arch}`) +console.log( + 'Production is a speed baseline; geometry rows intentionally add semantics it cannot return.', +) +console.log('| Case | Resolver | Median / query | p95 / query |') +console.log('| --- | --- | ---: | ---: |') + +let checksum = 0 +const scenarioMeasurements: Array<{ + benchmark: PointerCase + measurements: Map +}> = [] +for (const benchmark of cases) { + const measurements = measure(benchmark) + scenarioMeasurements.push({ benchmark, measurements }) + for (const [label] of implementations) { + const samples = measurements.get(label)! + console.log( + `| ${benchmark.label} | ${label} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} |`, + ) + } +} +printScenarioComparisons(scenarioMeasurements) + +console.log('\nPoint-only comparison · identical targets on this fixture') +console.log('| Resolver | Median / query | p95 / query |') +console.log('| --- | ---: | ---: |') +const comparisonMeasurements = measure( + comparisonCase, + comparisonImplementations, +) +for (const [label] of comparisonImplementations) { + const samples = comparisonMeasurements.get(label)! + console.log( + `| ${label} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} |`, + ) +} +printAsciiDurations(comparisonMeasurements, comparisonImplementations) +printIndexBuildTimes() +printIndexStorage() + +console.log( + '\nRectangle containment comparison · identical targets on this fixture', +) +console.log( + 'Vega result is a bounds-only lower bound; its Canvas picker then builds and tests the mark path.', +) +console.log('| Resolver | Median / query | p95 / query |') +console.log('| --- | ---: | ---: |') +const rectangleComparisonMeasurements = measure( + rectangleComparisonCase, + rectangleComparisonImplementations, +) +for (const [label] of rectangleComparisonImplementations) { + const samples = rectangleComparisonMeasurements.get(label)! + console.log( + `| ${label} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} |`, + ) +} +printAsciiDurations( + rectangleComparisonMeasurements, + rectangleComparisonImplementations, +) +console.log(`checksum: ${checksum}`) + +function measure( + benchmark: PointerCase, + resolvers: readonly Implementation[] = implementations, +) { + for (let round = 0; round < 3; round += 1) { + for (const [, resolver] of resolvers) run(resolver, benchmark) + } + + const samples = new Map(resolvers.map(([label]) => [label, [] as number[]])) + for (let round = 0; round < 18; round += 1) { + for (let offset = 0; offset < resolvers.length; offset += 1) { + const [label, resolver] = resolvers[(round + offset) % resolvers.length]! + collectGarbage?.() + const startedAt = performance.now() + run(resolver, benchmark) + samples + .get(label)! + .push( + (performance.now() - startedAt) / + (benchmark.queries.length * benchmark.repetitions), + ) + } + } + for (const values of samples.values()) { + values.sort((left, right) => left - right) + } + return samples +} + +function run(resolver: Resolver, benchmark: PointerCase) { + for ( + let repetition = 0; + repetition < benchmark.repetitions; + repetition += 1 + ) { + for (const query of benchmark.queries) { + const result = resolver( + benchmark.points, + query.x, + query.y, + query.maxDistance, + ) + checksum = (checksum + (result?.datumIndex ?? 0) + 1) % 1_000_000_007 + } + } +} + +function anchorNearestPoint< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + let result: ChartPoint | undefined + let resultDistance = Infinity + for (const candidate of points) { + const dx = candidate.x - x + const dy = candidate.y - y + const distance = dx * dx + dy * dy + if (distance < resultDistance) { + result = candidate + resultDistance = distance + } + } + return resultDistance <= Math.max(0, maxDistance) ** 2 + ? (result ?? null) + : null +} + +// Selection loop copied from Observable Plot 0.6.17 pointer(), excluding DOM +// coordinate conversion, rendering, pooling, and event dispatch. +function plotPointerNearestPoint( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + let resultIndex = -1 + let resultDistance = Math.max(0, maxDistance) ** 2 + for (const index of plotIndexes) { + const dx = plotPx(index) - x + const dy = plotPy(index) - y + const distance = dx * dx + dy * dy + if (distance <= resultDistance) { + resultIndex = index + resultDistance = distance + } + } + return resultIndex < 0 ? null : (points[resultIndex] ?? null) +} + +function quadtreeNearestPoint( + _points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + return quadtreeIndex.find(x, y, Math.max(0, maxDistance)) ?? null +} + +function delaunayNearestPoint( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + return pointWithinDistance( + points[delaunayIndex.find(x, y)], + x, + y, + maxDistance, + ) +} + +function coherentDelaunayNearestPoint( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + delaunayCursor = delaunayIndex.find(x, y, delaunayCursor) + return pointWithinDistance(points[delaunayCursor], x, y, maxDistance) +} + +function pointWithinDistance( + point: ChartPoint | undefined, + x: number, + y: number, + maxDistance: number, +) { + if (!point) return null + const dx = point.x - x + const dy = point.y - y + return dx * dx + dy * dy <= Math.max(0, maxDistance) ** 2 ? point : null +} + +// Vega's Canvas picker traverses the scene in reverse paint order and first +// rejects items against cached bounds. This intentionally stops before Vega's +// mark-specific Canvas path test, so it is a lower bound rather than a claim +// about full Vega interaction performance. +function vegaBoundsNearestPoint( + _points: readonly ChartPoint[], + x: number, + y: number, + _maxDistance: number, +) { + return vegaPickVisit(vegaRectItems, (item) => + item.bounds.contains(x, y) ? item.point : null, + ) +} + +function vegaPickVisit( + items: readonly TItem[], + visitor: (item: TItem) => TResult | null, +) { + for (let index = items.length; index--;) { + const hit = visitor(items[index]!) + if (hit) return hit + } + return null +} + +function pocNearestPoint< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + let containing: ChartPoint | undefined + let containingAnchorDistance = Infinity + for (const candidate of points) { + if (!candidate.hitRegion || !pocContains(candidate.hitRegion, x, y)) { + continue + } + const dx = candidate.x - x + const dy = candidate.y - y + const anchorDistance = dx * dx + dy * dy + if (anchorDistance <= containingAnchorDistance) { + containing = candidate + containingAnchorDistance = anchorDistance + } + } + if (containing) return containing + + let result: ChartPoint | undefined + let resultDistance = { primary: Infinity, geometry: Infinity } + for (const candidate of points) { + const distance = pocFallbackDistance(candidate, x, y) + if ( + distance.primary < resultDistance.primary || + (distance.primary === resultDistance.primary && + distance.geometry < resultDistance.geometry) + ) { + result = candidate + resultDistance = distance + } + } + return result && resultDistance.primary <= Math.max(0, maxDistance) ** 2 + ? result + : null +} + +function pocFallbackDistance(point: ChartPoint, x: number, y: number) { + const affinity = point.focusAffinity ?? 'xy' + if (affinity === 'geometry') { + return { primary: Infinity, geometry: Infinity } + } + const geometry = pocSquaredDistanceToRegion(point, x, y) + if (affinity === 'x' || affinity === 'y') { + const coordinate = affinity === 'x' ? 0 : 1 + const [minimum, maximum] = pocExtentForPoint(point, coordinate) + const value = coordinate === 0 ? x : y + const distance = + value < minimum ? minimum - value : value > maximum ? value - maximum : 0 + return { primary: distance * distance, geometry } + } + return { primary: geometry, geometry } +} + +function pocSquaredDistanceToRegion(point: ChartPoint, x: number, y: number) { + const region = point.hitRegion + if (!region) { + const dx = point.x - x + const dy = point.y - y + return dx * dx + dy * dy + } + if (region.kind === 'rect') { + const [left, right] = ordered(region.x, region.x + region.width) + const [top, bottom] = ordered(region.y, region.y + region.height) + const dx = x < left ? left - x : x > right ? x - right : 0 + const dy = y < top ? top - y : y > bottom ? y - bottom : 0 + return dx * dx + dy * dy + } + if (region.kind === 'circle') { + const dx = x - region.x + const dy = y - region.y + const distance = Math.sqrt(dx * dx + dy * dy) + const outside = Math.max(0, distance - Math.max(0, region.radius)) + return outside * outside + } + if (pocContains(region, x, y)) return 0 + let distance = Infinity + for (let index = 0; index < region.points.length; index += 1) { + const start = region.points[index]! + const end = region.points[(index + 1) % region.points.length]! + distance = Math.min( + distance, + pocSquaredDistanceToSegment(x, y, start[0], start[1], end[0], end[1]), + ) + } + return distance +} + +function pocContains( + region: NonNullable, + x: number, + y: number, +) { + if (region.kind === 'rect') { + const [left, right] = ordered(region.x, region.x + region.width) + const [top, bottom] = ordered(region.y, region.y + region.height) + return x >= left && x <= right && y >= top && y <= bottom + } + if (region.kind === 'circle') { + const dx = x - region.x + const dy = y - region.y + return dx * dx + dy * dy <= Math.max(0, region.radius) ** 2 + } + + let inside = false + for ( + let index = 0, previous = region.points.length - 1; + index < region.points.length; + previous = index++ + ) { + const current = region.points[index]! + const prior = region.points[previous]! + if ( + current[1] > y !== prior[1] > y && + x < + ((prior[0] - current[0]) * (y - current[1])) / (prior[1] - current[1]) + + current[0] + ) { + inside = !inside + } + } + return inside +} + +function pocExtentForPoint( + point: ChartPoint, + coordinate: 0 | 1, +): readonly [number, number] { + const region = point.hitRegion + if (!region) { + const value = coordinate === 0 ? point.x : point.y + return [value, value] + } + if (region.kind === 'rect') { + const value = coordinate === 0 ? region.x : region.y + const size = coordinate === 0 ? region.width : region.height + return ordered(value, value + size) + } + if (region.kind === 'circle') { + const value = coordinate === 0 ? region.x : region.y + return [value - region.radius, value + region.radius] + } + + let minimum = Infinity + let maximum = -Infinity + for (const vertex of region.points) { + minimum = Math.min(minimum, vertex[coordinate]) + maximum = Math.max(maximum, vertex[coordinate]) + } + const fallback = coordinate === 0 ? point.x : point.y + return minimum <= maximum ? [minimum, maximum] : [fallback, fallback] +} + +function pocSquaredDistanceToSegment( + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number, +) { + const dx = x2 - x1 + const dy = y2 - y1 + const length = dx * dx + dy * dy + if (!length) return (x - x1) ** 2 + (y - y1) ** 2 + const amount = Math.max( + 0, + Math.min(1, ((x - x1) * dx + (y - y1) * dy) / length), + ) + const offsetX = x - (x1 + amount * dx) + const offsetY = y - (y1 + amount * dy) + return offsetX * offsetX + offsetY * offsetY +} + +function ordered(left: number, right: number): readonly [number, number] { + return left <= right ? [left, right] : [right, left] +} + +function verifyEquivalentResults() { + for (const benchmark of cases) { + for (const query of benchmark.queries) { + const reference = pocNearestPoint( + benchmark.points, + query.x, + query.y, + query.maxDistance, + ) + const optimized = nearestPoint( + benchmark.points, + query.x, + query.y, + query.maxDistance, + ) + if (reference?.key !== optimized?.key) { + throw new Error( + `Pointer resolver changed ${benchmark.label}: ${String(reference?.key)} !== ${String(optimized?.key)}`, + ) + } + } + } +} + +function verifyComparisonResults() { + delaunayCursor = 0 + for (const query of comparisonCase.queries) { + const reference = anchorNearestPoint( + comparisonCase.points, + query.x, + query.y, + query.maxDistance, + ) + for (const [label, resolver] of comparisonImplementations.slice(1)) { + const candidate = resolver( + comparisonCase.points, + query.x, + query.y, + query.maxDistance, + ) + if (reference?.key !== candidate?.key) { + throw new Error( + `Point comparison changed ${label}: ${String(reference?.key)} !== ${String(candidate?.key)}`, + ) + } + } + } + delaunayCursor = 0 +} + +function verifyRectangleComparisonResults() { + for (const query of rectangleComparisonCase.queries) { + const reference = nearestPoint( + rectangleComparisonCase.points, + query.x, + query.y, + query.maxDistance, + ) + const candidate = vegaBoundsNearestPoint( + rectangleComparisonCase.points, + query.x, + query.y, + query.maxDistance, + ) + if (reference?.key !== candidate?.key) { + throw new Error( + `Rectangle comparison changed Vega bounds lower bound: ${String(reference?.key)} !== ${String(candidate?.key)}`, + ) + } + } +} + +function printAsciiDurations( + measurements: ReadonlyMap, + resolvers: readonly Implementation[], +) { + const medians = resolvers.map(([label]) => ({ + label, + value: percentile(measurements.get(label)!, 0.5), + })) + const maximum = Math.max(...medians.map(({ value }) => value)) + const labelWidth = Math.max(...medians.map(({ label }) => label.length)) + console.log('\nMedian query time · shorter is faster') + for (const { label, value } of medians) { + const width = Math.max(1, Math.round((value / maximum) * 40)) + console.log( + `${label.padEnd(labelWidth)} ${'#'.repeat(width).padEnd(40)} ${formatDuration(value)}`, + ) + } +} + +function printScenarioComparisons( + scenarios: readonly { + benchmark: PointerCase + measurements: ReadonlyMap + }[], +) { + console.log('\nCurrent production vs new geometry · median query time') + for (const { benchmark, measurements } of scenarios) { + const current = percentile(measurements.get('anchor-only baseline')!, 0.5) + const optimized = percentile(measurements.get('optimized geometry')!, 0.5) + const maximum = Math.max(current, optimized) + const currentWidth = Math.max(1, Math.round((current / maximum) * 36)) + const optimizedWidth = Math.max(1, Math.round((optimized / maximum) * 36)) + console.log(`\n${benchmark.label}`) + console.log( + `current prod ${'#'.repeat(currentWidth).padEnd(36)} ${formatDuration(current)}`, + ) + console.log( + `new geometry ${'#'.repeat(optimizedWidth).padEnd(36)} ${formatDuration(optimized)}`, + ) + } +} + +function printIndexBuildTimes() { + const builders = [ + [ + 'D3 quadtree 3.0.1', + () => + quadtree>() + .x((point) => point.x) + .y((point) => point.y) + .addAll([...densePoints]) + .size(), + ], + [ + 'D3 Delaunay.from 6.0.4', + () => + Delaunay.from( + densePoints, + (point) => point.x, + (point) => point.y, + ).triangles.length, + ], + ] as const + const rows = builders.map(([label, build]) => { + for (let index = 0; index < 3; index += 1) checksum += build() + const samples: number[] = [] + for (let index = 0; index < 18; index += 1) { + collectGarbage?.() + const startedAt = performance.now() + checksum += build() + samples.push(performance.now() - startedAt) + } + samples.sort((left, right) => left - right) + return { label, samples } + }) + console.log('\nOne-time index construction · 10k points') + console.log('| Index | Median build | p95 build |') + console.log('| --- | ---: | ---: |') + for (const { label, samples } of rows) { + console.log( + `| ${label} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} |`, + ) + } +} + +function printIndexStorage() { + let internalNodes = 0 + let leafNodes = 0 + quadtreeIndex.visit((node) => { + if (node.length) internalNodes += 1 + else leafNodes += 1 + return false + }) + const delaunayArrays: readonly ArrayBufferView[] = [ + delaunayIndex.points as Float64Array, + delaunayIndex.triangles, + delaunayIndex.halfedges, + delaunayIndex.hull, + delaunayIndex.inedges, + ] + const delaunayBytes = delaunayArrays.reduce( + (total, array) => total + array.byteLength, + 0, + ) + + console.log('\nPersistent index structure · 10k-point fixture') + console.log('| Index | Measured retained structure |') + console.log('| --- | ---: |') + console.log( + `| D3 quadtree 3.0.1 | ${internalNodes.toLocaleString()} internal arrays + ${leafNodes.toLocaleString()} leaf objects |`, + ) + console.log( + `| D3 Delaunay 6.0.4 | at least ${formatStorage(delaunayBytes)} in public typed arrays |`, + ) + console.log( + 'Object headers, accessors, internal fields, and the original points are excluded.', + ) +} + +function point( + index: number, + x: number, + y: number, + hitRegion?: ChartPoint['hitRegion'], + focusAffinity?: ChartPoint['focusAffinity'], +): ChartPoint { + return { + key: `point:${index}`, + markId: 'benchmark', + group: null, + groupLabel: 'benchmark', + datum: { index }, + datumIndex: index, + xValue: x, + yValue: y, + x, + y, + hitRegion, + focusAffinity, + color: 'currentColor', + } +} + +function queries( + count: number, + create: (index: number) => PointerQuery, +): readonly PointerQuery[] { + return Array.from({ length: count }, (_, index) => create(index)) +} + +function percentile(samples: readonly number[], value: number) { + return samples[Math.floor((samples.length - 1) * value)] ?? 0 +} + +function formatDuration(milliseconds: number) { + return milliseconds < 1 + ? `${(milliseconds * 1_000).toFixed(1)} µs` + : `${milliseconds.toFixed(2)} ms` +} + +function formatStorage(bytes: number) { + return `${(bytes / 1_024).toFixed(1)} KiB` +} diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index 0d200f5b..bf2adf40 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -111,6 +111,37 @@ Each point should retain: - resolved pixel coordinates; - group identity and color. +For a large painted mark, set `hitRegion` on each emitted point and declare +the mark's off-shape fallback once with `focusAffinity`: + +```ts +return { + id, + focusAffinity: 'x', + channels, + render(context) { + return { + nodes, + points: nodes.map((node, index) => ({ + ...interactionPoint(index), + hitRegion: { + kind: 'rect', + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }, + })), + } + }, +} +``` + +Use `x` for vertically oriented marks, `y` for horizontal marks, `xy` for +ordinary two-dimensional proximity, and `geometry` when only exact +containment should focus the mark. The default resolver checks containment +across every mark before applying any fallback. + Omit points for decorative geometry. Do not invent fake interactive data for a frame, grid, or threshold that should not receive focus. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index fac5fbcf..83669685 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -27,6 +27,36 @@ With no custom focus strategy: normal tab-order participation while keeping keyboard handling enabled. Set `keyboard: false` to remove keyboard navigation and force tab index `-1`. +### Interaction geometry + +When neither `focus` nor `spatialIndex` is supplied, pointer resolution has two +stages: + +1. The topmost scene point whose `hitRegion` contains the pointer wins. Point + order follows paint order. +2. If no region contains the pointer, `focusAffinity` ranks the fallback. + `x` and `y` compare distance from that axis boundary first and use complete + geometry distance to break ties; `xy` compares complete geometry distance; + `geometry` has no off-shape fallback. + +`maxFocusDistance` applies to that primary boundary distance, not necessarily +to the point used as the tooltip and keyboard anchor. Points without either +field retain anchor-based two-dimensional distance. + +`ChartPoint.hitRegion` accepts painted rectangles, circles, and polygons. +Built-in marks declare these natural defaults: + +| Mark | Hit region | Fallback | +| ------------------------ | ---------- | -------- | +| `barY` | Rectangle | `x` | +| `barX` | Rectangle | `y` | +| `lineY`, `areaY` | None | `x` | +| `rect`, `dot`, `hexagon` | Shape | `xy` | +| `bandX` | Rectangle | `x` | +| `bandY` | Rectangle | `y` | + +An explicit focus preset or custom strategy replaces this default resolver. + ## Focus modes Use a preset for built-in focus behavior: @@ -284,6 +314,11 @@ owns its search algorithm and must apply `maxDistance`. Use the granular spatial primitive appropriate to the data; the boundary is described in [Scales and D3](../concepts/scales-and-d3.md). +Supplying an index also replaces default hit-region containment and affinity +ranking; the host does not add a linear safety scan after an indexed query. An +index that wants identical geometry semantics should index the region bounds +and perform exact shape checks on its candidates. + A custom `focus` strategy takes precedence over `spatialIndex` for pointer resolution. diff --git a/docs/reference/runtime-and-scene.md b/docs/reference/runtime-and-scene.md index 4ba0e901..20c2d51c 100644 --- a/docs/reference/runtime-and-scene.md +++ b/docs/reference/runtime-and-scene.md @@ -131,8 +131,9 @@ function findNearestPoint< ``` Coordinates are in scene pixels. `maxDistance` defaults to `Infinity`. -The function performs a linear scan of the scene's interaction points. For a -large point set, supply a spatial index to the DOM or framework host; see +The function performs a linear scan of the scene's interaction points, using +hit-region containment and mark affinity before anchor distance. For a large +point set, supply a spatial index to the DOM or framework host; see [Focus and interaction](./focus-and-interaction.md#spatial-indexes). ## `ChartScene` diff --git a/docs/reference/types.md b/docs/reference/types.md index a0ab5e43..7b5705e2 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -137,6 +137,10 @@ The complete overloads and runtime rules are in | `MarkScene` | Mark-owned scene nodes and optional interaction points | | `ChartScene` | Complete renderer-neutral output | | `ChartPoint` | Typed interaction target | +| `ChartHitRegion` | Rectangle, circle, or polygon interaction geometry union | +| `ChartRectHitRegion` | Painted rectangular interaction bounds | +| `ChartCircleHitRegion` | Painted circular interaction bounds | +| `ChartPolygonHitRegion` | Painted polygon interaction vertices | | `ChartTick` | Semantic value, formatted label, and pixel position | | `ResolvedScale` | Final positional scale | | `ResolvedColorScale` | Final color scale | @@ -227,6 +231,7 @@ See [DOM host](./dom-host.md) and | `ChartFocusSource` | Pointer, keyboard, programmatic, or restored source | | `ChartFocusFilter` | Focus-filtered mark matching configuration | | `ChartFocusMatch` | Primary, group, key, x, y, or series matching | +| `ChartFocusAffinity` | Mark fallback axis after exact hit-region containment | | `ChartSpatialIndex` | Nearest-point query | | `ChartSpatialIndexFactory` | Builds an index from current scene points | | `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md new file mode 100644 index 00000000..9560310d --- /dev/null +++ b/examples/sandbox/README.md @@ -0,0 +1,19 @@ +# Charts sandbox + +The private sandbox contains two complementary development surfaces: + +- `pnpm dev:sandbox` opens the integrated data dashboard at `/`; +- `pnpm dev:interaction-geometry` opens the interaction geometry lab at + `/?lab=interaction-geometry`. + +## Interaction geometry lab + +The lab compares legacy anchor-only focus with the current two-stage resolver: +painted-shape containment first, then the mark's declared `x`, `y`, `xy`, or +`geometry` fallback. Every chart keeps its source disclosure directly beneath +the rendered result. + +Add a focused case in `src/InteractionGeometryLab.tsx` when a distinct mark +family or interaction shape exercises behavior not already represented. Keep +resolver invariants in Charts core tests; the lab test only locks each case's +before-and-after outcome and the contextual source contract. diff --git a/examples/sandbox/package.json b/examples/sandbox/package.json index 70ac8c9a..6ce9d1b4 100644 --- a/examples/sandbox/package.json +++ b/examples/sandbox/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite --host --port 5183 --strictPort" + "dev": "vite --host --port 5183 --strictPort", + "dev:interaction-geometry": "vite --host --port 5183 --strictPort --open '/?lab=interaction-geometry'" }, "dependencies": { "@charts-poc/demo-data": "workspace:*", diff --git a/examples/sandbox/src/InteractionGeometryLab.test.ts b/examples/sandbox/src/InteractionGeometryLab.test.ts new file mode 100644 index 00000000..75c5392d --- /dev/null +++ b/examples/sandbox/src/InteractionGeometryLab.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { createChartScene } from '../../../packages/charts-core/src/scene' +import { nearestPoint } from '../../../packages/charts-core/src/nearest' +import type { ChartPoint } from '@tanstack/charts' +import { + InteractionGeometryLab, + proofCases, + type ProofDatum, +} from './InteractionGeometryLab' + +describe('interaction geometry source disclosure', () => { + it('renders contextual source beneath every before and after chart', () => { + const markup = renderToStaticMarkup(createElement(InteractionGeometryLab)) + + expect(markup.match(/class="hit-region-proof__source"/g)).toHaveLength( + proofCases.length * 2, + ) + expect(proofCases).toHaveLength(12) + expect(markup).toContain('const stackedSeries = [') + expect(markup).toContain('polarGuideMark('polar-sector-guides')') + expect(markup).toContain( + 'normalizedPolygonMark('radar', radar, 'polar')', + ) + expect(markup).toContain( + 'const beforeDefinition = interactiveDefinition(verticalBase, legacyPointFocus)', + ) + expect(markup).toContain( + 'const afterDefinition = interactiveDefinition(verticalBase)', + ) + expect(markup).toContain('focusAffinity: 'geometry'') + expect(markup).toContain( + 'hitRegion: { kind: 'polygon', points: polygon }', + ) + expect(markup).not.toContain('packages/charts-core/src/nearest.ts') + }) +}) + +describe('interaction geometry proof gallery', () => { + for (const proof of proofCases) { + it(`${proof.id} demonstrates its before and after result`, () => { + const scene = createChartScene(proof.after, { + width: 520, + height: 230, + }) + const probe = proof.probe(scene) + expect(probe).not.toBeNull() + if (!probe) return + + const before = legacyNearest(scene.points, probe.x, probe.y, 48) + const after = nearestPoint(scene.points, probe.x, probe.y, 48) + + expect(before?.datum.label ?? 'Nothing focused yet').toBe( + proof.beforeExpected, + ) + expect(after?.datum.label ?? 'Nothing focused yet').toBe( + proof.afterExpected, + ) + }) + } + + it('selects the nearest stacked segment outside the stack', () => { + const proof = proofCases.find( + (candidate) => candidate.id === 'stacked-bars', + ) + expect(proof).toBeDefined() + if (!proof) return + + const scene = createChartScene(proof.after, { width: 520, height: 230 }) + const top = scene.points.find((point) => point.datum.id === 'october-other') + const bottom = scene.points.find( + (point) => point.datum.id === 'october-disease', + ) + expect(top?.hitRegion?.kind).toBe('rect') + expect(bottom?.hitRegion?.kind).toBe('rect') + if (top?.hitRegion?.kind !== 'rect' || bottom?.hitRegion?.kind !== 'rect') + return + + const above = Math.min( + top.hitRegion.y, + top.hitRegion.y + top.hitRegion.height, + ) + const below = Math.max( + bottom.hitRegion.y, + bottom.hitRegion.y + bottom.hitRegion.height, + ) + + expect(nearestPoint(scene.points, top.x, above - 12, 48)?.datum.id).toBe( + 'october-other', + ) + expect(nearestPoint(scene.points, bottom.x, below + 12, 48)?.datum.id).toBe( + 'october-disease', + ) + }) +}) + +function legacyNearest( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + let result: ChartPoint | undefined + let distance = Number.POSITIVE_INFINITY + for (const point of points) { + const candidate = (point.x - x) ** 2 + (point.y - y) ** 2 + if (candidate < distance) { + result = point + distance = candidate + } + } + return distance <= maxDistance ** 2 ? result : null +} diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx new file mode 100644 index 00000000..34066f9c --- /dev/null +++ b/examples/sandbox/src/InteractionGeometryLab.tsx @@ -0,0 +1,1110 @@ +import * as React from 'react' +import { + areaY, + barX, + barY, + createMark, + defineChart, + dot, + rect, + stack, + type ChartFocusAffinity, + type ChartFocusStrategy, + type ChartPoint, + type ChartRenderContext, + type ChartScene, + type SceneNode, + type StaticChartDefinition, +} from '@tanstack/charts' +import { tooltip } from '@tanstack/charts/tooltip' +import { Chart } from '@tanstack/react-charts' +import { scaleBand, scaleLinear } from 'd3-scale' +import proofSource from './InteractionGeometryLab.tsx?raw' + +export interface ProofDatum { + id: string + label: string + x?: number + y?: number + x1?: number + x2?: number + y1?: number + y2?: number + value?: number + series?: string + radius?: number + polygon?: readonly (readonly [number, number])[] + probe?: readonly [number, number] + color?: string +} + +interface ProofCase { + id: string + title: string + affinity: ChartFocusAffinity + explanation: string + beforeExpected: string + afterExpected: string + source: string + before: StaticChartDefinition + after: StaticChartDefinition + probe: ( + scene: ChartScene, + ) => { x: number; y: number } | null +} + +const legacyPointFocus: ChartFocusStrategy = { + resolve(points, x, y, maxDistance) { + let nearest: ChartPoint | undefined + let nearestDistance = Number.POSITIVE_INFINITY + for (const point of points) { + const distance = (point.x - x) ** 2 + (point.y - y) ** 2 + if (distance < nearestDistance) { + nearest = point + nearestDistance = distance + } + } + return nearest && nearestDistance <= Math.max(0, maxDistance) ** 2 + ? [nearest] + : [] + }, + group(_points, point) { + return [point] + }, + navigation(points) { + return points + }, +} + +export const proofCases = createProofCases() + +export function InteractionGeometryLab() { + return ( +
+
+

Interaction geometry

+

+ Each comparison uses the same pointer position and 48px threshold. + Before measures from a mark’s anchor point. After tests its painted + shape first, then uses the mark’s natural interaction axis. +

+
+ +
+ {proofCases.map((proof) => ( +
+
+

{proof.title}

+ fallback: {proof.affinity} +
+

+ {proof.explanation} +

+
+ + +
+
+ ))} +
+
+ ) +} + +function SourceIcon() { + return ( + + ) +} + +function ProofChart({ + proof, + mode, +}: { + proof: ProofCase + mode: 'before' | 'after' +}) { + const [focused, setFocused] = React.useState(null) + const [probe, setProbe] = React.useState<{ x: number; y: number } | null>( + null, + ) + const onRender = React.useCallback( + (context: ChartRenderContext) => { + const next = proof.probe(context.scene) + if (!next) return + setProbe((current) => + current && current.x === next.x && current.y === next.y + ? current + : next, + ) + }, + [proof], + ) + const expected = + mode === 'before' ? proof.beforeExpected : proof.afterExpected + const actual = focused?.label ?? 'Nothing focused yet' + + return ( +
+
+ {mode} + 48px +
+ +
+ setFocused(point?.datum ?? null)} + onRender={onRender} + /> + {probe ? ( + + ) : null} +
+ +
+
+
Expected
+
{expected}
+
+
+
Actually focused
+
{actual}
+
+
+ + +
+ ) +} + +function ChartSourceDisclosure({ + proof, + mode, +}: { + proof: ProofCase + mode: 'before' | 'after' +}) { + const source = sourceForMode(proof.source, mode) + + return ( +
+ + + + Source + + + {lineCount(source).toLocaleString('en-US')} lines · {mode} + + +
+        {source}
+      
+
+ ) +} + +function sourceForMode(source: string, mode: 'before' | 'after') { + const baseName = /const (\w+Base)\s*=/.exec(source)?.[1] ?? 'base' + const focus = mode === 'before' ? ', legacyPointFocus' : '' + const helpers = source.includes('normalizedRectMark(') + ? [sourceSection('normalized-rect-mark')] + : source.includes('normalizedPolygonMark(') + ? [sourceSection('normalized-polygon-mark')] + : [] + if (source.includes('polarGuideMark(')) { + helpers.unshift(sourceSection('polar-guide-mark')) + } + return [ + source, + ...helpers, + `const ${mode}Definition = interactiveDefinition(${baseName}${focus})`, + ].join('\n\n') +} + +function sourceSection(id: string) { + const startMarker = `// source:${id}:start` + const endMarker = `// source:${id}:end` + const start = proofSource.indexOf(startMarker) + const end = proofSource.indexOf(endMarker) + if (start < 0 || end <= start) { + throw new TypeError(`Missing proof source section: ${id}`) + } + return proofSource + .slice(start + startMarker.length, end) + .trim() + .replace(/^ {2}/gm, '') +} + +function lineCount(source: string) { + return source ? source.split('\n').length : 0 +} + +function createProofCases(): ProofCase[] { + // source:stacked-bars:start + const stackedMonths = [ + { month: 'May', disease: 86, wounds: 26, other: 14 }, + { month: 'June', disease: 80, wounds: 31, other: 11 }, + { month: 'July', disease: 92, wounds: 25, other: 18 }, + { month: 'August', disease: 84, wounds: 34, other: 12 }, + { month: 'September', disease: 72, wounds: 29, other: 16 }, + { month: 'October', disease: 210, wounds: 96, other: 48 }, + { month: 'November', disease: 76, wounds: 35, other: 15 }, + { month: 'December', disease: 88, wounds: 28, other: 20 }, + { month: 'January', disease: 81, wounds: 32, other: 17 }, + { month: 'February', disease: 94, wounds: 27, other: 13 }, + { month: 'March', disease: 85, wounds: 30, other: 19 }, + ] as const + const stackedSeries = ['disease', 'wounds', 'other'] as const + const verticalBars = stackedMonths.flatMap((month, x) => + stackedSeries.map((series) => ({ + id: `${month.month.toLowerCase()}-${series}`, + label: `${month.month} · ${series} · ${month[series]}`, + x, + y: month[series], + series, + })), + ) satisfies ProofDatum[] + const verticalBase = baseDefinition( + [ + barY(verticalBars, { + id: 'vertical-bars', + x: 'x', + y: 'y', + z: 'series', + key: 'id', + layout: stack({ order: stackedSeries }), + inset: 10, + }), + ], + scaleBand() + .domain(verticalBars.map((datum) => datum.x)) + .padding(0.15), + scaleLinear().domain([0, 380]), + ) + // source:stacked-bars:end + + // source:horizontal-bars:start + const horizontalBars = [ + { id: 'long', label: 'Long ranking bar · 190', x: 190, y: 0 }, + { id: 'near', label: 'Neighbor endpoint · 82', x: 82, y: 1 }, + { id: 'third', label: 'Third bar · 130', x: 130, y: 2 }, + { id: 'fourth', label: 'Fourth bar · 105', x: 105, y: 3 }, + { id: 'fifth', label: 'Fifth bar · 118', x: 118, y: 4 }, + ] satisfies ProofDatum[] + const horizontalBase = baseDefinition( + [ + barX(horizontalBars, { + id: 'horizontal-bars', + x: 'x', + y: 'y', + key: 'id', + inset: 5, + }), + ], + scaleLinear().domain([0, 210]), + scaleBand().domain([0, 1, 2, 3, 4]).padding(0.12), + ) + // source:horizontal-bars:end + + // source:line-area:start + const areaRows = [ + { id: 'left', label: 'Left sample · 84', x: 0, y: 84 }, + { id: 'middle', label: 'Middle slice · 205', x: 1, y: 205 }, + { id: 'right', label: 'Right sample · 92', x: 2, y: 92 }, + ] satisfies ProofDatum[] + const areaBase = baseDefinition( + [ + areaY(areaRows, { + id: 'area', + x: 'x', + y: 'y', + key: 'id', + fillOpacity: 0.42, + stroke: '#404040', + strokeWidth: 2, + }), + ], + scaleLinear().domain([0, 2]), + scaleLinear().domain([0, 220]), + ) + // source:line-area:end + + // source:bubbles:start + const bubbles = [ + { + id: 'large-bubble', + label: 'Large bubble', + x: 40, + y: 55, + radius: 46, + }, + { + id: 'small-bubble', + label: 'Nearby small bubble', + x: 49, + y: 55, + radius: 5, + }, + { id: 'other-bubble', label: 'Other bubble', x: 78, y: 28, radius: 12 }, + ] satisfies ProofDatum[] + const bubbleBase = baseDefinition( + [ + dot(bubbles, { + id: 'bubbles', + x: 'x', + y: 'y', + r: 'radius', + key: 'id', + fillOpacity: 0.7, + stroke: '#ffffff', + strokeWidth: 1.5, + }), + ], + scaleLinear().domain([0, 100]), + scaleLinear().domain([0, 100]), + ) + // source:bubbles:end + + // source:cells:start + const cells = [ + { + id: 'large-cell', + label: 'Large heatmap / treemap cell', + x1: 0, + x2: 70, + y1: 0, + y2: 100, + color: '#404040', + }, + { + id: 'small-cell', + label: 'Neighbor cell', + x1: 70.5, + x2: 79, + y1: 42, + y2: 58, + color: '#a3a3a3', + }, + ] satisfies ProofDatum[] + const cellBase = baseDefinition( + [ + rect(cells, { + id: 'cells', + x1: 'x1', + x2: 'x2', + y1: 'y1', + y2: 'y2', + key: 'id', + color: 'id', + inset: 2, + }), + ], + scaleLinear().domain([0, 100]), + scaleLinear().domain([0, 100]), + ) + // source:cells:end + + // source:financial:start + const financial = [ + { + id: 'tall-candle', + label: 'Tall candle / box plot', + x1: 0.34, + x2: 0.42, + y1: 0.08, + y2: 0.92, + probe: [0.38, 0.78], + color: '#404040', + }, + { + id: 'near-candle', + label: 'Nearby candle endpoint', + x1: 0.445, + x2: 0.49, + y1: 0.67, + y2: 0.79, + color: '#a3a3a3', + }, + ] satisfies ProofDatum[] + const financialBase = baseDefinition( + [normalizedRectMark('financial', financial, 'x')], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:financial:end + + // source:timeline:start + const timeline = [ + { + id: 'long-task', + label: 'Long Gantt interval', + x1: 0.08, + x2: 0.83, + y1: 0.25, + y2: 0.43, + probe: [0.76, 0.34], + color: '#404040', + }, + { + id: 'milestone', + label: 'Nearby milestone', + x1: 0.72, + x2: 0.8, + y1: 0.5, + y2: 0.64, + color: '#a3a3a3', + }, + ] satisfies ProofDatum[] + const timelineBase = baseDefinition( + [normalizedRectMark('timeline', timeline, 'y')], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:timeline:end + + // source:pie:start + const pie = [ + { + id: 'large-slice', + label: 'Large pie / donut slice', + polygon: [ + [0.5, 0.5], + [0.5, 0.08], + [0.86, 0.22], + [0.9, 0.56], + ], + probe: [0.82, 0.23], + color: '#404040', + }, + { + id: 'small-slice', + label: 'Neighbor slice centroid', + polygon: [ + [0.5, 0.5], + [0.9, 0.56], + [0.69, 0.9], + ], + color: '#a3a3a3', + }, + { + id: 'last-slice', + label: 'Last slice', + polygon: [ + [0.5, 0.5], + [0.69, 0.9], + [0.16, 0.7], + [0.5, 0.08], + ], + color: '#d4d4d4', + }, + ] satisfies ProofDatum[] + const pieBase = baseDefinition( + [normalizedPolygonMark('pie', pie)], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:pie:end + + // source:polar-sectors:start + const annularSector = ( + startAngle: number, + endAngle: number, + innerRadius: number, + outerRadius: number, + ) => { + const center = 0.5 + const steps = 10 + const angles = Array.from( + { length: steps + 1 }, + (_, index) => startAngle + ((endAngle - startAngle) * index) / steps, + ) + const point = (angle: number, radius: number) => + [ + center + Math.cos(angle) * radius, + center + Math.sin(angle) * radius, + ] as const + return [ + ...angles.map((angle) => point(angle, outerRadius)), + ...[...angles].reverse().map((angle) => point(angle, innerRadius)), + ] + } + const polarSectors = [ + { + id: 'long-sector', + label: 'Long radial bar / annular sector', + polygon: annularSector(-2.2, 0.25, 0.15, 0.43), + probe: [0.83, 0.57], + color: '#404040', + }, + { + id: 'near-sector', + label: 'Neighbor sector centroid', + polygon: annularSector(0.29, 0.7, 0.15, 0.43), + color: '#a3a3a3', + }, + { + id: 'remaining-sector', + label: 'Remaining polar sector', + polygon: annularSector(0.74, 4.02, 0.15, 0.43), + color: '#d4d4d4', + }, + ] satisfies ProofDatum[] + const polarSectorBase = baseDefinition( + [ + polarGuideMark('polar-sector-guides'), + normalizedPolygonMark('polar-sectors', polarSectors, 'polar'), + ], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:polar-sectors:end + + // source:radar-area:start + const radar = [ + { + id: 'radar-profile', + label: 'Large radar profile', + polygon: [ + [0.5, 0.1], + [0.76, 0.32], + [0.84, 0.68], + [0.5, 0.82], + [0.2, 0.68], + [0.28, 0.34], + ], + probe: [0.76, 0.36], + color: '#525252', + }, + { + id: 'radar-neighbor', + label: 'Nearby polar point', + polygon: [ + [0.79, 0.27], + [0.82, 0.25], + [0.85, 0.27], + [0.85, 0.31], + [0.82, 0.33], + [0.79, 0.31], + ], + color: '#b3b3b3', + }, + ] satisfies ProofDatum[] + const radarBase = baseDefinition( + [ + polarGuideMark('radar-guides'), + normalizedPolygonMark('radar', radar, 'polar'), + ], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:radar-area:end + + // source:map:start + const map = [ + { + id: 'west-region', + label: 'Large map region', + polygon: [ + [0.08, 0.2], + [0.52, 0.12], + [0.61, 0.42], + [0.44, 0.82], + [0.12, 0.72], + ], + probe: [0.58, 0.4], + color: '#404040', + }, + { + id: 'east-region', + label: 'Neighbor region centroid', + polygon: [ + [0.62, 0.28], + [0.72, 0.22], + [0.73, 0.58], + [0.63, 0.62], + ], + color: '#a3a3a3', + }, + ] satisfies ProofDatum[] + const mapBase = baseDefinition( + [normalizedPolygonMark('map', map)], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:map:end + + // source:sankey:start + const sankey = [ + { + id: 'wide-link', + label: 'Wide Sankey / network link', + polygon: [ + [0.16, 0.2], + [0.84, 0.58], + [0.84, 0.74], + [0.16, 0.36], + ], + probe: [0.76, 0.56], + color: '#525252', + }, + { + id: 'node', + label: 'Nearby node anchor', + polygon: [ + [0.79, 0.36], + [0.89, 0.36], + [0.89, 0.52], + [0.79, 0.52], + ], + color: '#a3a3a3', + }, + ] satisfies ProofDatum[] + const sankeyBase = baseDefinition( + [normalizedPolygonMark('sankey', sankey)], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:sankey:end + + return [ + makeCase({ + id: 'stacked-bars', + title: 'Stacked bars', + affinity: 'x', + explanation: + 'Containment selects the tall October rectangle; outside all bars, only horizontal category distance matters.', + beforeExpected: 'September · disease · 72', + afterExpected: 'October · disease · 210', + base: verticalBase, + probe: (scene) => pointAtValue(scene, 'october-disease', 'y', 72), + }), + makeCase({ + id: 'horizontal-bars', + title: 'Rankings and horizontal bars', + affinity: 'y', + explanation: + 'The long painted bar wins directly; off-mark fallback stays in its row rather than jumping diagonally.', + beforeExpected: 'Neighbor endpoint · 82', + afterExpected: 'Long ranking bar · 190', + base: horizontalBase, + probe: (scene) => pointAtValue(scene, 'long', 'x', 82), + }), + makeCase({ + id: 'line-area', + title: 'Lines and vertical areas', + affinity: 'x', + explanation: + 'There is no large point to hit, so the mark’s x affinity resolves the sample at the pointer’s time/category.', + beforeExpected: 'Nothing focused yet', + afterExpected: 'Middle slice · 205', + base: areaBase, + probe: (scene) => pointAtValue(scene, 'middle', 'y', 8), + }), + makeCase({ + id: 'bubbles', + title: 'Scatterplots and bubbles', + affinity: 'xy', + explanation: + 'A large circle is targetable across its painted radius before ordinary two-dimensional proximity is considered.', + beforeExpected: 'Nearby small bubble', + afterExpected: 'Large bubble', + base: bubbleBase, + probe: (scene) => circleEdgeProbe(scene, 'large-bubble', 0.78), + }), + makeCase({ + id: 'cells', + title: 'Heatmaps and treemaps', + affinity: 'xy', + explanation: + 'The containing cell wins; outside the cells, distance is measured to their rectangle boundaries in both axes.', + beforeExpected: 'Neighbor cell', + afterExpected: 'Large heatmap / treemap cell', + base: cellBase, + probe: (scene) => rectProbe(scene, 'large-cell', 0.94, 0.5), + }), + makeCase({ + id: 'financial', + title: 'Candlesticks and box plots', + affinity: 'x', + explanation: + 'Tall financial intervals use their body or whisker geometry first, then the natural x position of the observation.', + beforeExpected: 'Nearby candle endpoint', + afterExpected: 'Tall candle / box plot', + base: financialBase, + probe: normalizedDatumProbe('tall-candle'), + }), + makeCase({ + id: 'timeline', + title: 'Timelines and Gantt charts', + affinity: 'y', + explanation: + 'Long intervals remain targetable throughout their body, and nearby fallback follows the task lane.', + beforeExpected: 'Nearby milestone', + afterExpected: 'Long Gantt interval', + base: timelineBase, + probe: normalizedDatumProbe('long-task'), + }), + makeCase({ + id: 'pie', + title: 'Pie and donut slices', + affinity: 'geometry', + explanation: + 'Irregular slices opt out of axis fallback: the pointer must be inside the slice instead of near an arbitrary centroid.', + beforeExpected: 'Nothing focused yet', + afterExpected: 'Large pie / donut slice', + base: pieBase, + probe: normalizedDatumProbe('large-slice'), + }), + makeCase({ + id: 'polar-sectors', + title: 'Radial bars and annular sectors', + affinity: 'geometry', + explanation: + 'Long polar marks remain targetable across their full angular and radial extent instead of only near an arc centroid.', + beforeExpected: 'Neighbor sector centroid', + afterExpected: 'Long radial bar / annular sector', + base: polarSectorBase, + probe: normalizedPolarDatumProbe('long-sector'), + }), + makeCase({ + id: 'radar-area', + title: 'Radar and polar areas', + affinity: 'geometry', + explanation: + 'The radar profile owns its painted interior; a nearby polar sample cannot steal the pointer just because its anchor is closer.', + beforeExpected: 'Nearby polar point', + afterExpected: 'Large radar profile', + base: radarBase, + probe: normalizedPolarDatumProbe('radar-profile'), + }), + makeCase({ + id: 'map', + title: 'Maps and geographic regions', + affinity: 'geometry', + explanation: + 'A region’s polygon is the interaction contract; neighboring centroids no longer steal interior pointer positions.', + beforeExpected: 'Neighbor region centroid', + afterExpected: 'Large map region', + base: mapBase, + probe: normalizedDatumProbe('west-region'), + }), + makeCase({ + id: 'sankey', + title: 'Sankey and network geometry', + affinity: 'geometry', + explanation: + 'Wide links and nodes expose their own shapes, avoiding a global x or y rule that would be wrong for topology.', + beforeExpected: 'Nearby node anchor', + afterExpected: 'Wide Sankey / network link', + base: sankeyBase, + probe: normalizedDatumProbe('wide-link'), + }), + ] +} + +function baseDefinition( + marks: StaticChartDefinition['marks'], + xScale: + | ReturnType> + | ReturnType>, + yScale: + | ReturnType> + | ReturnType>, +): StaticChartDefinition { + return { + marks, + x: { scale: xScale, grid: true }, + y: { scale: yScale, grid: true }, + guides: false, + margin: { top: 18, right: 20, bottom: 18, left: 20 }, + theme: { + foreground: '#171717', + muted: '#737373', + grid: '#e5e5e5', + background: 'transparent', + palette: ['#171717', '#737373', '#d4d4d4', '#a3a3a3', '#525252'], + }, + } +} + +function makeCase( + input: Omit & { + base: StaticChartDefinition + }, +): ProofCase { + const { base, ...proof } = input + return { + ...proof, + source: sourceSection(proof.id), + before: interactiveDefinition(base, legacyPointFocus), + after: interactiveDefinition(base), + } +} + +function interactiveDefinition( + base: StaticChartDefinition, + focus?: ChartFocusStrategy, +) { + return defineChart(base, { + focus, + maxFocusDistance: 48, + animate: false, + tooltip: { + use: tooltip, + className: 'hit-region-proof__tooltip', + sticky: true, + placement: ['top', 'right', 'left', 'bottom'], + format: (point) => point.datum.label, + }, + }) +} + +// source:normalized-rect-mark:start +function normalizedRectMark( + id: string, + data: readonly ProofDatum[], + focusAffinity: ChartFocusAffinity, +) { + return createMark(() => ({ + id, + focusAffinity, + channels: {}, + render: ({ chart }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + if ( + datum.x1 === undefined || + datum.x2 === undefined || + datum.y1 === undefined || + datum.y2 === undefined + ) + return + const x = chart.x + datum.x1 * chart.width + const y = chart.y + datum.y1 * chart.height + const width = (datum.x2 - datum.x1) * chart.width + const height = (datum.y2 - datum.y1) * chart.height + const centerX = x + width / 2 + const centerY = y + height / 2 + nodes.push({ + kind: 'rect', + key: datum.id, + x, + y, + width, + height, + radius: 4, + style: { fill: datum.color ?? '#404040', fillOpacity: 0.78 }, + }) + points.push({ + key: datum.id, + markId: id, + group: null, + groupLabel: id, + datum, + datumIndex, + xValue: centerX, + yValue: centerY, + x: centerX, + y: centerY, + hitRegion: { kind: 'rect', x, y, width, height }, + color: datum.color ?? '#404040', + }) + }) + return { nodes, points } + }, + })) +} +// source:normalized-rect-mark:end + +// source:polar-guide-mark:start +function polarGuideMark(id: string) { + return createMark(() => ({ + id, + channels: {}, + render: ({ chart }) => { + const centerX = chart.x + chart.width / 2 + const centerY = chart.y + chart.height / 2 + const radius = Math.min(chart.width, chart.height) * 0.43 + const nodes: SceneNode[] = [] + for (const ratio of [0.25, 0.5, 0.75, 1]) { + nodes.push({ + kind: 'dot', + key: `${id}:ring:${ratio}`, + x: centerX, + y: centerY, + radius: radius * ratio, + style: { fill: 'none', stroke: '#e5e5e5', strokeWidth: 1 }, + }) + } + for (let index = 0; index < 8; index++) { + const angle = (index / 8) * Math.PI * 2 + nodes.push({ + kind: 'polyline', + key: `${id}:spoke:${index}`, + points: [ + [centerX, centerY], + [ + centerX + Math.cos(angle) * radius, + centerY + Math.sin(angle) * radius, + ], + ], + style: { fill: 'none', stroke: '#e5e5e5', strokeWidth: 1 }, + }) + } + return { nodes, points: [] } + }, + })) +} +// source:polar-guide-mark:end + +// source:normalized-polygon-mark:start +function normalizedPolygonMark( + id: string, + data: readonly ProofDatum[], + coordinate: 'cartesian' | 'polar' = 'cartesian', +) { + return createMark(() => ({ + id, + focusAffinity: 'geometry', + channels: {}, + render: ({ chart }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + const size = Math.min(chart.width, chart.height) + const originX = + coordinate === 'polar' ? chart.x + (chart.width - size) / 2 : chart.x + const originY = + coordinate === 'polar' ? chart.y + (chart.height - size) / 2 : chart.y + const width = coordinate === 'polar' ? size : chart.width + const height = coordinate === 'polar' ? size : chart.height + data.forEach((datum, datumIndex) => { + if (!datum.polygon?.length) return + const polygon = datum.polygon.map( + ([x, y]) => [originX + x * width, originY + y * height] as const, + ) + const x = + polygon.reduce((sum, point) => sum + point[0], 0) / polygon.length + const y = + polygon.reduce((sum, point) => sum + point[1], 0) / polygon.length + nodes.push({ + kind: 'area', + key: datum.id, + points: polygon, + style: { + fill: datum.color ?? '#404040', + fillOpacity: 0.78, + stroke: '#ffffff', + strokeWidth: 1, + }, + }) + points.push({ + key: datum.id, + markId: id, + group: null, + groupLabel: id, + datum, + datumIndex, + xValue: x, + yValue: y, + x, + y, + hitRegion: { kind: 'polygon', points: polygon }, + color: datum.color ?? '#404040', + }) + }) + return { nodes, points } + }, + })) +} +// source:normalized-polygon-mark:end + +function pointAtValue( + scene: ChartScene, + id: string, + axis: 'x' | 'y', + value: number, +) { + const point = scene.points.find((candidate) => candidate.datum.id === id) + const coordinate = scene.scales[axis]?.map(value) + if (!point || !Number.isFinite(coordinate)) return null + return axis === 'x' + ? { x: coordinate!, y: point.y } + : { x: point.x, y: coordinate! } +} + +function circleEdgeProbe( + scene: ChartScene, + id: string, + amount: number, +) { + const point = scene.points.find((candidate) => candidate.datum.id === id) + if (point?.hitRegion?.kind !== 'circle') return null + return { + x: point.hitRegion.x + point.hitRegion.radius * amount, + y: point.hitRegion.y, + } +} + +function rectProbe( + scene: ChartScene, + id: string, + xAmount: number, + yAmount: number, +) { + const point = scene.points.find((candidate) => candidate.datum.id === id) + if (point?.hitRegion?.kind !== 'rect') return null + return { + x: point.hitRegion.x + point.hitRegion.width * xAmount, + y: point.hitRegion.y + point.hitRegion.height * yAmount, + } +} + +function normalizedDatumProbe(id: string) { + return (scene: ChartScene) => { + const point = scene.points.find((candidate) => candidate.datum.id === id) + const probe = point?.datum.probe + if (!probe) return null + return { + x: scene.chart.x + probe[0] * scene.chart.width, + y: scene.chart.y + probe[1] * scene.chart.height, + } + } +} + +function normalizedPolarDatumProbe(id: string) { + return (scene: ChartScene) => { + const point = scene.points.find((candidate) => candidate.datum.id === id) + const probe = point?.datum.probe + if (!probe) return null + const size = Math.min(scene.chart.width, scene.chart.height) + return { + x: scene.chart.x + (scene.chart.width - size) / 2 + probe[0] * size, + y: scene.chart.y + (scene.chart.height - size) / 2 + probe[1] * size, + } + } +} diff --git a/examples/sandbox/src/main.tsx b/examples/sandbox/src/main.tsx index 4271663e..c7460c8d 100644 --- a/examples/sandbox/src/main.tsx +++ b/examples/sandbox/src/main.tsx @@ -1,5 +1,14 @@ import { createRoot } from 'react-dom/client' import './styles.css' import { App } from './App' +import { InteractionGeometryLab } from './InteractionGeometryLab' -createRoot(document.getElementById('root')!).render() +const lab = new URLSearchParams(window.location.search).get('lab') +const showInteractionGeometryLab = lab === 'interaction-geometry' +if (showInteractionGeometryLab) { + document.title = 'Charts interaction geometry lab' +} + +createRoot(document.getElementById('root')!).render( + showInteractionGeometryLab ? : , +) diff --git a/examples/sandbox/src/styles.css b/examples/sandbox/src/styles.css index 82bafecc..923b1bea 100644 --- a/examples/sandbox/src/styles.css +++ b/examples/sandbox/src/styles.css @@ -30,6 +30,359 @@ --ts-chart-5: #45d49c; } +.hit-region-proof { + min-height: 100vh; + padding: 44px clamp(20px, 4vw, 64px) 72px; + color-scheme: light; + background: #ffffff; + color: #171717; +} + +.hit-region-proof__header { + width: min(760px, 100%); + margin: 0 auto 36px; +} + +.hit-region-proof__header h1 { + margin: 0; + color: #171717; + font-size: clamp(30px, 4vw, 44px); + letter-spacing: -0.04em; +} + +.hit-region-proof__header p { + max-width: 700px; + margin: 12px 0 0; + color: #525252; + font-size: 14px; + line-height: 1.6; +} + +.hit-region-proof__source { + width: 100%; + margin: 10px 0 0; + border: 1px solid #e5e5e5; + border-radius: 8px; + background: #ffffff; +} + +.hit-region-proof__source summary { + position: relative; + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 11px 14px 11px 34px; + cursor: pointer; + list-style: none; + color: #262626; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.hit-region-proof__source summary::-webkit-details-marker { + display: none; +} + +.hit-region-proof__source summary:focus-visible { + border-radius: 7px; + outline: 2px solid #171717; + outline-offset: -2px; +} + +.hit-region-proof__source summary::before { + position: absolute; + top: 50%; + left: 15px; + color: #737373; + content: '›'; + font-size: 17px; + line-height: 1; + transform: translateY(-50%); + transition: transform 140ms ease; +} + +.hit-region-proof__source[open] > summary::before { + transform: translateY(-50%) rotate(90deg); +} + +.hit-region-proof__source > summary > span:first-child { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; +} + +.hit-region-proof__source > summary svg { + width: 14px; + height: 14px; + fill: none; + stroke: #525252; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; +} + +.hit-region-proof__source-meta { + overflow: hidden; + color: #737373; + font-size: 10px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hit-region-proof__source > pre { + overflow: auto; + width: 100%; + max-height: 320px; + margin: 0; + padding: 15px 17px; + border: 0; + border-top: 1px solid #e5e5e5; + border-radius: 0; + color: #262626; + background: #fafafa; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + line-height: 1.6; + tab-size: 2; +} + +.hit-region-proof__source code { + font: inherit; +} + +.hit-region-proof__gallery { + display: grid; + width: min(1220px, 100%); + margin: 0 auto; + gap: 34px; +} + +.hit-region-proof__case { + padding-top: 34px; + border-top: 1px solid #e5e5e5; +} + +.hit-region-proof__case:first-child { + padding-top: 0; + border-top: 0; +} + +.hit-region-proof__case-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + margin-bottom: 7px; +} + +.hit-region-proof__case-header h2 { + margin: 0; + color: #171717; + font-size: 21px; + letter-spacing: -0.03em; +} + +.hit-region-proof__case-header code { + padding: 6px 9px; + border: 1px solid #d4d4d4; + border-radius: 6px; + color: #525252; + background: #fafafa; + font-size: 10px; +} + +.hit-region-proof__case-description { + max-width: 780px; + margin: 0 0 12px; + color: #525252; + font-size: 12px; + line-height: 1.55; +} + +.hit-region-proof__grid { + display: grid; + width: 100%; + margin: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.hit-region-proof__card { + min-width: 0; + padding: 15px; + border: 1px solid #d4d4d4; + border-radius: 12px; + background: #ffffff; +} + +.hit-region-proof__card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.hit-region-proof__badge, +.hit-region-proof__distance { + display: inline-flex; + align-items: center; + border: 1px solid #d4d4d4; + border-radius: 999px; + color: #525252; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hit-region-proof__badge { + height: 23px; + padding: 0 9px; +} + +.hit-region-proof__card--before .hit-region-proof__badge { + color: #262626; + background: #ffffff; +} + +.hit-region-proof__card--after .hit-region-proof__badge { + border-color: #171717; + color: #ffffff; + background: #171717; +} + +.hit-region-proof__distance { + height: 28px; + padding: 0 10px; +} + +.hit-region-proof__chart { + position: relative; + overflow: visible; + min-width: 0; +} + +.hit-region-proof__chart > svg, +.hit-region-proof__chart > div:first-child { + min-height: 230px; +} + +.hit-region-proof__chart svg { + overflow: visible; +} + +.hit-region-proof__probe { + position: absolute; + z-index: 3; + width: 0; + height: 0; + pointer-events: none; +} + +.hit-region-proof__probe span { + position: absolute; + width: 24px; + height: 24px; + border: 2px solid #171717; + border-radius: 50%; + background: rgb(255 255 255 / 72%); + box-shadow: 0 0 0 4px rgb(23 23 23 / 10%); + transform: translate(-50%, -50%); +} + +.hit-region-proof__probe span::before, +.hit-region-proof__probe span::after { + position: absolute; + top: 50%; + left: 50%; + background: #171717; + content: ''; + transform: translate(-50%, -50%); +} + +.hit-region-proof__probe span::before { + width: 34px; + height: 1px; +} + +.hit-region-proof__probe span::after { + width: 1px; + height: 34px; +} + +.hit-region-proof__result { + display: grid; + margin: 5px 0 0; + padding: 14px 16px; + border: 1px solid #e5e5e5; + border-radius: 8px; + background: #fafafa; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.hit-region-proof__result div { + min-width: 0; +} + +.hit-region-proof__result dt { + margin-bottom: 5px; + color: #737373; + font-size: 9px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hit-region-proof__result dd { + overflow: hidden; + margin: 0; + color: #262626; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hit-region-proof__card--after [data-proof-result='after'] { + color: #171717; + font-weight: 700; +} + +.hit-region-proof__card--before [data-proof-result='before'] { + color: #525252; +} + +.hit-region-proof__tooltip { + border: 1px solid #d4d4d4 !important; + color: #171717 !important; + background: #ffffff !important; + box-shadow: none !important; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +@media (max-width: 900px) { + .hit-region-proof__grid { + grid-template-columns: 1fr; + } + + .hit-region-proof__case-header { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .hit-region-proof__source-meta { + max-width: 46vw; + } +} + * { box-sizing: border-box; } diff --git a/package.json b/package.json index ce317adf..3b5fbe6e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dev:react": "pnpm --filter @plot-poc/react-example dev", "dev:octane": "pnpm --filter @plot-poc/octane-example dev", "dev:sandbox": "pnpm --filter @charts-poc/sandbox dev", + "dev:interaction-geometry": "pnpm --filter @charts-poc/sandbox dev:interaction-geometry", "demo-data:check": "node scripts/sync-demo-data.mjs --check", "demo-data:sync": "node scripts/sync-demo-data.mjs", "docs:check": "nx run charts-workspace:docs-check", @@ -47,6 +48,7 @@ "conformance:quick": "node scripts/compare-plot-catalog.mjs --profile=quick", "conformance:size": "node scripts/compare-plot-catalog.mjs --size-only", "performance": "node scripts/measure-rendering.mjs", + "performance:pointer": "node --expose-gc scripts/measure-pointer-resolution.mjs", "react-native:poc:bundle": "node scripts/measure-react-native-poc.mjs", "react-native:poc:types": "node scripts/check-react-native-types.mjs", "package:check": "nx run charts-workspace:package-check", 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 0d200f5b..bf2adf40 100644 --- a/packages/charts-core/docs/guides/custom-marks-and-renderers.md +++ b/packages/charts-core/docs/guides/custom-marks-and-renderers.md @@ -111,6 +111,37 @@ Each point should retain: - resolved pixel coordinates; - group identity and color. +For a large painted mark, set `hitRegion` on each emitted point and declare +the mark's off-shape fallback once with `focusAffinity`: + +```ts +return { + id, + focusAffinity: 'x', + channels, + render(context) { + return { + nodes, + points: nodes.map((node, index) => ({ + ...interactionPoint(index), + hitRegion: { + kind: 'rect', + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }, + })), + } + }, +} +``` + +Use `x` for vertically oriented marks, `y` for horizontal marks, `xy` for +ordinary two-dimensional proximity, and `geometry` when only exact +containment should focus the mark. The default resolver checks containment +across every mark before applying any fallback. + Omit points for decorative geometry. Do not invent fake interactive data for a frame, grid, or threshold that should not receive focus. diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index fac5fbcf..83669685 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -27,6 +27,36 @@ With no custom focus strategy: normal tab-order participation while keeping keyboard handling enabled. Set `keyboard: false` to remove keyboard navigation and force tab index `-1`. +### Interaction geometry + +When neither `focus` nor `spatialIndex` is supplied, pointer resolution has two +stages: + +1. The topmost scene point whose `hitRegion` contains the pointer wins. Point + order follows paint order. +2. If no region contains the pointer, `focusAffinity` ranks the fallback. + `x` and `y` compare distance from that axis boundary first and use complete + geometry distance to break ties; `xy` compares complete geometry distance; + `geometry` has no off-shape fallback. + +`maxFocusDistance` applies to that primary boundary distance, not necessarily +to the point used as the tooltip and keyboard anchor. Points without either +field retain anchor-based two-dimensional distance. + +`ChartPoint.hitRegion` accepts painted rectangles, circles, and polygons. +Built-in marks declare these natural defaults: + +| Mark | Hit region | Fallback | +| ------------------------ | ---------- | -------- | +| `barY` | Rectangle | `x` | +| `barX` | Rectangle | `y` | +| `lineY`, `areaY` | None | `x` | +| `rect`, `dot`, `hexagon` | Shape | `xy` | +| `bandX` | Rectangle | `x` | +| `bandY` | Rectangle | `y` | + +An explicit focus preset or custom strategy replaces this default resolver. + ## Focus modes Use a preset for built-in focus behavior: @@ -284,6 +314,11 @@ owns its search algorithm and must apply `maxDistance`. Use the granular spatial primitive appropriate to the data; the boundary is described in [Scales and D3](../concepts/scales-and-d3.md). +Supplying an index also replaces default hit-region containment and affinity +ranking; the host does not add a linear safety scan after an indexed query. An +index that wants identical geometry semantics should index the region bounds +and perform exact shape checks on its candidates. + A custom `focus` strategy takes precedence over `spatialIndex` for pointer resolution. diff --git a/packages/charts-core/docs/reference/runtime-and-scene.md b/packages/charts-core/docs/reference/runtime-and-scene.md index 4ba0e901..20c2d51c 100644 --- a/packages/charts-core/docs/reference/runtime-and-scene.md +++ b/packages/charts-core/docs/reference/runtime-and-scene.md @@ -131,8 +131,9 @@ function findNearestPoint< ``` Coordinates are in scene pixels. `maxDistance` defaults to `Infinity`. -The function performs a linear scan of the scene's interaction points. For a -large point set, supply a spatial index to the DOM or framework host; see +The function performs a linear scan of the scene's interaction points, using +hit-region containment and mark affinity before anchor distance. For a large +point set, supply a spatial index to the DOM or framework host; see [Focus and interaction](./focus-and-interaction.md#spatial-indexes). ## `ChartScene` diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index a0ab5e43..7b5705e2 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -137,6 +137,10 @@ The complete overloads and runtime rules are in | `MarkScene` | Mark-owned scene nodes and optional interaction points | | `ChartScene` | Complete renderer-neutral output | | `ChartPoint` | Typed interaction target | +| `ChartHitRegion` | Rectangle, circle, or polygon interaction geometry union | +| `ChartRectHitRegion` | Painted rectangular interaction bounds | +| `ChartCircleHitRegion` | Painted circular interaction bounds | +| `ChartPolygonHitRegion` | Painted polygon interaction vertices | | `ChartTick` | Semantic value, formatted label, and pixel position | | `ResolvedScale` | Final positional scale | | `ResolvedColorScale` | Final color scale | @@ -227,6 +231,7 @@ See [DOM host](./dom-host.md) and | `ChartFocusSource` | Pointer, keyboard, programmatic, or restored source | | `ChartFocusFilter` | Focus-filtered mark matching configuration | | `ChartFocusMatch` | Primary, group, key, x, y, or series matching | +| `ChartFocusAffinity` | Mark fallback axis after exact hit-region containment | | `ChartSpatialIndex` | Nearest-point query | | `ChartSpatialIndexFactory` | Builds an index from current scene points | | `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input | diff --git a/packages/charts-core/src/area.ts b/packages/charts-core/src/area.ts index 393ca9ec..1c82ac13 100644 --- a/packages/charts-core/src/area.ts +++ b/packages/charts-core/src/area.ts @@ -113,6 +113,7 @@ export function areaY( return { id, + focusAffinity: 'x', states: markStates(data, options.states), seriesFromColor: options.z === undefined && options.color !== undefined, channels: { diff --git a/packages/charts-core/src/band.ts b/packages/charts-core/src/band.ts index 3ac33bfd..16fdae71 100644 --- a/packages/charts-core/src/band.ts +++ b/packages/charts-core/src/band.ts @@ -77,6 +77,7 @@ export function bandX< return { id, + focusAffinity: 'x', channels: { x: { scale: 'x', values: values.filter(isChartValue) }, color: { scale: 'color', values: colorValues.filter(isChartKey) }, @@ -101,12 +102,14 @@ export function bandX< ) const group = zValues[index] ?? null const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const left = x - width / 2 + inset + const paintedWidth = Math.max(0, width - inset * 2) nodes.push({ kind: 'rect', key, - x: x - width / 2 + inset, + x: left, y: chart.y, - width: Math.max(0, width - inset * 2), + width: paintedWidth, height: chart.height, radius: resolved.radius, style: { fill, fillOpacity: resolved.fillOpacity }, @@ -122,6 +125,13 @@ export function bandX< yValue: 0, x, y: chart.y + chart.height / 2, + hitRegion: { + kind: 'rect', + x: left, + y: chart.y, + width: paintedWidth, + height: chart.height, + }, color: fill, }) }) @@ -170,6 +180,7 @@ export function bandY< return { id, + focusAffinity: 'y', channels: { y: { scale: 'y', values: values.filter(isChartValue) }, color: { scale: 'color', values: colorValues.filter(isChartKey) }, @@ -194,13 +205,15 @@ export function bandY< ) const group = zValues[index] ?? null const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const top = y - height / 2 + inset + const paintedHeight = Math.max(0, height - inset * 2) nodes.push({ kind: 'rect', key, x: chart.x, - y: y - height / 2 + inset, + y: top, width: chart.width, - height: Math.max(0, height - inset * 2), + height: paintedHeight, radius: resolved.radius, style: { fill, fillOpacity: resolved.fillOpacity }, }) @@ -215,6 +228,13 @@ export function bandY< yValue, x: chart.x + chart.width / 2, y, + hitRegion: { + kind: 'rect', + x: chart.x, + y: top, + width: chart.width, + height: paintedHeight, + }, color: fill, }) }) diff --git a/packages/charts-core/src/bar.ts b/packages/charts-core/src/bar.ts index 7c94fd04..71bafb13 100644 --- a/packages/charts-core/src/bar.ts +++ b/packages/charts-core/src/bar.ts @@ -133,6 +133,7 @@ export function barY( return { id, + focusAffinity: 'x', states: markStates(data, options.states), seriesFromColor: options.z === undefined && @@ -194,14 +195,16 @@ export function barY( const valuePosition = scales.y.map(y2Value) const x = center - totalBandwidth / 2 + groupOffset + inset const y = Math.min(baselinePosition, valuePosition) + const width = Math.max(0, groupBandwidth - inset * 2) + const height = Math.abs(baselinePosition - valuePosition) const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` nodes.push({ kind: 'rect', key, x, y, - width: Math.max(0, groupBandwidth - inset * 2), - height: Math.abs(baselinePosition - valuePosition), + width, + height, radius: options.radius, inset, style: { @@ -223,6 +226,7 @@ export function barY( yInterval: 'difference', x: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, y: valuePosition, + hitRegion: { kind: 'rect', x, y, width, height }, color: fill, }) }) @@ -311,6 +315,7 @@ export function barX( return { id, + focusAffinity: 'y', states: markStates(data, options.states), seriesFromColor: options.z === undefined && @@ -371,14 +376,17 @@ export function barX( const valuePosition = scales.x.map(x2Value) const center = scales.y.map(yValue) const y = center - totalBandwidth / 2 + groupOffset + inset + const x = Math.min(baselinePosition, valuePosition) + const width = Math.abs(baselinePosition - valuePosition) + const height = Math.max(0, groupBandwidth - inset * 2) const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` nodes.push({ kind: 'rect', key, - x: Math.min(baselinePosition, valuePosition), + x, y, - width: Math.abs(baselinePosition - valuePosition), - height: Math.max(0, groupBandwidth - inset * 2), + width, + height, radius: options.radius, inset, style: { @@ -400,6 +408,7 @@ export function barX( xInterval: 'difference', x: valuePosition, y: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, + hitRegion: { kind: 'rect', x, y, width, height }, color: fill, }) }) diff --git a/packages/charts-core/src/dot.ts b/packages/charts-core/src/dot.ts index 9b2e645b..f439578e 100644 --- a/packages/charts-core/src/dot.ts +++ b/packages/charts-core/src/dot.ts @@ -90,6 +90,7 @@ export function dot( return { id, + focusAffinity: 'xy', states: markStates(data, options.states), channels: { x: { scale: 'x', values: xValues.filter(isChartValue) }, @@ -145,6 +146,7 @@ export function dot( yValue, x, y, + hitRegion: { kind: 'circle', x, y, radius }, color, }) }) diff --git a/packages/charts-core/src/facet.ts b/packages/charts-core/src/facet.ts index 9c3544ff..4cf8bdac 100644 --- a/packages/charts-core/src/facet.ts +++ b/packages/charts-core/src/facet.ts @@ -753,9 +753,29 @@ function offsetPoints( key: `${id}:${identity}:${point.key}`, x: point.x + x, y: point.y + y, + ...(point.hitRegion + ? { hitRegion: offsetHitRegion(point.hitRegion, x, y) } + : {}), })) } +function offsetHitRegion( + region: NonNullable, + x: number, + y: number, +): NonNullable { + switch (region.kind) { + case 'rect': + case 'circle': + return { ...region, x: region.x + x, y: region.y + y } + case 'polygon': + return { + ...region, + points: region.points.map((point) => [point[0] + x, point[1] + y]), + } + } +} + function maxSceneMargins(scenes: readonly ChartScene[]): ChartMargin { return maxMargins(scenes.map((scene) => scene.margin)) } diff --git a/packages/charts-core/src/hexagon.ts b/packages/charts-core/src/hexagon.ts index 86933e17..32b795a5 100644 --- a/packages/charts-core/src/hexagon.ts +++ b/packages/charts-core/src/hexagon.ts @@ -84,6 +84,7 @@ export function hexagon( return { id, + focusAffinity: 'xy', channels: { x: { scale: 'x', values: xValues.filter(isChartValue) }, y: { scale: 'y', values: yValues.filter(isChartValue) }, @@ -151,6 +152,7 @@ export function hexagon( yValue, x, y, + hitRegion: { kind: 'polygon', points: vertices }, color: fill, }) }) diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index d819d0f6..63e51af6 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -186,15 +186,18 @@ export type { ChartCurve, ChartDefinition, ChartDefinitionOptions, + ChartCircleHitRegion, ChartExtensionInput, ChartFocusMode, ChartFocusFilter, + ChartFocusAffinity, ChartFocusMatch, ChartFocusPreset, ChartFocusSource, ChartFocusState, ChartFocusStrategy, ChartGradientStop, + ChartHitRegion, ChartKey, ChartLayoutOptions, ChartMargin, @@ -217,6 +220,8 @@ export type { ChartMarkX, ChartMarkY, ChartPoint, + ChartPolygonHitRegion, + ChartRectHitRegion, ChartRuntime, ChartScene, ChartScale, diff --git a/packages/charts-core/src/line.ts b/packages/charts-core/src/line.ts index d9520096..32f72dc5 100644 --- a/packages/charts-core/src/line.ts +++ b/packages/charts-core/src/line.ts @@ -96,6 +96,7 @@ export function lineY( return { id, + focusAffinity: 'x', states: markStates(data, options.states), seriesFromColor: options.z === undefined && options.color !== undefined, channels: { diff --git a/packages/charts-core/src/nearest.test.ts b/packages/charts-core/src/nearest.test.ts new file mode 100644 index 00000000..762b628b --- /dev/null +++ b/packages/charts-core/src/nearest.test.ts @@ -0,0 +1,293 @@ +import { scaleBand, scaleLinear } from 'd3-scale' +import { describe, expect, it } from 'vitest' +import { barX, barY } from './bar' +import { nearestPoint } from './nearest' +import { createChartScene, defineChart } from './scene' +import type { + ChartPoint, + ChartFocusAffinity, + ChartValue, + SceneNode, + StaticChartDefinition, +} from './types' + +describe('nearest point hit regions', () => { + it('selects a containing rectangle before a closer interaction anchor', () => { + const tallBar = point('tall', 100, 20, { + kind: 'rect', + x: 90, + y: 20, + width: 20, + height: 180, + }) + const neighboringPoint = point('neighbor', 140, 170) + + expect(nearestPoint([tallBar, neighboringPoint], 100, 170, 48)?.key).toBe( + 'tall', + ) + }) + + it('applies maximum distance from the rectangle boundary', () => { + const bar = point('bar', 100, 20, { + kind: 'rect', + x: 90, + y: 20, + width: 20, + height: 180, + }) + + expect(nearestPoint([bar], 119, 100, 10)?.key).toBe('bar') + expect(nearestPoint([bar], 121, 100, 10)).toBeNull() + }) + + it('uses exact containment before a competing axis fallback', () => { + const containing = point('containing', 100, 20, { + kind: 'rect', + x: 90, + y: 20, + width: 20, + height: 180, + }) + const sameX = point('same-x', 100, 170, undefined, 'x') + + expect(nearestPoint([containing, sameX], 100, 170, 48)?.key).toBe( + 'containing', + ) + }) + + it('uses paint order when hit regions overlap', () => { + const lower = point('lower', 100, 100, { + kind: 'rect', + x: 80, + y: 80, + width: 100, + height: 100, + }) + const upper = point('upper', 170, 170, { + kind: 'rect', + x: 80, + y: 80, + width: 100, + height: 100, + }) + + expect(nearestPoint([lower, upper], 100, 100, 48)?.key).toBe('upper') + }) + + it('falls back along the mark affinity after missing every region', () => { + const xAligned = point('x-aligned', 100, 20, undefined, 'x') + const yAligned = point('y-aligned', 250, 170, undefined, 'y') + + expect(nearestPoint([xAligned], 109, 190, 10)?.key).toBe('x-aligned') + expect(nearestPoint([xAligned], 111, 20, 10)).toBeNull() + expect(nearestPoint([yAligned], 20, 179, 10)?.key).toBe('y-aligned') + expect(nearestPoint([yAligned], 250, 181, 10)).toBeNull() + }) + + it('uses geometry to break x-affinity ties between stacked segments', () => { + const bottom = point( + 'bottom', + 100, + 100, + { kind: 'rect', x: 90, y: 100, width: 20, height: 100 }, + 'x', + ) + const middle = point( + 'middle', + 100, + 60, + { kind: 'rect', x: 90, y: 60, width: 20, height: 40 }, + 'x', + ) + const top = point( + 'top', + 100, + 20, + { kind: 'rect', x: 90, y: 20, width: 20, height: 40 }, + 'x', + ) + + expect(nearestPoint([bottom, middle, top], 100, 10, 48)?.key).toBe('top') + expect(nearestPoint([bottom, middle, top], 100, 210, 48)?.key).toBe( + 'bottom', + ) + }) + + it('uses geometry to break y-affinity ties between stacked segments', () => { + const left = point( + 'left', + 20, + 100, + { kind: 'rect', x: 20, y: 90, width: 40, height: 20 }, + 'y', + ) + const middle = point( + 'middle', + 60, + 100, + { kind: 'rect', x: 60, y: 90, width: 40, height: 20 }, + 'y', + ) + const right = point( + 'right', + 100, + 100, + { kind: 'rect', x: 100, y: 90, width: 100, height: 20 }, + 'y', + ) + + expect(nearestPoint([left, middle, right], 10, 100, 48)?.key).toBe('left') + expect(nearestPoint([left, middle, right], 210, 100, 48)?.key).toBe('right') + }) + + it('requires containment for geometry-only marks', () => { + const region = point( + 'region', + 100, + 100, + { + kind: 'polygon', + points: [ + [80, 80], + [120, 80], + [100, 130], + ], + }, + 'geometry', + ) + + expect(nearestPoint([region], 100, 100, 48)?.key).toBe('region') + expect(nearestPoint([region], 125, 100, 48)).toBeNull() + }) + + it('supports circular painted geometry', () => { + const bubble = point('bubble', 100, 100, { + kind: 'circle', + x: 100, + y: 100, + radius: 40, + }) + const neighbor = point('neighbor', 137, 100) + + expect(nearestPoint([bubble, neighbor], 135, 100, 48)?.key).toBe('bubble') + }) + + it('preserves point-distance behavior without a hit region', () => { + const anchor = point('anchor', 100, 20) + + expect(nearestPoint([anchor], 100, 29, 10)?.key).toBe('anchor') + expect(nearestPoint([anchor], 100, 31, 10)).toBeNull() + }) + + it('preserves the first point when anchor distances tie', () => { + const first = point('first', 90, 100) + const second = point('second', 110, 100) + + expect(nearestPoint([first, second], 100, 100, 10)?.key).toBe('first') + }) + + it('supports reversed rectangle bounds', () => { + const bar = point('bar', 100, 20, { + kind: 'rect', + x: 110, + y: 200, + width: -20, + height: -180, + }) + + expect(nearestPoint([bar], 119, 100, 10)?.key).toBe('bar') + expect(nearestPoint([bar], 121, 100, 10)).toBeNull() + }) + + it('uses the painted vertical bar rectangle as its hit region', () => { + expectBarUsesPaintedRect( + defineChart({ + marks: [ + barY([{ category: 'A', value: 80 }], { + x: 'category', + y: 'value', + }), + ], + x: { scale: scaleBand().domain(['A']) }, + y: { scale: scaleLinear().domain([0, 100]) }, + guides: false, + margin: 0, + }), + 'x', + ) + }) + + it('uses the painted horizontal bar rectangle as its hit region', () => { + expectBarUsesPaintedRect( + defineChart({ + marks: [ + barX([{ category: 'A', value: 80 }], { + x: 'value', + y: 'category', + }), + ], + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleBand().domain(['A']) }, + guides: false, + margin: 0, + }), + 'y', + ) + }) +}) + +function point( + key: string, + x: number, + y: number, + hitRegion?: ChartPoint['hitRegion'], + focusAffinity?: ChartFocusAffinity, +): ChartPoint { + return { + key, + markId: 'test', + group: null, + groupLabel: 'test', + datum: key, + datumIndex: 0, + xValue: x, + yValue: y, + x, + y, + hitRegion, + focusAffinity, + color: 'currentColor', + } +} + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => + node.kind === 'group' ? [node, ...flatten(node.children)] : [node], + ) +} + +function expectBarUsesPaintedRect< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + definition: StaticChartDefinition, + expectedAffinity: ChartFocusAffinity, +) { + const scene = createChartScene(definition, { width: 300, height: 200 }) + const point = scene.points[0]! + const rect = flatten(scene.nodes).find( + (node) => node.kind === 'rect' && node.key === point.key, + ) + + expect(rect?.kind).toBe('rect') + if (rect?.kind !== 'rect') throw new Error('Expected bar rectangle') + expect(point.hitRegion).toEqual({ + kind: 'rect', + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }) + expect(point.focusAffinity).toBe(expectedAffinity) +} diff --git a/packages/charts-core/src/nearest.ts b/packages/charts-core/src/nearest.ts index c5ef91e6..258425a6 100644 --- a/packages/charts-core/src/nearest.ts +++ b/packages/charts-core/src/nearest.ts @@ -1,4 +1,4 @@ -import type { ChartPoint, ChartValue } from './types' +import type { ChartHitRegion, ChartPoint, ChartValue } from './types' export function nearestPoint< TDatum, @@ -11,16 +11,193 @@ export function nearestPoint< maxDistance: number, ): ChartPoint | null { let result: ChartPoint | undefined - let resultDistance = Infinity + let resultPrimaryDistance = Infinity + let anchorOnly = true + + // Resolve exact painted containment before considering any nearby fallback. + for (let index = points.length; index--;) { + const point = points[index]! + const region = point.hitRegion + if (region) { + anchorOnly = false + if (contains(region, x, y)) { + // Scene points follow paint order, so reverse traversal finds topmost first. + return point + } + } else if (point.focusAffinity && point.focusAffinity !== 'xy') { + anchorOnly = false + } else { + const dx = point.x - x + const dy = point.y - y + const distance = dx * dx + dy * dy + // Reverse traversal uses <= so legacy ties still resolve to the first point. + if (distance <= resultPrimaryDistance) { + result = point + resultPrimaryDistance = distance + } + } + } + if (anchorOnly) { + return result && resultPrimaryDistance <= Math.max(0, maxDistance) ** 2 + ? result + : null + } + + result = undefined + resultPrimaryDistance = Infinity + let resultGeometryDistance = Infinity for (const point of points) { - const dx = point.x - x - const dy = point.y - y - const distance = dx * dx + dy * dy - if (distance < resultDistance) { + const affinity = point.focusAffinity ?? 'xy' + if (affinity === 'geometry') continue + + const region = point.hitRegion + let primaryDistance: number + let geometryDistance: number + if (!region) { + const dx = point.x - x + const dy = point.y - y + geometryDistance = dx * dx + dy * dy + primaryDistance = + affinity === 'x' + ? dx * dx + : affinity === 'y' + ? dy * dy + : geometryDistance + } else { + primaryDistance = + affinity === 'x' + ? squaredAxisDistance(region, x, 0) + : affinity === 'y' + ? squaredAxisDistance(region, y, 1) + : 0 + if (affinity !== 'xy' && primaryDistance > resultPrimaryDistance) { + continue + } + geometryDistance = squaredDistanceToBoundary(region, x, y) + if (affinity === 'xy') primaryDistance = geometryDistance + } + if ( + primaryDistance < resultPrimaryDistance || + (primaryDistance === resultPrimaryDistance && + geometryDistance < resultGeometryDistance) + ) { result = point - resultDistance = distance + resultPrimaryDistance = primaryDistance + resultGeometryDistance = geometryDistance } } - if (!result) return null - return resultDistance <= Math.max(0, maxDistance) ** 2 ? result : null + + return result && resultPrimaryDistance <= Math.max(0, maxDistance) ** 2 + ? result + : null +} + +function contains(region: ChartHitRegion, x: number, y: number) { + if (region.kind === 'rect') { + const left = Math.min(region.x, region.x + region.width) + const right = Math.max(region.x, region.x + region.width) + const top = Math.min(region.y, region.y + region.height) + const bottom = Math.max(region.y, region.y + region.height) + return x >= left && x <= right && y >= top && y <= bottom + } + if (region.kind === 'circle') { + const dx = x - region.x + const dy = y - region.y + const radius = Math.max(0, region.radius) + return dx * dx + dy * dy <= radius * radius + } + + const points = region.points + let inside = false + for ( + let index = 0, previous = points.length - 1; + index < points.length; + previous = index++ + ) { + const current = points[index]! + const prior = points[previous]! + if ( + current[1] > y !== prior[1] > y && + x < + ((prior[0] - current[0]) * (y - current[1])) / (prior[1] - current[1]) + + current[0] + ) { + inside = !inside + } + } + return inside +} + +function squaredAxisDistance( + region: ChartHitRegion, + value: number, + coordinate: 0 | 1, +) { + let minimum: number + let maximum: number + if (region.kind === 'circle') { + const center = coordinate === 0 ? region.x : region.y + const radius = Math.max(0, region.radius) + minimum = center - radius + maximum = center + radius + } else if (region.kind === 'rect') { + const start = coordinate === 0 ? region.x : region.y + const size = coordinate === 0 ? region.width : region.height + minimum = Math.min(start, start + size) + maximum = Math.max(start, start + size) + } else { + minimum = Infinity + maximum = -Infinity + for (const vertex of region.points) { + minimum = Math.min(minimum, vertex[coordinate]) + maximum = Math.max(maximum, vertex[coordinate]) + } + } + const distance = + value < minimum ? minimum - value : value > maximum ? value - maximum : 0 + return distance * distance +} + +function squaredDistanceToBoundary( + region: ChartHitRegion, + x: number, + y: number, +) { + if (region.kind === 'rect') { + const left = Math.min(region.x, region.x + region.width) + const right = Math.max(region.x, region.x + region.width) + const top = Math.min(region.y, region.y + region.height) + const bottom = Math.max(region.y, region.y + region.height) + const dx = x < left ? left - x : x > right ? x - right : 0 + const dy = y < top ? top - y : y > bottom ? y - bottom : 0 + return dx * dx + dy * dy + } + if (region.kind === 'circle') { + const dx = x - region.x + const dy = y - region.y + const distance = Math.max( + 0, + Math.sqrt(dx * dx + dy * dy) - Math.max(0, region.radius), + ) + return distance * distance + } + + let distance = Infinity + for (let index = 0; index < region.points.length; index += 1) { + const start = region.points[index]! + const end = region.points[(index + 1) % region.points.length]! + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = dx * dx + dy * dy + const amount = length + ? Math.max( + 0, + Math.min(1, ((x - start[0]) * dx + (y - start[1]) * dy) / length), + ) + : 0 + const offsetX = x - (start[0] + amount * dx) + const offsetY = y - (start[1] + amount * dy) + distance = Math.min(distance, offsetX * offsetX + offsetY * offsetY) + } + return distance } diff --git a/packages/charts-core/src/rect.ts b/packages/charts-core/src/rect.ts index db484454..92ebc732 100644 --- a/packages/charts-core/src/rect.ts +++ b/packages/charts-core/src/rect.ts @@ -137,6 +137,7 @@ export function rect( return { id, + focusAffinity: 'xy', states: markStates(data, options.states), channels: { x: { @@ -192,13 +193,17 @@ export function rect( const color = options.fill ?? resolveColor(colorValues[datumIndex] ?? null) const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const paintedX = left + inset + const paintedY = top + inset + const paintedWidth = Math.max(0, width - inset * 2) + const paintedHeight = Math.max(0, height - inset * 2) nodes.push({ kind: 'rect', key, - x: left + inset, - y: top + inset, - width: Math.max(0, width - inset * 2), - height: Math.max(0, height - inset * 2), + x: paintedX, + y: paintedY, + width: paintedWidth, + height: paintedHeight, radius: options.radius, inset, style: { @@ -228,6 +233,13 @@ export function rect( yInterval: 'range', x: left + width / 2, y: top + height / 2, + hitRegion: { + kind: 'rect', + x: paintedX, + y: paintedY, + width: paintedWidth, + height: paintedHeight, + }, color, }) }) diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts index 20d8d4bb..63cc0bd4 100644 --- a/packages/charts-core/src/renderer.test.ts +++ b/packages/charts-core/src/renderer.test.ts @@ -10,7 +10,7 @@ import type { ChartSurface, ChartSurfaceRenderOptions, } from './dom-types' -import type { ChartScene, ChartTooltipAnchorContext } from './types' +import type { ChartPoint, ChartScene, ChartTooltipAnchorContext } from './types' interface Datum { id: string @@ -139,6 +139,40 @@ describe('renderer-neutral chart host', () => { expect(container.childElementCount).toBe(0) }) + it('lets a spatial index fully own pointer resolution', () => { + const fake = createFakeRenderer() + const container = document.createElement('div') + const onFocusChange = vi.fn() + const findNearest = vi.fn() + const spatialIndex = vi.fn( + (points: readonly ChartPoint[]) => { + findNearest.mockReturnValue(points[1] ?? null) + return { findNearest } + }, + ) + const host = mountChartRenderer(container, { + definition: defineChart(definition, { spatialIndex }), + renderer: fake.renderer, + width: 480, + height: 260, + ariaLabel: 'Indexed chart', + onFocusChange, + }) + + fake.element.dispatchEvent( + new MouseEvent('pointermove', { + bubbles: true, + clientX: 0, + clientY: 0, + }), + ) + + const firstPoint = host.getScene().points[0]! + expect(findNearest).toHaveBeenCalledWith(firstPoint.x, firstPoint.y, 48) + expect(onFocusChange).toHaveBeenLastCalledWith(host.getScene().points[1]) + host.destroy() + }) + it('anchors to the pointer, follows placement fallbacks, and clears pointer state for keyboard focus', () => { const fake = createFakeRenderer() fake.clientToScene.mockReturnValue({ x: 20, y: 20 }) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index fde6f894..288c80f4 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -236,6 +236,11 @@ function createChartSceneWithScaleResolver< children: rendered.nodes, }) } else { + const markPoints = (rendered.points ?? []).map((point) => + point.focusAffinity || !mark.focusAffinity + ? point + : { ...point, focusAffinity: mark.focusAffinity }, + ) as readonly ChartPoint[] if (mark.states) { markNodes.push({ kind: 'group', @@ -244,19 +249,13 @@ function createChartSceneWithScaleResolver< states: { data: mark.states.data, definitions: mark.states.definitions, - points: rendered.points ?? [], + points: markPoints, }, }) } else { for (const node of rendered.nodes) markNodes.push(node) } - for (const point of (rendered.points ?? []) as readonly ChartPoint< - TDatum, - TXValue, - TYValue - >[]) { - points.push(point) - } + for (const point of markPoints) points.push(point) } }) const nodes: SceneNode[] = [ diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index b035d581..79b536bf 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -683,6 +683,8 @@ export interface InitializedMark< channels: Readonly> /** The mark uses a discrete color channel as inferred series identity. */ seriesFromColor?: boolean + /** Natural pointer fallback after exact hit-region containment. */ + focusAffinity?: ChartFocusAffinity focus?: ChartFocusFilter states?: { data: readonly unknown[] @@ -701,6 +703,27 @@ export interface MarkScene< points?: readonly ChartPoint[] } +export interface ChartRectHitRegion extends ChartBounds { + kind: 'rect' +} + +export interface ChartCircleHitRegion { + kind: 'circle' + x: number + y: number + radius: number +} + +export interface ChartPolygonHitRegion { + kind: 'polygon' + points: readonly (readonly [number, number])[] +} + +export type ChartHitRegion = + ChartRectHitRegion | ChartCircleHitRegion | ChartPolygonHitRegion + +export type ChartFocusAffinity = 'x' | 'y' | 'xy' | 'geometry' + export interface ChartPoint< TDatum = unknown, TXValue extends ChartValue = ChartValue, @@ -722,6 +745,10 @@ export interface ChartPoint< yInterval?: 'range' | 'difference' x: number y: number + /** Optional painted geometry used by the default pointer-distance resolver. */ + hitRegion?: ChartHitRegion + /** Resolved mark-level pointer fallback after exact geometry containment. */ + focusAffinity?: ChartFocusAffinity color: string } diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 3ae69950..434e6b22 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -108,7 +108,9 @@ export type { ChartCurve, ChartDefinition, ChartDefinitionOptions, + ChartCircleHitRegion, ChartFocusFilter, + ChartFocusAffinity, ChartFocusMatch, ChartExtensionInput, ChartFocusMode, @@ -117,6 +119,7 @@ export type { ChartFocusState, ChartFocusStrategy, ChartGradientStop, + ChartHitRegion, ChartKey, ChartLayoutOptions, ChartMargin, @@ -139,6 +142,8 @@ export type { ChartMarkX, ChartMarkY, ChartPoint, + ChartPolygonHitRegion, + ChartRectHitRegion, ChartRuntime, ChartScene, ChartScale, diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 7f20ed4d..90dc082c 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -744,6 +744,15 @@ const entries = [ 'benchmarks/entries/d3-delaunay-kernel.ts', 7.3, ), + measured( + 'Anchor-only pointer resolver baseline', + 'benchmarks/entries/charts-pointer-anchor-kernel.ts', + ), + budgeted( + 'Geometry pointer resolver kernel', + 'benchmarks/entries/charts-pointer-geometry-kernel.ts', + 1, + ), budgeted( 'D3 brush controller kernel', 'benchmarks/entries/d3-brush-kernel.ts', diff --git a/scripts/measure-pointer-resolution.mjs b/scripts/measure-pointer-resolution.mjs new file mode 100644 index 00000000..0966ad79 --- /dev/null +++ b/scripts/measure-pointer-resolution.mjs @@ -0,0 +1,22 @@ +import { mkdir } from 'node:fs/promises' +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' +import { build } from 'esbuild' + +const root = resolve(import.meta.dirname, '..') +const outputDirectory = resolve(root, '.bundle-output') +const outfile = resolve(outputDirectory, 'pointer-resolution.mjs') + +await mkdir(outputDirectory, { recursive: true }) +await build({ + entryPoints: [resolve(root, 'benchmarks/interaction/nearest.ts')], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + legalComments: 'none', + logLevel: 'silent', +}) + +await import(`${pathToFileURL(outfile).href}?time=${Date.now()}`) From 3bedab8d214ea99cc00c15452b3b6b85dcfd4814 Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Fri, 31 Jul 2026 23:48:25 -0600 Subject: [PATCH 2/5] Add interaction geometry stress cases --- API-FRICTION.md | 5 +- examples/sandbox/README.md | 3 +- .../src/InteractionGeometryLab.test.ts | 36 ++++- .../sandbox/src/InteractionGeometryLab.tsx | 148 +++++++++++++++++- examples/sandbox/src/styles.css | 9 ++ 5 files changed, 192 insertions(+), 9 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 8d09d76f..3de40e19 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -4284,10 +4284,11 @@ Each entry records: - Verification: focused tests cover containment priority, x/y/xy/geometry fallback, rectangle/circle/polygon shapes, built-in bar affinity, reversed rectangles, paint-order overlap, stack-edge selection, spatial-index - ownership, and legacy tie order. The twelve-case sandbox compares before and + ownership, and legacy tie order. The fourteen-case sandbox compares before and after behavior for stacked and horizontal bars, line/area, bubbles, cells, financial intervals, timelines, pie and annular sectors, radar, maps, and - Sankey geometry. The full unit matrix passes 675 tests across 123 files; + Sankey geometry, then stress-tests 4,098 rectangles and 2,050 polygons with + 13,318 total vertices. The full unit matrix passes 678 tests across 123 files; typecheck, documentation, packed-consumer, framework-adapter, formatting, and sandbox production-build gates also pass. diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index 9560310d..9157f1ed 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -11,7 +11,8 @@ The private sandbox contains two complementary development surfaces: The lab compares legacy anchor-only focus with the current two-stage resolver: painted-shape containment first, then the mark's declared `x`, `y`, `xy`, or `geometry` fallback. Every chart keeps its source disclosure directly beneath -the rendered result. +the rendered result. Its final stress cases exercise 4,098 rectangles and +2,050 polygons containing 13,318 vertices. Add a focused case in `src/InteractionGeometryLab.tsx` when a distinct mark family or interaction shape exercises behavior not already represented. Keep diff --git a/examples/sandbox/src/InteractionGeometryLab.test.ts b/examples/sandbox/src/InteractionGeometryLab.test.ts index 75c5392d..72504988 100644 --- a/examples/sandbox/src/InteractionGeometryLab.test.ts +++ b/examples/sandbox/src/InteractionGeometryLab.test.ts @@ -17,7 +17,7 @@ describe('interaction geometry source disclosure', () => { expect(markup.match(/class="hit-region-proof__source"/g)).toHaveLength( proofCases.length * 2, ) - expect(proofCases).toHaveLength(12) + expect(proofCases).toHaveLength(14) expect(markup).toContain('const stackedSeries = [') expect(markup).toContain('polarGuideMark('polar-sector-guides')') expect(markup).toContain( @@ -93,6 +93,40 @@ describe('interaction geometry proof gallery', () => { 'october-disease', ) }) + + it('keeps the large rectangle and polygon stress fixtures intact', () => { + const rectangleProof = proofCases.find( + (candidate) => candidate.id === 'dense-rectangles', + ) + const polygonProof = proofCases.find( + (candidate) => candidate.id === 'complex-polygons', + ) + expect(rectangleProof).toBeDefined() + expect(polygonProof).toBeDefined() + if (!rectangleProof || !polygonProof) return + + const rectangleScene = createChartScene(rectangleProof.after, { + width: 1_120, + height: 320, + }) + const polygonScene = createChartScene(polygonProof.after, { + width: 1_120, + height: 320, + }) + + expect(rectangleScene.points).toHaveLength(4_098) + expect(polygonScene.points).toHaveLength(2_050) + expect( + polygonScene.points.reduce( + (total, point) => + total + + (point.hitRegion?.kind === 'polygon' + ? point.hitRegion.points.length + : 0), + 0, + ), + ).toBe(13_318) + }) }) function legacyNearest( diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx index 34066f9c..f48340a0 100644 --- a/examples/sandbox/src/InteractionGeometryLab.tsx +++ b/examples/sandbox/src/InteractionGeometryLab.tsx @@ -42,6 +42,10 @@ interface ProofCase { id: string title: string affinity: ChartFocusAffinity + stress?: { + geometryLabel: string + height: number + } explanation: string beforeExpected: string afterExpected: string @@ -86,20 +90,25 @@ export function InteractionGeometryLab() {

Each comparison uses the same pointer position and 48px threshold. Before measures from a mark’s anchor point. After tests its painted - shape first, then uses the mark’s natural interaction axis. + shape first, then uses the mark’s natural interaction axis. The final + two comparisons scale that contract to thousands of regions.

{proofCases.map((proof) => (

{proof.title}

- fallback: {proof.affinity} + + {proof.stress + ? `${proof.stress.geometryLabel} · fallback: ${proof.affinity}` + : `fallback: ${proof.affinity}`} +

{proof.explanation} @@ -166,8 +175,8 @@ function ProofChart({

setFocused(point?.datum ?? null)} onRender={onRender} @@ -690,6 +699,108 @@ function createProofCases(): ProofCase[] { ) // source:sankey:end + // source:dense-rectangles:start + const denseRectangleGrid = Array.from({ length: 4_096 }, (_, index) => { + const columns = 128 + const rows = 32 + const column = index % columns + const row = Math.floor(index / columns) + const columnWidth = 0.36 / columns + const rowHeight = 0.9 / rows + return { + id: `dense-cell-${index}`, + label: `Dense cell ${index + 1}`, + x1: 0.62 + column * columnWidth, + x2: 0.62 + (column + 0.82) * columnWidth, + y1: 0.05 + row * rowHeight, + y2: 0.05 + (row + 0.82) * rowHeight, + color: index % 2 ? '#d4d4d4' : '#e5e5e5', + } + }) satisfies ProofDatum[] + const denseRectangles = [ + { + id: 'dense-giant-rectangle', + label: 'Giant rectangle across 4,098 regions', + x1: 0.03, + x2: 0.58, + y1: 0.06, + y2: 0.94, + probe: [0.57, 0.5], + color: '#404040', + }, + { + id: 'dense-rectangle-decoy', + label: 'Closest micro-cell', + x1: 0.584, + x2: 0.604, + y1: 0.47, + y2: 0.53, + color: '#a3a3a3', + }, + ...denseRectangleGrid, + ] satisfies ProofDatum[] + const denseRectangleBase = baseDefinition( + [normalizedRectMark('dense-rectangles', denseRectangles, 'xy')], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:dense-rectangles:end + + // source:complex-polygons:start + const regularPolygon = ( + x: number, + y: number, + radius: number, + vertices: number, + wobble = 0, + verticalRatio = 1, + ) => + Array.from({ length: vertices }, (_, index) => { + const angle = (index / vertices) * Math.PI * 2 + const adjustedRadius = + radius * (1 + Math.sin(index * 17) * Math.max(0, wobble)) + return [ + x + Math.cos(angle) * adjustedRadius, + y + Math.sin(angle) * adjustedRadius * verticalRatio, + ] as const + }) + const polygonCloud = Array.from({ length: 2_048 }, (_, index) => { + const columns = 64 + const rows = 32 + const column = index % columns + const row = Math.floor(index / columns) + const x = 0.64 + ((column + 0.5) / columns) * 0.34 + const y = 0.06 + ((row + 0.5) / rows) * 0.88 + return { + id: `cloud-polygon-${index}`, + label: `Cloud polygon ${index + 1}`, + polygon: regularPolygon(x, y, 0.0022, 6, 0, 3), + color: index % 2 ? '#a3a3a3' : '#d4d4d4', + } + }) satisfies ProofDatum[] + const complexPolygons = [ + { + id: 'complex-giant-polygon', + label: '1,024-vertex contour across 13,318 vertices', + polygon: regularPolygon(0.31, 0.5, 0.26, 1_024, 0.03), + probe: [0.56, 0.5], + color: '#404040', + }, + { + id: 'complex-polygon-decoy', + label: 'Closest six-vertex polygon', + polygon: regularPolygon(0.59, 0.5, 0.014, 6), + color: '#a3a3a3', + }, + ...polygonCloud, + ] satisfies ProofDatum[] + const complexPolygonBase = baseDefinition( + [normalizedPolygonMark('complex-polygons', complexPolygons)], + scaleLinear().domain([0, 1]), + scaleLinear().domain([0, 1]), + ) + // source:complex-polygons:end + return [ makeCase({ id: 'stacked-bars', @@ -823,6 +934,33 @@ function createProofCases(): ProofCase[] { base: sankeyBase, probe: normalizedDatumProbe('wide-link'), }), + makeCase({ + id: 'dense-rectangles', + title: 'Stress: thousands of rectangles', + affinity: 'xy', + stress: { geometryLabel: '4,098 rectangles', height: 320 }, + explanation: + 'A giant region sits behind 4,097 later-painted rectangles. The exact pass must reject every unrelated boundary before finding the containing shape.', + beforeExpected: 'Closest micro-cell', + afterExpected: 'Giant rectangle across 4,098 regions', + base: denseRectangleBase, + probe: normalizedDatumProbe('dense-giant-rectangle'), + }), + makeCase({ + id: 'complex-polygons', + title: 'Stress: thousands of complex polygons', + affinity: 'geometry', + stress: { + geometryLabel: '2,050 polygons · 13,318 vertices', + height: 320, + }, + explanation: + 'A 1,024-vertex contour sits behind 2,049 later-painted polygons, forcing a worst-direction containment scan without an axis shortcut.', + beforeExpected: 'Closest six-vertex polygon', + afterExpected: '1,024-vertex contour across 13,318 vertices', + base: complexPolygonBase, + probe: normalizedDatumProbe('complex-giant-polygon'), + }), ] } diff --git a/examples/sandbox/src/styles.css b/examples/sandbox/src/styles.css index 923b1bea..8218289c 100644 --- a/examples/sandbox/src/styles.css +++ b/examples/sandbox/src/styles.css @@ -211,6 +211,15 @@ gap: 18px; } +.hit-region-proof__case--stress .hit-region-proof__grid { + grid-template-columns: 1fr; +} + +.hit-region-proof__case--stress .hit-region-proof__chart > svg, +.hit-region-proof__case--stress .hit-region-proof__chart > div:first-child { + min-height: 320px; +} + .hit-region-proof__card { min-width: 0; padding: 15px; From 054d23b8b7d4c1abaa6c58a6b302a09df8cd410d Mon Sep 17 00:00:00 2001 From: Kyle Gill Date: Sat, 1 Aug 2026 22:09:01 -0600 Subject: [PATCH 3/5] Refine scene-owned interaction geometry --- .changeset/bright-hit-regions.md | 12 +- API-FRICTION.md | 135 +- README.md | 3 +- .../cases/scatter-bubble/model.test.ts | 15 + .../conformance/cases/scatter-bubble/model.ts | 24 + .../conformance/cases/scatter-bubble/plot.ts | 13 +- .../cases/scatter-bubble/tanstack.ts | 13 +- .../entries/charts-pointer-geometry-kernel.ts | 2 +- benchmarks/interaction/README.md | 2 +- benchmarks/interaction/nearest.ts | 83 +- docs/guides/custom-marks-and-renderers.md | 55 +- docs/guides/tooltips-and-focus.md | 25 +- docs/reference/custom-extensions.md | 7 +- docs/reference/focus-and-interaction.md | 80 +- docs/reference/rendering-and-export.md | 13 +- docs/reference/runtime-and-scene.md | 35 +- docs/reference/types.md | 9 +- examples/sandbox/README.md | 23 +- .../src/InteractionGeometryLab.test.ts | 255 ++- .../sandbox/src/InteractionGeometryLab.tsx | 1894 ++++++++++++++++- examples/sandbox/src/styles.css | 7 +- .../docs/guides/custom-marks-and-renderers.md | 55 +- .../docs/guides/tooltips-and-focus.md | 25 +- .../docs/reference/custom-extensions.md | 7 +- .../docs/reference/focus-and-interaction.md | 80 +- .../docs/reference/rendering-and-export.md | 13 +- .../docs/reference/runtime-and-scene.md | 35 +- packages/charts-core/docs/reference/types.md | 9 +- packages/charts-core/src/area-x.ts | 10 +- packages/charts-core/src/area.ts | 11 +- packages/charts-core/src/band.ts | 68 +- packages/charts-core/src/bar.ts | 72 +- packages/charts-core/src/canvas.ts | 1 + packages/charts-core/src/dom-types.ts | 2 +- packages/charts-core/src/dot.ts | 31 +- packages/charts-core/src/facet.test.ts | 186 +- packages/charts-core/src/facet.ts | 143 +- packages/charts-core/src/focus-layer.ts | 20 +- packages/charts-core/src/focus-mark.test.ts | 100 +- packages/charts-core/src/hexagon.ts | 31 +- packages/charts-core/src/index.ts | 5 +- packages/charts-core/src/line.ts | 9 +- packages/charts-core/src/mark-state.ts | 10 +- packages/charts-core/src/marks.test.ts | 6 +- packages/charts-core/src/nearest.test.ts | 442 ++-- packages/charts-core/src/nearest.ts | 629 ++++-- packages/charts-core/src/rect.ts | 46 +- packages/charts-core/src/renderer.test.ts | 65 +- packages/charts-core/src/renderer.ts | 11 +- packages/charts-core/src/scene.test.ts | 6 +- packages/charts-core/src/scene.ts | 74 +- packages/charts-core/src/svg-surface.ts | 1 + .../charts-core/src/type-contract.test.ts | 3 +- packages/charts-core/src/types.ts | 58 +- packages/charts-core/src/universal-types.ts | 5 +- .../react-native-charts/src/interaction.ts | 2 +- scripts/measure-bundles.mjs | 2 +- 57 files changed, 4077 insertions(+), 901 deletions(-) create mode 100644 benchmarks/conformance/cases/scatter-bubble/model.test.ts create mode 100644 benchmarks/conformance/cases/scatter-bubble/model.ts diff --git a/.changeset/bright-hit-regions.md b/.changeset/bright-hit-regions.md index 8236b259..d8035065 100644 --- a/.changeset/bright-hit-regions.md +++ b/.changeset/bright-hit-regions.md @@ -3,6 +3,12 @@ --- Resolve default pointer focus against painted mark geometry before applying a -mark's natural x, y, or two-dimensional fallback. Built-in bars, bands, dots, -rectangles, and hexagons now expose their hit regions, while custom marks can -provide rectangle, circle, or polygon regions directly on scene points. +mark's natural x, y, or two-dimensional fallback. Interaction metadata now +lives on the resolved scene primitive, so built-in and custom marks share the +same rectangle, circle, polygon, line, or area geometry used by renderers after +layout, facets, transforms, clipping, and inline state resolution. + +Facet-local default markers now stay bound to the primary point even when +another panel has identical channel values; explicit x/y focus marks remain the +opt-in synchronized-cursor path. Animated bar inset states also preserve the +quantitative axis and baseline while changing only categorical width or height. diff --git a/API-FRICTION.md b/API-FRICTION.md index 3de40e19..b202fbe1 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -212,6 +212,7 @@ Each entry records: | F-174 | OIDC release cannot claim a new npm package name | Tooling | monitoring | | F-175 | Native SVG resource normalization collapsed authored IDs | Application | resolved | | F-176 | Large marks were focused by distant anchor points | API | monitoring | +| F-177 | Bubble overlap inherited incidental source order | Application | resolved | ## Findings @@ -4272,43 +4273,81 @@ Each entry records: ranking, while chart-wide nearest-x made off-bar selection too permissive. Pure x fallback also tied every segment in one stack and selected the bottom segment when the pointer was above the stack. -- Current decision: use a two-stage mark contract rather than infer a strategy - from chart composition. Interaction points may expose their painted - rectangle, circle, or polygon, while the mark declares its natural off-shape - fallback as `x`, `y`, `xy`, or `geometry`. Exact containment wins across all - marks before fallback ranking. Axis fallback compares its declared axis - first, then uses full painted-geometry distance to break ties. Overlapping - contained shapes resolve topmost-first in paint order. Points without either - field retain legacy point-distance behavior. Explicit focus strategies and - custom spatial indexes continue to own their complete search semantics. +- Current decision: use a two-stage scene contract rather than infer a strategy + from chart composition or copy geometry onto `ChartPoint`. A resolved `rect`, + `dot`, `area`, `polyline`, or `rule` attaches its semantic point or points and + natural `x`, `y`, `xy`, or `geometry` fallback. The default resolver collects + those targets from the final scene in paint order, accumulating facet and + group translations and clips. Exact containment wins across all marks before + fallback ranking; axis fallback uses visible primitive bounds first and full + geometry distance to break ties. Inline mark states return their destination + scene to the host, which intentionally uses that scene during animation. + Points not attached to a primitive retain legacy point-distance behavior. + Explicit focus strategies and custom spatial indexes continue to own their + complete search semantics; the spatial-index factory now receives the final + scene as a backward-compatible second argument so bounds, quadtrees, or + Delaunay can remain optional acceleration layers without copying geometry + onto points. Facet layout also scopes the final primitive and focus-layer + keys. Default `primary`/`group` presentation matches canonical focused points + instead of treating equal x/y/series tuples in another panel as the same + point; `whenFocused(..., { match: "x" })` or `match: "y"` remains the + explicit synchronized-cursor contract. - Verification: focused tests cover containment priority, x/y/xy/geometry - fallback, rectangle/circle/polygon shapes, built-in bar affinity, reversed - rectangles, paint-order overlap, stack-edge selection, spatial-index - ownership, and legacy tie order. The fourteen-case sandbox compares before and - after behavior for stacked and horizontal bars, line/area, bubbles, cells, - financial intervals, timelines, pie and annular sectors, radar, maps, and - Sankey geometry, then stress-tests 4,098 rectangles and 2,050 polygons with - 13,318 total vertices. The full unit matrix passes 678 tests across 123 files; - typecheck, documentation, packed-consumer, framework-adapter, formatting, and - sandbox production-build gates also pass. - - On Node 24 arm64 on an Apple M4 Pro, the optimized resolver improves the - original POC's median - query time from 116.7 to 23.8 microseconds for 10k ordinary points, 62.2 to - 27.1 for contained rectangles, 210.5 to 127.0 for stacked fallback, 64.5 to - 25.7 for circles, and 132.9 to 79.5 for 2k polygons. On an exact-target point - fixture, production, optimized geometry, Observable Plot 0.6.17, D3 quadtree, - cold D3 Delaunay, and coherent Delaunay take 13.9, 25.2, 41.4, 2.8, 8.6, and - 2.8 microseconds. Quadtree and Delaunay construction take 1.76 and 2.47 - milliseconds for 10k points. A source-equivalent Vega cached-bounds pass - takes 9.8 microseconds versus 23.8 for the generic rectangle resolver, but - deliberately excludes Vega's subsequent Canvas path test. - - The isolated resolver grows from 157 minified / 153 gzip bytes to 1,955 / 869 - bytes, a 716-byte gzip feature cost beneath a new 1 kB ceiling. The D3 - quadtree and Delaunay kernels are 1,971 and 7,308 gzip bytes before geometry - refinement. The complete DOM host remains 685 gzip bytes above the - pre-feature lock rather than silently accepting a new baseline. + fallback, rounded/reversed rectangles, circles, polygons, rules and lines, + built-in bar affinity, paint-order overlap, stack-edge selection, nested + translation, partial and complete clipping, destination-state scene + selection, spatial-index ownership, legacy tie order, duplicate-valued facet + identity, explicit synchronized x/y facet bands, and axis-correct animated + bar insets. The sandbox adds default-primary, x-synchronized, and + y-synchronized facet focus modes with contextual source, plus a live + destination-animation contract before twenty-four chart-family, grouped-bar, + clipping, polar, facet, and large-geometry comparisons. The lab now includes + dense scatter, pre-binned hexagon, nested-bubble paint-order, and richer + Sankey/network probes; its twenty-eight proof families split evenly between + labelled SVG and Canvas cards, and the destination-animation contract renders + in both so attribute interpolation and buffer crossfading share the same + picking semantics. Four composed cases verify that built-in bars, areas, + lines, rectangles, and dots contribute their natural affinity per primitive + without a chart-wide setting, including topmost containment when unlike marks + overlap. Facet coverage includes plain, grouped, stacked, and bubble marks. + Three mixed-mark cases now include an additional native `group-x` or + `group-y` tooltip card. This exposed that grouped tooltips are not an + independent presentation option: each grouped preset replaces the default + scene-containment resolver with nearest-axis selection as well as returning + the focus group. The lab keeps those cards separate and labelled rather than + claiming that geometry-first primary selection and axis grouping currently + compose. + The full unit matrix passes 745 tests across 131 files; + typecheck, documentation, + formatting, packed-consumer, seven-adapter, sandbox production-build, and + live browser checks also pass. + + On Node 24 arm64 on an Apple M4 Pro, the cached scene resolver improves the + unoptimized POC's median query time from 113.5 to 14.2 microseconds for 10k + ordinary points, 62.5 to 16.4 for contained rectangles, 210.6 to 118.0 for + stacked fallback, 64.5 to 16.2 for circles, and 126.2 to 71.8 for 2k + polygons. On an exact-target point fixture, production, scene geometry, + Observable Plot 0.6.17, D3 quadtree, cold D3 Delaunay, and coherent Delaunay + take 13.7, 13.7, 41.4, 2.6, 8.7, and 3.0 microseconds. Quadtree and Delaunay + construction take 1.92 and 2.53 milliseconds for 10k points. A + source-equivalent Vega cached-bounds pass takes 10.0 microseconds versus 15.2 + for the generic rectangle resolver, but deliberately excludes Vega's + subsequent Canvas path test. + + The isolated scene resolver is 4,952 minified / 1,981 gzip bytes versus 157 / + 153 for the anchor-only kernel: a 1,828-byte gzip feature cost under an + explicit 2 kB ceiling. The complete DOM host is 1,816 gzip bytes above the + pre-feature product lock, which remains unchanged for reviewer approval + rather than silently accepting a new baseline. A final size audit removed + redundant built-in `MarkScene.points` arrays and explicit default `xy` + affinity fields while retaining the optional point list for custom-mark + compatibility. Against the immediate pre-audit build, that saves 119 + minified / 58 gzip / 67 Brotli bytes in the representative-marks entry and 24 + / 10 / 39 bytes in the D3-line scene. The interactive host is unchanged + because it does not bundle those mark encoders. Packing cached interaction + targets into tuples was rejected after the same 10k stacked-fallback fixture + regressed from about 118 to 294 microseconds per query; the larger but + optimizer-friendly object shape remains. [Observable Plot](https://observablehq.com/plot/interactions/pointer) documents point-only dead spots and dominant-axis modes; [D3 quadtree](https://d3js.org/d3-quadtree#quadtree_find) and @@ -4318,5 +4357,25 @@ Each entry records: and [Canvas picker](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/canvas/pick.js) establish topmost traversal, cached-bounds rejection, and exact path tests. -- Follow-up: verify rounded corners and true paths, negative bars, animation, - and SVG/Canvas parity before resolving this entry. +- Follow-up: exact picking against optional authored SVG path strings and an + interpolated mid-transition scene remain separate refinements. Verify full + SVG/Canvas parity before resolving this entry. + +### F-177 — Bubble overlap inherited incidental source order + +- Status: resolved +- Severity: medium +- Owner: Application +- Observed in: Palmer penguin bubble-scatter conformance pair +- Friction: translucent bubbles deliberately use paint order to resolve + overlapping containment, but the conformance rows retained incidental source + order. A smaller observation could therefore be painted behind and become + difficult to target even though it remained visually perceptible. +- Decision: share one typed row selector between the Plot and TanStack cases, + filter complete channel values with a type predicate, and paint larger body + masses first so smaller bubbles remain visible and targetable on top. Keep + the library's generic paint-order policy unchanged because authored scene + order can be semantically meaningful. +- Verification: the model regression covers the initial 320-row fixture and + asserts monotonically descending body mass for the paired renderers' shared + row selector. diff --git a/README.md b/README.md index 311b8303..ecb05529 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,8 @@ pnpm dev:conformance The sandbox opens its existing integrated data dashboard by default. The interaction-geometry command opens a before-and-after lab for exercising -painted hit regions and mark-level focus fallback across chart families. +scene-owned interaction geometry, paint order, transforms, clipping, and +natural focus fallback across chart families. The repository includes three complementary benchmark suites: diff --git a/benchmarks/conformance/cases/scatter-bubble/model.test.ts b/benchmarks/conformance/cases/scatter-bubble/model.test.ts new file mode 100644 index 00000000..0d8adece --- /dev/null +++ b/benchmarks/conformance/cases/scatter-bubble/model.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { bubbleRows } from './model' + +describe('bubbleRows', () => { + it('paints larger bubbles first so smaller overlaps remain targetable', () => { + const rows = bubbleRows(0) + + expect(rows).toHaveLength(320) + for (let index = 1; index < rows.length; index += 1) { + expect(rows[index - 1]!.body_mass_g).toBeGreaterThanOrEqual( + rows[index]!.body_mass_g, + ) + } + }) +}) diff --git a/benchmarks/conformance/cases/scatter-bubble/model.ts b/benchmarks/conformance/cases/scatter-bubble/model.ts new file mode 100644 index 00000000..36cb39a0 --- /dev/null +++ b/benchmarks/conformance/cases/scatter-bubble/model.ts @@ -0,0 +1,24 @@ +import { penguins } from '@charts-poc/demo-data/penguins' +import type { PenguinsRow } from '@charts-poc/demo-data/penguins' + +export type BubblePenguin = PenguinsRow & { + culmen_length_mm: number + culmen_depth_mm: number + body_mass_g: number +} + +const completePenguins = penguins.filter(isBubblePenguin) + +export function bubbleRows(revision: number): readonly BubblePenguin[] { + return completePenguins + .slice(revision * 8, revision * 8 + 320) + .sort((left, right) => right.body_mass_g - left.body_mass_g) +} + +function isBubblePenguin(row: PenguinsRow): row is BubblePenguin { + return ( + row.culmen_length_mm !== null && + row.culmen_depth_mm !== null && + row.body_mass_g !== null + ) +} diff --git a/benchmarks/conformance/cases/scatter-bubble/plot.ts b/benchmarks/conformance/cases/scatter-bubble/plot.ts index a5936b7d..7e02826e 100644 --- a/benchmarks/conformance/cases/scatter-bubble/plot.ts +++ b/benchmarks/conformance/cases/scatter-bubble/plot.ts @@ -1,22 +1,13 @@ -import { penguins } from '@charts-poc/demo-data/penguins' import * as Plot from '@observablehq/plot' import { mountObservablePlot } from '../../shared/mount' import type { ConformanceMount } from '../../types' +import { bubbleRows } from './model' const groupRange = ['#2563eb', '#f97316', '#10b981'] -const completePenguins = penguins.filter( - (row) => - row.culmen_length_mm !== null && - row.culmen_depth_mm !== null && - row.body_mass_g !== null, -) export const mount: ConformanceMount = (container, input) => mountObservablePlot(container, input, (nextInput) => { - const rows = completePenguins.slice( - nextInput.revision * 8, - nextInput.revision * 8 + 320, - ) + const rows = bubbleRows(nextInput.revision) return Plot.plot({ width: nextInput.width, diff --git a/benchmarks/conformance/cases/scatter-bubble/tanstack.ts b/benchmarks/conformance/cases/scatter-bubble/tanstack.ts index f3373c03..6c1e3544 100644 --- a/benchmarks/conformance/cases/scatter-bubble/tanstack.ts +++ b/benchmarks/conformance/cases/scatter-bubble/tanstack.ts @@ -1,22 +1,13 @@ -import { penguins } from '@charts-poc/demo-data/penguins' import { colorLegend, defineChart, dot } from '@tanstack/charts' import { scaleLinear, scaleSqrt } from 'd3-scale' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' +import { bubbleRows } from './model' const groupRange = ['#2563eb', '#f97316', '#10b981'] -const completePenguins = penguins.filter( - (row) => - row.culmen_length_mm !== null && - row.culmen_depth_mm !== null && - row.body_mass_g !== null, -) const definition = (input: ConformanceInput) => { - const rows = completePenguins.slice( - input.revision * 8, - input.revision * 8 + 320, - ) + const rows = bubbleRows(input.revision) return defineChart({ marks: [ diff --git a/benchmarks/entries/charts-pointer-geometry-kernel.ts b/benchmarks/entries/charts-pointer-geometry-kernel.ts index ed752e44..0f3e9339 100644 --- a/benchmarks/entries/charts-pointer-geometry-kernel.ts +++ b/benchmarks/entries/charts-pointer-geometry-kernel.ts @@ -1 +1 @@ -export { nearestPoint } from '../../packages/charts-core/src/nearest' +export { nearestScenePoint } from '../../packages/charts-core/src/nearest' diff --git a/benchmarks/interaction/README.md b/benchmarks/interaction/README.md index 37c57c7e..06cfe35b 100644 --- a/benchmarks/interaction/README.md +++ b/benchmarks/interaction/README.md @@ -81,7 +81,7 @@ and [Canvas picker](https://github.com/vega/vega/blob/main/packages/vega-scenegr `pnpm bundle:check` reports isolated anchor-only and geometry resolver entries beside the existing D3 quadtree and Delaunay kernels. The geometry resolver has -a 1 kB gzip ceiling; the historical anchor-only entry stays unbudgeted so the +a 2 kB gzip ceiling; the historical anchor-only entry stays unbudgeted so the comparison remains visible without treating removed behavior as a product. The command exposes explicit garbage collection and rotates implementation diff --git a/benchmarks/interaction/nearest.ts b/benchmarks/interaction/nearest.ts index 06337e91..f00e9254 100644 --- a/benchmarks/interaction/nearest.ts +++ b/benchmarks/interaction/nearest.ts @@ -1,12 +1,32 @@ import { performance } from 'node:perf_hooks' import { Delaunay } from 'd3-delaunay' import { quadtree } from 'd3-quadtree' -import { nearestPoint } from '../../packages/charts-core/src/nearest' +import { nearestScenePoint } from '../../packages/charts-core/src/nearest' import type { - ChartPoint, + ChartFocusAffinity, + ChartPoint as CoreChartPoint, + ChartScene, ChartValue, + SceneNode, } from '../../packages/charts-core/src/types' +type BenchmarkHitRegion = + | { kind: 'rect'; x: number; y: number; width: number; height: number } + | { kind: 'circle'; x: number; y: number; radius: number } + | { + kind: 'polygon' + points: readonly (readonly [number, number])[] + } + +type ChartPoint< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> = CoreChartPoint & { + hitRegion?: BenchmarkHitRegion + focusAffinity?: ChartFocusAffinity +} + interface Datum { index: number } @@ -44,6 +64,11 @@ type Resolver = ( type Implementation = readonly [label: string, resolver: Resolver] +const benchmarkSceneCache = new WeakMap< + readonly ChartPoint[], + ChartScene +>() + const densePoints = Array.from({ length: 10_000 }, (_, index) => point(index, index % 1_000, Math.floor(index / 1_000) * 20), ) @@ -141,7 +166,7 @@ const cases: readonly PointerCase[] = [ const implementations: readonly Implementation[] = [ ['anchor-only baseline', anchorNearestPoint], ['unoptimized POC', pocNearestPoint], - ['optimized geometry', nearestPoint], + ['scene geometry', sceneNearestPoint], ] as const const comparisonCase: PointerCase = { @@ -196,14 +221,14 @@ const delaunayIndex = Delaunay.from( let delaunayCursor = 0 const comparisonImplementations: readonly Implementation[] = [ ['current prod · linear anchor', anchorNearestPoint], - ['new geometry · point-only', nearestPoint], + ['new geometry · point-only', sceneNearestPoint], ['Observable Plot 0.6.17 · pointer kernel', plotPointerNearestPoint], ['D3 quadtree 3.0.1 · indexed', quadtreeNearestPoint], ['D3 Delaunay 6.0.4 · cold start', delaunayNearestPoint], ['D3 Delaunay 6.0.4 · coherent start', coherentDelaunayNearestPoint], ] as const const rectangleComparisonImplementations: readonly Implementation[] = [ - ['new geometry · generic rect', nearestPoint], + ['new geometry · generic rect', sceneNearestPoint], ['Vega 5.2.1 · bounds-only lower bound', vegaBoundsNearestPoint], ] as const const collectGarbage = (globalThis as { gc?: () => void }).gc @@ -416,6 +441,48 @@ function pointWithinDistance( return dx * dx + dy * dy <= Math.max(0, maxDistance) ** 2 ? point : null } +function sceneNearestPoint( + points: readonly ChartPoint[], + x: number, + y: number, + maxDistance: number, +) { + let scene = benchmarkSceneCache.get(points) + if (!scene) { + const nodes: SceneNode[] = [] + for (const point of points) { + const region = point.hitRegion + if (!region) continue + const interaction = { + point, + affinity: point.focusAffinity, + } + nodes.push( + region.kind === 'rect' + ? { ...region, key: point.key, interaction } + : region.kind === 'circle' + ? { + kind: 'dot', + key: point.key, + x: region.x, + y: region.y, + radius: region.radius, + interaction, + } + : { + kind: 'area', + key: point.key, + points: region.points, + interaction, + }, + ) + } + scene = { nodes, points } as unknown as ChartScene + benchmarkSceneCache.set(points, scene) + } + return nearestScenePoint(scene, x, y, maxDistance) +} + // Vega's Canvas picker traverses the scene in reverse paint order and first // rejects items against cached bounds. This intentionally stops before Vega's // mark-specific Canvas path test, so it is a lower bound rather than a claim @@ -636,7 +703,7 @@ function verifyEquivalentResults() { query.y, query.maxDistance, ) - const optimized = nearestPoint( + const optimized = sceneNearestPoint( benchmark.points, query.x, query.y, @@ -679,7 +746,7 @@ function verifyComparisonResults() { function verifyRectangleComparisonResults() { for (const query of rectangleComparisonCase.queries) { - const reference = nearestPoint( + const reference = sceneNearestPoint( rectangleComparisonCase.points, query.x, query.y, @@ -727,7 +794,7 @@ function printScenarioComparisons( console.log('\nCurrent production vs new geometry · median query time') for (const { benchmark, measurements } of scenarios) { const current = percentile(measurements.get('anchor-only baseline')!, 0.5) - const optimized = percentile(measurements.get('optimized geometry')!, 0.5) + const optimized = percentile(measurements.get('scene geometry')!, 0.5) const maximum = Math.max(current, optimized) const currentWidth = Math.max(1, Math.round((current / maximum) * 36)) const optimizedWidth = Math.max(1, Math.round((optimized / maximum) * 36)) diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index bf2adf40..06413984 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -111,36 +111,38 @@ Each point should retain: - resolved pixel coordinates; - group identity and color. -For a large painted mark, set `hitRegion` on each emitted point and declare -the mark's off-shape fallback once with `focusAffinity`: +For a large painted mark, create the semantic point once and attach that same +object to the scene primitive that paints it: ```ts -return { - id, - focusAffinity: 'x', - channels, - render(context) { - return { - nodes, - points: nodes.map((node, index) => ({ - ...interactionPoint(index), - hitRegion: { - kind: 'rect', - x: node.x, - y: node.y, - width: node.width, - height: node.height, - }, - })), - } - }, +import type { SceneRect } from '@tanstack/charts' + +const point = interactionPoint(index) +const node: SceneRect = { + kind: 'rect', + key: point.key, + x, + y, + width, + height, + interaction: { point, affinity: 'x' }, } + +return { nodes: [node], points: [point] } ``` Use `x` for vertically oriented marks, `y` for horizontal marks, `xy` for ordinary two-dimensional proximity, and `geometry` when only exact containment should focus the mark. The default resolver checks containment -across every mark before applying any fallback. +across every mark before applying any fallback. A continuous `polyline` or +`area` may attach all of the semantic samples it represents with +`interaction: { points, affinity }`; containment selects the closest sample +within that primitive. + +Keep primitive coordinates local when returning translated groups. Scene +traversal applies nested translation, clipping, facets, and paint order after +layout. Do not calculate a second set of global hit bounds beside the rendered +node. Omit points for decorative geometry. Do not invent fake interactive data for a frame, grid, or threshold that should not receive focus. @@ -201,6 +203,10 @@ coordinate conversion, focus painting, and cleanup. The host retains sizing, runtime, keyboard, tooltip, selection, and focus-strategy behavior. Keep `prerender` deterministic and make `mount` adopt compatible server markup. +If `paintFocus` resolves and paints inline mark-state geometry, return that +destination `ChartScene`. The host will use it for subsequent pointer hits; +returning nothing preserves base-scene interaction for simpler renderers. + Use `ChartRendererRenderContext.surface` instead of assuming `onRender` exposes an SVG element. Framework consumers pass `renderer` through `@tanstack/react-charts/core` or `@tanstack/octane-charts/core`. @@ -236,8 +242,9 @@ navigation. Its generic types must remain identical to the chart points it receives. A `ChartSpatialIndexFactory` builds optional nearest-point acceleration from -scene points. 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 as its second argument. +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/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index 07b58783..87cfc900 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -48,6 +48,23 @@ category. A sparse snapped cursor can opt into `maxFocusDistance: Number.POSITIVE_INFINITY`; keep the finite default when empty space should mean no focus. +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 +turning those mirrors into additional selected data, add an ordinary focus +mark with `whenFocused(..., { match: 'x' })` or `match: 'y'`. The tooltip and +focus callback still receive the resolver's primary point or explicit focus +group. + +```ts +whenFocused(bandX(rows, { x: 'date' }), { match: 'x' }) +whenFocused(bandY(rows, { y: 'value' }), { match: 'y' }) +``` + +These are presentation filters, not alternate selection strategies. The first +paints a vertical band wherever the focused x value exists; the second paints a +horizontal band wherever the focused y value exists. +