diff --git a/.changeset/bright-hit-regions.md b/.changeset/bright-hit-regions.md new file mode 100644 index 00000000..d8035065 --- /dev/null +++ b/.changeset/bright-hit-regions.md @@ -0,0 +1,14 @@ +--- +'@tanstack/charts': patch +--- + +Resolve default pointer focus against painted mark geometry before applying a +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 67f48f09..977cef47 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -211,6 +211,8 @@ 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 | +| F-177 | Bubble overlap inherited incidental source order | Application | resolved | ## Findings @@ -4257,3 +4259,127 @@ 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 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, 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 5,010 minified / 2,005 gzip bytes versus 157 / + 153 for the anchor-only kernel: a 1,852-byte gzip feature cost under an + explicit 2 KiB ceiling. Against the pre-feature product lock, the complete + DOM host adds 1,840 gzip bytes, the React line consumer adds 1,859, and the + native host adds 1,824. These shared-host costs and the related aggregate + fixture ceilings were reviewed and accepted because painted-geometry + interaction is the default contract across DOM, Canvas, and native charts; + the exact locked baselines now record that decision while the isolated 2 kB + ceiling continues to constrain the resolver itself. 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 + [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: 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/PLAN.md b/PLAN.md index 255a04b9..fe7bd89f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -744,7 +744,7 @@ Measured by `pnpm bundle` with esbuild minification and Node gzip. These are loc | Bundle | Minified | Gzip | | --------------------------------------- | --------: | --------: | -| Core host | 6.97 kB | 2.85 kB | +| Legacy Plot POC host core | 6.97 kB | 2.85 kB | | Native `lineY` scene | 7.46 kB | 3.06 kB | | Native `lineY` plus static SVG | 9.75 kB | 3.93 kB | | Native DOM host | 9.59 kB | 3.90 kB | diff --git a/README.md b/README.md index 67bfc53b..ecb05529 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,15 @@ 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 +scene-owned interaction geometry, paint order, transforms, clipping, and +natural focus fallback across chart families. + The repository includes three complementary benchmark suites: - [`benchmarks/bundle-size`](./benchmarks/bundle-size) locks ordinary consumer @@ -309,6 +315,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/bundle-size/README.md b/benchmarks/bundle-size/README.md index e631ee44..0756029c 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,11 +25,17 @@ may add only its transport module over the tooltip consumer. Ordinary line, compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The -scene has an 8.1 KiB gzip ceiling. The React line consumer has a 16.8 KiB -ceiling with React and React DOM external. `d3-array` tick helpers are allowed -only in the compact linear path; categorical compact-scale kernels reject -every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`, -`d3-interpolate`, `d3-color`, and `internmap`. +scene has an 8.1 KiB gzip ceiling. The React compact-scale line consumer has an +18.6 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +allowed only in the compact linear path; categorical compact-scale kernels +reject every D3 runtime input. All compact fixtures reject `d3-scale`, +`d3-format`, `d3-interpolate`, `d3-color`, and `internmap`. + +Painted-geometry interaction is part of the default scene and host contract +across DOM, Canvas, and native rendering. Its isolated resolver has a 2 KiB +gzip ceiling. The locked shared-host entries record the reviewed integration +cost, while noninteractive consumers retain only the small scene-compiler +portion of that contract. Every public transform family has an isolated budget and retained-input allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index ec3e2747..972100ce 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 39086, - "gzip": 15175 + "bytes": 39461, + "gzip": 15332 }, "D3-scale line + static SVG": { - "bytes": 42027, - "gzip": 16268 + "bytes": 42402, + "gzip": 16421 }, "Representative marks": { - "bytes": 57688, - "gzip": 21428 + "bytes": 58137, + "gzip": 21586 }, "TanStack DOM host": { - "bytes": 38770, - "gzip": 14229 + "bytes": 44140, + "gzip": 16069 }, "React adapter": { - "bytes": 40937, - "gzip": 14924 + "bytes": 46308, + "gzip": 16790 }, "React line consumer": { - "bytes": 63516, - "gzip": 23973 + "bytes": 68924, + "gzip": 25832 }, "Compact-scale line scene": { - "bytes": 21550, - "gzip": 8118 + "bytes": 21925, + "gzip": 8275 }, "React compact-scale line consumer": { - "bytes": 46033, - "gzip": 16876 + "bytes": 51437, + "gzip": 18736 }, "Custom-scale line scene": { - "bytes": 19734, - "gzip": 7371 + "bytes": 20110, + "gzip": 7529 }, "D3 linear-scale line scene": { - "bytes": 39018, - "gzip": 15139 + "bytes": 39393, + "gzip": 15295 } } } 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-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..0f3e9339 --- /dev/null +++ b/benchmarks/entries/charts-pointer-geometry-kernel.ts @@ -0,0 +1 @@ +export { nearestScenePoint } from '../../packages/charts-core/src/nearest' diff --git a/benchmarks/interaction/README.md b/benchmarks/interaction/README.md new file mode 100644 index 00000000..6ae9f952 --- /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 2 KiB 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..f00e9254 --- /dev/null +++ b/benchmarks/interaction/nearest.ts @@ -0,0 +1,931 @@ +import { performance } from 'node:perf_hooks' +import { Delaunay } from 'd3-delaunay' +import { quadtree } from 'd3-quadtree' +import { nearestScenePoint } from '../../packages/charts-core/src/nearest' +import type { + 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 +} + +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 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), +) +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], + ['scene geometry', sceneNearestPoint], +] 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', 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', sceneNearestPoint], + ['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 +} + +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 +// 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 = sceneNearestPoint( + 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 = sceneNearestPoint( + 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('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)) + 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/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index f61bb378..065f5802 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -66,7 +66,7 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 16.8 kB gzip. +The locked compact React line consumer must remain at or below 18.6 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index 0d200f5b..06413984 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -111,6 +111,39 @@ Each point should retain: - resolved pixel coordinates; - group identity and color. +For a large painted mark, create the semantic point once and attach that same +object to the scene primitive that paints it: + +```ts +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. 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. @@ -170,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`. @@ -205,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. +