diff --git a/.changeset/live-paths-shift.md b/.changeset/live-paths-shift.md new file mode 100644 index 00000000..b2d40e31 --- /dev/null +++ b/.changeset/live-paths-shift.md @@ -0,0 +1,8 @@ +--- +'@tanstack/charts': patch +--- + +Add validated rolling path transforms with dynamic y-domain reprojection, +continuous translated viewports with stationary guides, and a controlled focus +controller that follows presentation geometry. Default SVG rendering now +honors scene clips and gradients. diff --git a/.gitignore b/.gitignore index 24571ae4..5fb41b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules .pnpm-store dist +dist-live +dist-paged +.netlify/ .svelte-kit .bundle-output .benchmark-output diff --git a/API-FRICTION.md b/API-FRICTION.md index 486e8b4d..09fbc3fd 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -251,6 +251,11 @@ Each entry records: | F-213 | Focused rules had no matchable presentation points | API | resolved | | F-214 | Callback parameter shapes were inconsistent | API/Tooling | resolved | | F-215 | Example keys collapsed distinct source rows | Application | resolved | +| F-216 | Rolling paths morphed samples instead of shifting them | API | resolved | +| F-217 | Motion ignored authored SVG clips | API | resolved | +| F-218 | Paged history required overlaid chart hosts | API | resolved | +| F-219 | Long-press focus duplicated host pointer geometry | API | resolved | +| F-220 | Focus cursor width depended on private band inference | API | resolved | ## Findings @@ -5315,10 +5320,12 @@ Each entry records: exact upstream protocols such as D3 threshold generators, and consumer-called service methods as classified exceptions. - Verification: the public callback inventory follows exported types, - functions, and values into nested package-owned types. It classifies all 527 - reachable callable surfaces, including Alpine's external directive protocol - and Vue's nested tooltip slot, and rejects unclassified surfaces, callback - arity above two, or a non-object second callback argument. Failed parameter + functions, and values into nested package-owned types. It classifies all 534 + reachable callable surfaces, including Alpine's external directive protocol, + Vue's nested tooltip slot, live-chart interaction and presentation service + handles, the presentation-points listener callback, and viewport mapping. It + rejects unclassified surfaces, callback arity above two, or a non-object + second callback argument. Failed parameter type resolution preserves the parameter as a fail-closed non-object bag instead of aborting or undercounting the inventory. Focused core, React Native, React, Octane, channel, facet, focus, legend, tooltip, and contract @@ -5351,3 +5358,176 @@ Each entry records: many-point example retains 300 unique keys for their initial and revised windows. Focused standard conformance passes each example's full 320/640/960 light/dark visual matrix. + +### F-216 — Rolling paths morphed samples instead of shifting them + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: Liveline-inspired streaming React examples +- Friction: a fixed-length rolling line retained keyed samples, but SVG path + interpolation matched commands by array position. Each old y-value therefore + bent toward the following sample instead of the trace translating left. + Stable datum keys already preserved interaction points but could not change + the single path element's interpolation strategy. +- Decision: add one first-principles rolling object contract, + `motion.path: { update: 'rolling', x: 'shift', y, fallback }`, with fixed or + affine-reprojected y geometry and an explicit snap-or-morph fallback. Validate + the retained key window, balanced batch, stable semantic values, uniform x + displacement, stable primitive kind and plot bounds, required clipping, + structured path geometry, clip-edge coverage, and the absence of transient x + or y viewport translation before installing the target path and animating one + matrix to identity. Invalid rolling updates snap by default instead of + silently becoming a different interpolation. +- Follow-up evidence: while a rolling path translated correctly, its SVG focus + circles snapped to destination coordinates and an active tooltip received no + updates as presentation points advanced. The surface could expose current + presentation geometry but had no notification contract for the shared host. +- Follow-up decision: animate keyed focus-layer geometry with its owning data + points and add optional `ChartSurface.subscribePresentationPoints()`. The + shared host now re-resolves a stationary pointer or restores pinned and + keyboard focus against each published presentation frame, keeping tooltip + anchors and focus markers aligned without chart-specific wiring. +- Follow-up evidence: entering and exiting point dots still used independent + fades, snap fallback retained removed presentation points until its nominal + duration, and applying an inline focus state cancelled the active data + transform. +- Follow-up decision: associate both previous and target semantic points with + each rolling plan. Retained and entering dots, default focus circles, + exiting dots, and presentation points now share the path transform and + timing; snap removes stale geometry and points in the same commit. Focus + layers remain live during data motion, while inline mark-state geometry and + style retain the latest request across back-to-back updates and reconcile + when data motion becomes idle instead of taking over its transform. +- Verification: pure planner tests cover valid batches and every rejected + invariant, including nonzero x or y viewport translation. Integration tests + hold target line and area path data constant while x translation and affine y + reprojection animate through one matrix, verify matching presentation points, + compose an interrupted A-to-B-to-C update from the currently painted + transform, keep retained, entering, and exiting point and focus decorations + aligned, keep an authored focus band on the same trajectory, retain a + deferred inline focus state across back-to-back rolls, and verify snap removes + old DOM and presentation points without scheduling a frame. The live + dynamic-y example uses the rolling object contract, clipped overscan, a stable + semantic area baseline, and fixed plot margins. The complete motion SVG + renderer measures 14.17 KiB gzip under its reviewed 14.4 KiB ceiling. + +### F-217 — Motion ignored authored SVG clips + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: Liveline-inspired streaming React examples +- Friction: the definitions correctly set `clip: true`, but `motion()` used the + resource-free SVG serializer. Translated line and area geometry therefore + painted through the y-axis labels and beyond the plot instead of being + clipped to `scene.chart`. There was no public way to combine the motion + renderer with the resource-aware serializer. +- Decision: make resource-aware SVG serialization the default and use it from + `motion()` for both prerendering and updates. SVG hosts now consume clips and + gradients already declared in the renderer-neutral scene. +- Verification: the streaming motion regression asserts that the marks group + references a generated clip path whose rectangle exactly matches the + resolved chart x, y, width, and height. The running React examples expose + three distinct scoped clip paths and keep linear shifted marks inside each + plot while guides remain outside. The static SVG line consumer measures + 17.75 KiB gzip; the reviewed universal baseline records its 1,743-byte gzip + increase from making scene resources part of the default renderer. + +### F-218 — Paged history required overlaid chart hosts + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: iOS-style paged history React example +- Friction: stationary guides over a continuously swiped line required two + overlaid chart hosts, duplicated scale definitions, manual width measurement, + CSS clipping, and pixel offsets. Focus geometry belonged to one host while + the visible coordinates belonged to the other. +- Decision: add a continuous axis `viewport` with a committed semantic domain + and transient scene-pixel translation. Resolve guides against the viewport + domain and place viewport content in clipped layers per mark and per owned + axis. Infer ownership from materialized channels and let custom marks override + each axis as `content` or `fixed`, keeping guides and unrelated annotations + stationary. Remap scene, node-interaction, focus-layer, and mark-state point + references to presented coordinates. Expose the full content domain and + presented mapper on the resolved scale. Preserve complete scene points for + rendering and diagnostics while `viewportInteractionPoints` and the optional + `findNearestPoint` candidate list limit focus and keyboard navigation to + clipped content anchors inside the plot while retaining points from + fixed-ownership marks outside it. +- Verification: type tests constrain `ChartContinuousDomain` to homogeneous + numeric or Date endpoints. Configured-scale tests require continuous, + invertible, unclamped scales with independently configurable domain and range, + reject categorical, quantize, getter-only, and clamped configured scales, + reject an authored viewport on an opaque custom resolver, accept a custom + resolver that returns its own complete viewport, and cover positive, + reversed, and negative same-sign logarithmic domains. Scene, + SVG, focus, and renderer tests assert screen-direction translation, + per-mark/per-axis clips, fixed guides and annotations, explicit custom-mark + ownership, presented point references, candidate filtering, and tooltip/focus + continuity. The paged example renders its complete history as one line and + area in one chart host while the application owns only drag policy and page + settling. The locked D3-scale line scene measures 16.18 KiB gzip, a reviewed + 1,233-byte increase for the default viewport-capable scene contract. + +### F-219 — Long-press focus duplicated host pointer geometry + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: iOS-style paged history React example +- Friction: delaying focus until a touch hold required the application to read + SVG bounds, convert client coordinates, search scene points, position a + cursor, and render a second tooltip. The chart already owned all of that + logic, but its pointer handling was all-or-nothing and not callable. +- Decision: expose one stable `ChartInteractionController` on the host and + render context. `clientToScene()` exposes renderer-correct drag geometry, + `resolvePointer()` applies the current presentation and configured focus + strategy, and `setControlledFocus()` paints or clears the same focus marks + and tooltip as native input. Definition `pointer: false` disables automatic + pointer move, leave, and click without disabling keyboard focus, and + controlled focus has separate ownership from pointer and keyboard focus. + Share SVG client-to-scene conversion between normal and motion surfaces. + Keep that surface capability optional for existing custom renderers; the + controller returns `null` when it is absent. Passing a pointer resolution to + `setControlledFocus()` infers pointer source unless explicitly overridden, + while a raw point defaults to programmatic source. +- Follow-up evidence: the host rebuilt a configured spatial index from the + transition-start presentation points immediately after a data update. Motion + correctly bypassed that index while presentation points were active, but + re-enabled the stale index when the transition settled. +- Follow-up decision: build spatial indexes from the destination scene's + visible points. Presentation points remain authoritative during motion and + the destination index becomes authoritative only after they settle. +- Verification: renderer tests cover resolution, focus groups, clearing, + pinning, pointer opt-out, ownership boundaries, presentation updates, and + controller identity, including inferred pointer source across a scene update + and destination spatial-index resolution after presentation geometry clears. + Every DOM framework adapter forwards the controller in `onRender`, and the + paged example delegates its long-press cursor and tooltip to the definition + without application SVG math. The locked DOM host measures 18.39 KiB gzip; + its reviewed 2,583-byte increase includes the default viewport and controlled + interaction contracts. + +### F-220 — Focus cursor width depended on private band inference + +- Status: resolved +- Severity: low +- Owner: API +- Observed in: iOS-style paged history React example +- Friction: a one-pixel definition-owned cursor could use a focused `bandX`, + but its width was always inferred from sample spacing. Producing a precise + cursor with `inset` required the application to duplicate the mark's private + `0.8` bandwidth factor. At the time, `ruleX` could paint the right geometry + but emitted no focus-match points. +- Decision: add explicit scene-pixel `width` to `bandX` and `height` to + `bandY`. Explicit dimensions replace scale or inferred bandwidth before + applying `inset`; existing definitions retain inferred sizing. +- Verification: mark tests assert fixed one- and two-pixel continuous bands, + and the paged history definition expresses its cursor as `width: 1` without + responsive sample-spacing math. +- Follow-up: F-213 later added presentation-only focus points to rules, so a + focused `ruleX` can now express a one-pixel cursor too. Explicit band + dimensions remain useful when authored band geometry needs an exact scene- + pixel width or height. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 0756029c..317e3ca0 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,18 +25,25 @@ 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 compact-scale line consumer has an -18.6 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +scene has a 9.5 KiB gzip ceiling. The React compact-scale line consumer has a +21.2 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 +across DOM, Canvas, and native rendering. Its isolated resolver has a 2.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. +Continuous viewports and the controlled interaction controller are also part +of the default scene and host contracts. Default static SVG consumes scene +clips and gradients. Their reviewed shared-path cost is recorded in the locked +entries and the corresponding complete-consumer budgets. Rolling path planning +remains confined to the opt-in motion renderer, whose complete SVG budget is +14.4 KiB gzip. + 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 `d3-shape`. Other transform entries reject those dependencies, while every diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 5aac91b5..8893d357 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": 39569, - "gzip": 15382 + "bytes": 43460, + "gzip": 16624 }, "D3-scale line + static SVG": { - "bytes": 42510, - "gzip": 16473 + "bytes": 47882, + "gzip": 18219 }, "Representative marks": { - "bytes": 58666, - "gzip": 21703 + "bytes": 64027, + "gzip": 23441 }, "TanStack DOM host": { - "bytes": 44879, - "gzip": 16403 + "bytes": 52638, + "gzip": 18983 }, "React adapter": { - "bytes": 47104, - "gzip": 17127 + "bytes": 54837, + "gzip": 19813 }, "React line consumer": { - "bytes": 69840, - "gzip": 26233 + "bytes": 77572, + "gzip": 28813 }, "Compact-scale line scene": { - "bytes": 22033, - "gzip": 8322 + "bytes": 25919, + "gzip": 9529 }, "React compact-scale line consumer": { - "bytes": 52348, - "gzip": 19127 + "bytes": 60079, + "gzip": 21717 }, "Custom-scale line scene": { - "bytes": 20217, - "gzip": 7583 + "bytes": 24103, + "gzip": 8801 }, "D3 linear-scale line scene": { - "bytes": 39501, - "gzip": 15346 + "bytes": 43392, + "gzip": 16588 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 179e1df3..09362d66 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "generatedAt": "2026-08-05T16:17:02.804Z", + "generatedAt": "2026-08-05T17:26:11.302Z", "packageVersions": { "tanstack": "0.6.5", "chartjs": "4.5.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "dcce66ca3838ebd4a6eda17e2a11e5ef01832d5a" + "revision": "2688b431ae10fe42a9132a2e1f7c88d58cb0cd28" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 78607, - "gzipBytes": 29626, - "brotliBytes": 26260, - "incrementalGzipBytes": 29626, - "incrementalBrotliBytes": 26260 + "minifiedBytes": 86395, + "gzipBytes": 32291, + "brotliBytes": 28544, + "incrementalGzipBytes": 32291, + "incrementalBrotliBytes": 28544 }, "tanstack-line-interactive": { - "minifiedBytes": 83809, - "gzipBytes": 31281, - "brotliBytes": 27591, - "incrementalGzipBytes": 31281, - "incrementalBrotliBytes": 27591 + "minifiedBytes": 91597, + "gzipBytes": 33900, + "brotliBytes": 29976, + "incrementalGzipBytes": 33900, + "incrementalBrotliBytes": 29976 }, "tanstack-line-advanced": { - "minifiedBytes": 90990, - "gzipBytes": 33624, - "brotliBytes": 29636, - "incrementalGzipBytes": 33624, - "incrementalBrotliBytes": 29636 + "minifiedBytes": 98780, + "gzipBytes": 36151, + "brotliBytes": 31926, + "incrementalGzipBytes": 36151, + "incrementalBrotliBytes": 31926 }, "tanstack-bar-basic": { - "minifiedBytes": 85622, - "gzipBytes": 32298, - "brotliBytes": 28578, - "incrementalGzipBytes": 32298, - "incrementalBrotliBytes": 28578 + "minifiedBytes": 93405, + "gzipBytes": 34862, + "brotliBytes": 30778, + "incrementalGzipBytes": 34862, + "incrementalBrotliBytes": 30778 }, "tanstack-bar-interactive": { - "minifiedBytes": 89673, - "gzipBytes": 33507, - "brotliBytes": 29550, - "incrementalGzipBytes": 33507, - "incrementalBrotliBytes": 29550 + "minifiedBytes": 97462, + "gzipBytes": 36077, + "brotliBytes": 31824, + "incrementalGzipBytes": 36077, + "incrementalBrotliBytes": 31824 }, "tanstack-bar-advanced": { - "minifiedBytes": 90012, - "gzipBytes": 33641, - "brotliBytes": 29696, - "incrementalGzipBytes": 33641, - "incrementalBrotliBytes": 29696 + "minifiedBytes": 97801, + "gzipBytes": 36224, + "brotliBytes": 31969, + "incrementalGzipBytes": 36224, + "incrementalBrotliBytes": 31969 }, "tanstack-area-basic": { - "minifiedBytes": 82421, - "gzipBytes": 31119, - "brotliBytes": 27580, - "incrementalGzipBytes": 31119, - "incrementalBrotliBytes": 27580 + "minifiedBytes": 90206, + "gzipBytes": 33673, + "brotliBytes": 29886, + "incrementalGzipBytes": 33673, + "incrementalBrotliBytes": 29886 }, "tanstack-area-interactive": { - "minifiedBytes": 87627, - "gzipBytes": 32804, - "brotliBytes": 28888, - "incrementalGzipBytes": 32804, - "incrementalBrotliBytes": 28888 + "minifiedBytes": 95412, + "gzipBytes": 35303, + "brotliBytes": 31191, + "incrementalGzipBytes": 35303, + "incrementalBrotliBytes": 31191 }, "tanstack-area-advanced": { - "minifiedBytes": 94990, - "gzipBytes": 35086, - "brotliBytes": 30958, - "incrementalGzipBytes": 35086, - "incrementalBrotliBytes": 30958 + "minifiedBytes": 102780, + "gzipBytes": 37708, + "brotliBytes": 33313, + "incrementalGzipBytes": 37708, + "incrementalBrotliBytes": 33313 }, "tanstack-scatter-basic": { - "minifiedBytes": 78269, - "gzipBytes": 29533, - "brotliBytes": 26166, - "incrementalGzipBytes": 29533, - "incrementalBrotliBytes": 26166 + "minifiedBytes": 86057, + "gzipBytes": 32196, + "brotliBytes": 28492, + "incrementalGzipBytes": 32196, + "incrementalBrotliBytes": 28492 }, "tanstack-scatter-interactive": { - "minifiedBytes": 83467, - "gzipBytes": 31175, - "brotliBytes": 27513, - "incrementalGzipBytes": 31175, - "incrementalBrotliBytes": 27513 + "minifiedBytes": 91259, + "gzipBytes": 33790, + "brotliBytes": 29828, + "incrementalGzipBytes": 33790, + "incrementalBrotliBytes": 29828 }, "tanstack-scatter-advanced": { - "minifiedBytes": 83483, - "gzipBytes": 31181, - "brotliBytes": 27567, - "incrementalGzipBytes": 31181, - "incrementalBrotliBytes": 27567 + "minifiedBytes": 91275, + "gzipBytes": 33795, + "brotliBytes": 29824, + "incrementalGzipBytes": 33795, + "incrementalBrotliBytes": 29824 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 05b513f9..2245c3e7 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `dcce66c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2688b43` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `dcce66c`. +at page render time. The measured TanStack workspace revision is `2688b43`. ## Capability matrix @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 28.84–34.26 KiB | — | Controlled suite | +| TanStack Charts | 31.44–36.82 KiB | — | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 1accafe5..952e7d1d 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -226,6 +226,70 @@ For a normal cartesian chart: See [Scales](./scales-and-d3.md) for scale selection, responsive ownership, and pixel-to-value inversion. +## Continuous viewports + +A continuous axis can present one semantic window of a larger content domain: + +```ts +const x = { + scale: scaleUtc().domain([historyStart, historyEnd]), + viewport: { + domain: [visibleStart, visibleEnd], + translate: dragOffset, + }, +} +``` + +The configured or inferred scale domain describes the complete content. +`viewport.domain` is the committed semantic window used for mapping, axes, and +grid lines. `viewport.translate` is a transient output-space offset applied +after that mapping. + +Viewport ownership is resolved for each mark and each axis. A mark that +materializes an active viewport axis is content on that axis by default. The +compiler gives each such mark its own plot-bounded clip layer and applies only +the translations for axes it owns. Axes and grid lines stay fixed. Marks that +do not depend on the translated axis also stay fixed, such as a frame or a +y-only annotation during an x drag. Custom marks can override either axis as +`'content'` or `'fixed'` through `InitializedMark.viewport`. + +Marks, focus layers, interaction points, and tooltip anchors use the same +presented coordinates. `scene.points` retains every content point, including +off-window points, for rendering and diagnostics. The interaction host limits +pointer strategies and keyboard navigation to clipped content points whose +presented anchors are inside the plot clip. Points from marks with fixed +viewport ownership remain candidates outside the plot. +`viewportInteractionPoints(scene)` returns that subset without changing +`scene.points`. + +Translation is expressed in screen-direction scene pixels: positive x moves +content right, negative x moves it left, positive y moves it down, and negative +y moves it up. Domain order and `reverse` do not change those directions. + +This makes paged history one chart and one continuous line rather than a guide +chart overlaid with several plot charts. During a drag, keep the committed +domain fixed and update only `translate`. To settle one page, animate the +translation to one plot width, then update the semantic domain and reset the +translation to zero in the same application commit. + +Viewport domains accept two distinct finite numbers or two distinct finite +Dates. The scale must be configured or inferable, continuous, invertible, +unclamped, and independently accept domain and range assignment. Band, +ordinal, quantize, clamped, and getter-only scales are rejected. An authored +`axis.viewport` cannot be applied to an opaque custom `ChartScale`; a custom +resolver can instead return a complete `ResolvedScale.viewport` that it owns. +A logarithmic content domain and viewport domain must contain finite, nonzero +numbers and remain on the same side of zero. + +The resolved scale exposes both coordinate systems: + +```ts +const { contentDomain, domain, translate, map } = scene.scales.x.viewport! +``` + +`scene.scales.x.map(value)` is the committed, untranslated coordinate used to +construct geometry. `viewport.map(value)` returns its presented coordinate. + ## Non-cartesian coordinates Polar and geographic marks resolve geometry from the same final diff --git a/docs/framework/octane/adapter.md b/docs/framework/octane/adapter.md index c383d6f8..334432ff 100644 --- a/docs/framework/octane/adapter.md +++ b/docs/framework/octane/adapter.md @@ -70,8 +70,9 @@ resource prefix from Octane's `useId()` when `idPrefix` is absent. `tabIndex` defaults to `0` on both targets. `keyboard: false` forces it to `-1`. -Pass `renderChartSvgWithResources` on both targets for gradients and clipping; -see [Rendering and export](../../reference/rendering-and-export.md#resource-aware-svg). +The default SVG renderer emits gradients and clipping on both targets. Custom +serializers must preserve the same resources; see +[Rendering and export](../../reference/rendering-and-export.md#svg-resources). ## Sizing and layout diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 798fa3d6..ee605aeb 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -97,9 +97,9 @@ stable through hydration. `tabIndex` defaults to `0` on both server and client. `keyboard: false` forces it to `-1`. -For resource-aware gradients or clipping, pass the same renderer on both -server and client. See -[Rendering and export](../../reference/rendering-and-export.md#resource-aware-svg). +The default SVG renderer emits gradients and clipping on both server and +client. Custom serializers must preserve the same resources. See +[Rendering and export](../../reference/rendering-and-export.md#svg-resources). ## Sizing and layout diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index dbc726bb..9084f38e 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -199,14 +199,20 @@ const host = mountChartRenderer(container, { ``` The renderer owns server shell markup, its mounted element, scene painting, -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. +focus painting, and cleanup. It can implement `clientToScene` when controlled +pointer gestures need client-coordinate conversion; the interaction controller +returns `null` when that optional capability is absent. 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. +If the renderer animates point geometry, implement `getPresentationPoints` +and `subscribePresentationPoints`. This keeps stationary pointer focus, +keyboard focus, and tooltip anchors aligned with the painted frame. + 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`. @@ -231,9 +237,9 @@ adapter. Preserve: - scoped IDs through `idPrefix`; - deterministic server output. -Use `renderChartSvgWithResources` from -`@tanstack/charts/svg/resources` when the only missing behavior is gradients or -clipping. +The default `renderChartSvg` already emits declared gradients and group clips. +The compatible `renderChartSvgWithResources` export remains available when an +explicit resource serializer name is useful. ## Custom focus and spatial indexes diff --git a/docs/guides/dynamic-data-and-animation.md b/docs/guides/dynamic-data-and-animation.md index 42f3449b..690475ab 100644 --- a/docs/guides/dynamic-data-and-animation.md +++ b/docs/guides/dynamic-data-and-animation.md @@ -198,4 +198,37 @@ For high-rate data: 4. Keep viewport state controlled. 5. Coalesce upstream work when only the latest state matters. +For a scrolling trace, keep enough overscan before the visible x-domain to +cover the largest expected update batch, enable `clip`, and use a rolling path +contract: + +```ts +const definition = defineChart({ + motion: { + path: { + update: 'rolling', + x: 'shift', + y: 'reproject', + fallback: 'snap', + }, + transition: { type: 'tween', duration: sampleInterval, easing: 'linear' }, + }, + marks, +}) +``` + +The keyed retained window moves as one affine path. `y: 'reproject'` keeps that +motion valid while a continuous y-domain changes. A failed rolling invariant +snaps instead of occasionally becoming a different interpolation. Set +`fallback: 'morph'` only when path interpolation is intentional. A valid update +that arrives during another roll composes from the transform currently painted +on screen. + +Keep `viewport.translate` at zero on both the previous and target scene during +a rolling update. A nonzero transient viewport translation makes the rolling +contract fail and uses its configured fallback. Commit the viewport domain and +reset the translation before applying the next live-data window. Keep plot +margins fixed and prefer linear segments so appending a sample cannot recompute +a visible curve tangent. + The final definition passed to `host.update` is applied synchronously. diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index ed229b22..f9d9b942 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -46,6 +46,58 @@ Every application-owned gesture follows the same loop: Do not mutate SVG geometry directly and then attempt to reconcile application state afterward. +## Controlled point inspection + +Use the chart's interaction controller when the application owns pointer +timing but still wants the definition's focus strategy, focus marks, and +tooltip. Long-press inspection is one example: + +```tsx +let interaction: ChartInteractionController | undefined + +const definition = defineChart({ + marks: [lineY(rows, { x: 'date', y: 'value', key: 'id' })], + x: { scale: scaleUtc() }, + y: { scale: scaleLinear() }, + focus: 'nearest-x', + pointer: false, + tooltip, +}) + +const chart = ( + { + interaction = context.interaction + }} + /> +) + +function inspect(clientX: number, clientY: number) { + interaction?.setControlledFocus(interaction.resolvePointer(clientX, clientY)) +} + +function stopInspecting() { + interaction?.setControlledFocus(null) +} +``` + +`resolvePointer` uses the current renderer presentation, including an active +motion or viewport transform, and returns the scene position, primary point, +and complete focus group. `setControlledFocus` paints the same definition-owned +focus and tooltip as native pointer input. Pass `{ pinned: true }` when the +configured sticky tooltip should accept interaction. + +For a drag that does not require a nearby datum, use +`interaction.clientToScene(clientX, clientY)`. It applies the renderer's full +client-to-scene transform without coupling viewport movement to point focus. + +`pointer: false` disables automatic pointer move, leave, and click handling. It +does not disable keyboard navigation. Controlled focus has separate ownership, +so unrelated mouse-leave and focus-out events cannot clear it. The stable +controller is available as `host.interaction` and in every `onRender` context. + ## Invert configured scales Copy the same configured continuous scale onto the resolved plot range: @@ -76,7 +128,7 @@ D3 ownership and official interaction-module links. ## Disable competing datum focus -When a gesture owns the chart surface, disable native focus explicitly: +When a gesture has no datum inspection at all, disable native focus explicitly: ```ts import { focusDisabled } from '@tanstack/charts/focus/disabled' @@ -93,9 +145,10 @@ mountChart(element, { }) ``` -This prevents a brush or free cursor from competing with the host's point -marker and tooltip. It does not remove keyboard accessibility from the -application-owned controls. +This prevents a brush from competing with the host's point marker and tooltip. +Use `pointer: false` plus the interaction controller when the application owns +the gesture but the chart should still own datum focus. `focusDisabled` does +not remove keyboard accessibility from application-owned controls. ## Brush selection diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index 358b1acf..fbf26f39 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -104,12 +104,9 @@ container CSS for palette variables, inherited color, and typography. ## Gradients and clipping -Gradients are opt-in SVG resources. Declare them on the chart and render with -the resource-aware SVG renderer: +Gradients are opt-in resources. Declare them on the chart: ```ts -import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' - const definition = defineChart({ marks, x, @@ -130,16 +127,16 @@ const definition = defineChart({ }) ``` -Use `url(#area-fill)` as the mark paint and pass -`renderSvg: renderChartSvgWithResources` to the host or adapter. `idPrefix` -scopes resource and clip IDs when several charts share a document. +Use `url(#area-fill)` as the mark paint. Default SVG hosts emit and scope the +resource; `idPrefix` keeps resource and clip IDs distinct when several charts +share a document. Set `clip: true` when marks should be clipped to the resolved plot rectangle. Clipping is a geometry policy, not a substitute for correct scale domains. -Canvas consumes the same declared gradients and group clips without the -resource-aware SVG serializer. A Canvas gradient needs measurable node bounds; -path-only geometry with no point bounds should use an explicit paint instead. +Canvas consumes the same declared gradients and group clips. A Canvas gradient +needs measurable node bounds; path-only geometry with no point bounds should +use an explicit paint instead.