@perspective-dev/viewer-charts 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +2 -2
  3. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  4. package/dist/esm/axis/bar-axis.d.ts +9 -1
  5. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  8. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  9. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  10. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  11. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  12. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  13. package/dist/esm/charts/series/series-build.d.ts +38 -2
  14. package/dist/esm/charts/series/series-render.d.ts +1 -4
  15. package/dist/esm/charts/series/series-type.d.ts +19 -17
  16. package/dist/esm/charts/series/series.d.ts +16 -0
  17. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  18. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  19. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  20. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  21. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  22. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  23. package/dist/esm/perspective-viewer-charts.js +2 -2
  24. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  25. package/dist/esm/plugin/plugin.d.ts +0 -1
  26. package/dist/esm/theme/palette.d.ts +0 -5
  27. package/dist/esm/transport/protocol.d.ts +2 -7
  28. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  29. package/package.json +45 -45
  30. package/src/ts/axis/bar-axis.ts +74 -45
  31. package/src/ts/axis/categorical-axis.ts +0 -2
  32. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  33. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  34. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  35. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  36. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  37. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  38. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  39. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  40. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  41. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  42. package/src/ts/charts/chart-base.ts +20 -6
  43. package/src/ts/charts/chart.ts +1 -1
  44. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  45. package/src/ts/charts/common/expand-domain.ts +40 -0
  46. package/src/ts/charts/common/tree-chart.ts +16 -0
  47. package/src/ts/charts/common/tree-chrome.ts +86 -1
  48. package/src/ts/charts/common/tree-interact.ts +209 -0
  49. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  50. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  51. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  52. package/src/ts/charts/series/series-build.ts +394 -21
  53. package/src/ts/charts/series/series-render.ts +159 -38
  54. package/src/ts/charts/series/series-type.ts +37 -17
  55. package/src/ts/charts/series/series.ts +63 -68
  56. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  57. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  58. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  59. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  60. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  61. package/src/ts/charts/treemap/treemap.ts +1 -16
  62. package/src/ts/interaction/host-sink-message.ts +33 -22
  63. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  64. package/src/ts/interaction/zoom-controller.ts +120 -83
  65. package/src/ts/interaction/zoom-router.ts +3 -126
  66. package/src/ts/map/tile-layer.ts +13 -13
  67. package/src/ts/plugin/plugin.ts +100 -184
  68. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  69. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  70. package/src/ts/theme/palette.ts +1 -4
  71. package/src/ts/transport/protocol.ts +3 -8
  72. package/src/ts/worker/dispatch.ts +0 -1
  73. package/src/ts/worker/renderer.worker.ts +10 -46
@@ -22,16 +22,16 @@ import {
22
22
  import { Theme } from "../../theme/theme";
23
23
  import { resolvePalette, type Vec3 } from "../../theme/palette";
24
24
  import { type GradientStop } from "../../theme/gradient";
25
- import { renderLegend, renderCategoricalLegend } from "../../axis/legend";
26
- import { PlotLayout } from "../../layout/plot-layout";
27
25
  import { buildFacetGrid } from "../../layout/facet-grid";
28
26
  import { leafColor, leafRGBA, luminance } from "../common/leaf-color";
29
27
  import treemapVert from "../../shaders/treemap.vert.glsl";
30
28
  import treemapFrag from "../../shaders/treemap.frag.glsl";
29
+ import { compileProgram } from "../../webgl/program-cache";
31
30
  import { withChromeCache } from "../common/chrome-cache";
32
31
  import { wrapLabel } from "../../axis/label-geometry";
33
32
  import {
34
33
  renderBreadcrumbs as renderTreeBreadcrumbs,
34
+ renderTreeColorLegend,
35
35
  renderTreeTooltip,
36
36
  } from "../common/tree-chrome";
37
37
 
@@ -111,16 +111,18 @@ export function renderTreemapFrame(
111
111
  }
112
112
 
113
113
  if (!chart._program) {
114
- chart._program = glManager.shaders.getOrCreate(
114
+ const compiled = compileProgram<
115
+ { program: WebGLProgram } & NonNullable<TreemapChart["_locations"]>
116
+ >(
117
+ glManager,
115
118
  "treemap",
116
119
  treemapVert,
117
120
  treemapFrag,
121
+ ["u_resolution"],
122
+ ["a_position", "a_color"],
118
123
  );
119
- chart._locations = {
120
- u_resolution: gl.getUniformLocation(chart._program, "u_resolution"),
121
- a_position: gl.getAttribLocation(chart._program, "a_position"),
122
- a_color: gl.getAttribLocation(chart._program, "a_color"),
123
- };
124
+ chart._program = compiled.program;
125
+ chart._locations = compiled;
124
126
  }
125
127
 
126
128
  const theme = chart._resolveTheme();
@@ -648,44 +650,15 @@ function drawStaticChrome(
648
650
  renderTreeBreadcrumbs(chart, ctx, cssWidth, fontFamily, textColor);
649
651
  }
650
652
 
651
- // Legend: numeric mode → gradient bar; series mode with 2+ unique
652
- // labels → categorical swatches. Empty mode (and single-label series)
653
- // suppress the legend entirely.
654
- if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) {
655
- const legendLayout = new PlotLayout(cssWidth, cssHeight, {
656
- hasXLabel: false,
657
- hasYLabel: false,
658
- hasLegend: true,
659
- });
660
- renderCategoricalLegend(
661
- canvas,
662
- legendLayout,
663
- chart._uniqueColorLabels,
664
- palette,
665
- theme,
666
- );
667
- } else if (
668
- chart._colorMode === "numeric" &&
669
- chart._colorMin < chart._colorMax
670
- ) {
671
- const legendLayout = new PlotLayout(cssWidth, cssHeight, {
672
- hasXLabel: false,
673
- hasYLabel: false,
674
- hasLegend: true,
675
- });
676
- renderLegend(
677
- canvas,
678
- legendLayout,
679
- {
680
- min: chart._colorMin,
681
- max: chart._colorMax,
682
- label: chart._colorName,
683
- },
684
- stops,
685
- theme,
686
- chart.getColumnFormatter(chart._colorName, "value"),
687
- );
688
- }
653
+ renderTreeColorLegend(
654
+ chart,
655
+ canvas,
656
+ palette,
657
+ stops,
658
+ theme,
659
+ cssWidth,
660
+ cssHeight,
661
+ );
689
662
 
690
663
  // Per-facet titles (rendered over the layout; painted in the static
691
664
  // chrome bitmap so they appear alongside leaf labels).
@@ -12,7 +12,7 @@
12
12
 
13
13
  import type { ColumnDataMap } from "../../data/view-reader";
14
14
  import type { WebGLContextManager } from "../../webgl/context-manager";
15
- import { TreeChartBase } from "../common/tree-chart";
15
+ import { TreeChartBase, firstNonMetadataColumn } from "../common/tree-chart";
16
16
  import { NULL_NODE } from "../common/node-store";
17
17
  import {
18
18
  type BreadcrumbRegion,
@@ -34,21 +34,6 @@ export interface TreemapLocations {
34
34
  a_color: number;
35
35
  }
36
36
 
37
- /**
38
- * Sentinel fallback for the Size slot when the user hasn't picked one:
39
- * use the first non-metadata column in the incoming view. Treemap
40
- * still needs *some* numeric-ish column to size rects.
41
- */
42
- function firstNonMetadataColumn(columns: ColumnDataMap): string {
43
- for (const k of columns.keys()) {
44
- if (!k.startsWith("__")) {
45
- return k;
46
- }
47
- }
48
-
49
- return "";
50
- }
51
-
52
37
  /**
53
38
  * Treemap chart. Shares tree storage + streaming-pipeline + color-mode
54
39
  * state with `TreeChartBase`; adds rectangular layout + WebGL quad
@@ -10,6 +10,13 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ import type {
14
+ DismissTooltipMsg,
15
+ PinTooltipMsg,
16
+ SetCursorMsg,
17
+ UserClickMsg,
18
+ UserSelectMsg,
19
+ } from "../transport/protocol";
13
20
  import type {
14
21
  CssBounds,
15
22
  HostSink,
@@ -18,29 +25,24 @@ import type {
18
25
  } from "./tooltip-controller";
19
26
 
20
27
  /**
21
- * Envelope shape sent by `MessageHostSink`. The transport translates
22
- * each one into a corresponding `WorkerMsg` (`pinTooltip` /
23
- * `dismissTooltip` / `setCursor` / `userClick` / `userSelect`).
28
+ * The subset of `WorkerMsg`s that flow chart → host through a
29
+ * `MessageHostSink`. Identical to the worker-side post payloads so the
30
+ * sink can ship them straight to `WorkerRenderer.post` with no
31
+ * intermediate translation.
24
32
  */
25
33
  export type HostSinkEnvelope =
26
- | {
27
- kind: "pin";
28
- payload: {
29
- lines: string[];
30
- pos: { px: number; py: number };
31
- bounds: CssBounds;
32
- };
33
- }
34
- | { kind: "dismiss" }
35
- | { kind: "setCursor"; cursor: string }
36
- | { kind: "userClick"; payload: UserClickPayload }
37
- | { kind: "userSelect"; payload: UserSelectPayload };
34
+ | PinTooltipMsg
35
+ | DismissTooltipMsg
36
+ | SetCursorMsg
37
+ | UserClickMsg
38
+ | UserSelectMsg;
38
39
 
39
40
  /**
40
41
  * `HostSink` that posts pin / dismiss / setCursor / user-event intents
41
- * over a `postMessage`-style channel. The host-side transport listens
42
- * for these envelopes and drives a `DomHostSink` for pin/dismiss and
43
- * dispatches `CustomEvent`s on the viewer for user events.
42
+ * over a `postMessage`-style channel as `WorkerMsg`s. The host-side
43
+ * transport listens for these and drives a `DomHostSink` for
44
+ * pin/dismiss and dispatches `CustomEvent`s on the viewer for user
45
+ * events.
44
46
  */
45
47
  export class MessageHostSink implements HostSink {
46
48
  private _send: (msg: HostSinkEnvelope) => void;
@@ -54,11 +56,11 @@ export class MessageHostSink implements HostSink {
54
56
  pos: { px: number; py: number },
55
57
  bounds: CssBounds,
56
58
  ): void {
57
- this._send({ kind: "pin", payload: { lines, pos, bounds } });
59
+ this._send({ kind: "pinTooltip", lines, pos, bounds });
58
60
  }
59
61
 
60
62
  dismiss(): void {
61
- this._send({ kind: "dismiss" });
63
+ this._send({ kind: "dismissTooltip" });
62
64
  }
63
65
 
64
66
  setCursor(cursor: string): void {
@@ -66,10 +68,19 @@ export class MessageHostSink implements HostSink {
66
68
  }
67
69
 
68
70
  emitUserClick(payload: UserClickPayload): void {
69
- this._send({ kind: "userClick", payload });
71
+ // `UserClickPayload` is structurally identical to
72
+ // `PerspectiveClickDetail`; the cast carries the `config`
73
+ // field's looser inner shape without affecting runtime data.
74
+ this._send({ kind: "userClick", detail: payload as any });
70
75
  }
71
76
 
72
77
  emitUserSelect(payload: UserSelectPayload): void {
73
- this._send({ kind: "userSelect", payload });
78
+ this._send({
79
+ kind: "userSelect",
80
+ selected: payload.selected,
81
+ row: payload.row,
82
+ column_names: payload.column_names,
83
+ insertConfig: payload.insertConfig as any,
84
+ });
74
85
  }
75
86
  }
@@ -13,16 +13,15 @@
13
13
  import type { InteractionEvent } from "../transport/protocol";
14
14
 
15
15
  /**
16
- * Worker-mode counterpart to {@link ZoomRouter}. Captures wheel /
17
- * pointer events on the GL canvas, normalizes coords to canvas-relative
18
- * CSS pixels, and emits semantic {@link InteractionEvent}s to the
19
- * Renderer over its transport. The Renderer (running in a Web Worker)
20
- * owns the `ZoomController`s and runs the actual hit-test + apply
21
- * logic — see `applyWheel` / `applyPan` in `zoom-router.ts`.
16
+ * Captures wheel / pointer events on the GL canvas, normalizes coords
17
+ * to canvas-relative CSS pixels, and emits semantic
18
+ * {@link InteractionEvent}s to the Renderer over its transport. The
19
+ * Renderer owns the `ZoomController`s and runs the actual hit-test +
20
+ * apply logic — see `applyWheel` / `applyPan` in `zoom-router.ts`.
22
21
  *
23
22
  * Pointer capture is set on `pointerdown` and released on `pointerup`
24
23
  * so drags continue to deliver `pointermove` events when the cursor
25
- * leaves the canvas, matching the in-process `ZoomRouter` behavior.
24
+ * leaves the canvas.
26
25
  */
27
26
  export class RawEventForwarder {
28
27
  private _element: HTMLElement | null = null;
@@ -47,11 +46,10 @@ export class RawEventForwarder {
47
46
  const mx = e.clientX - rect.left;
48
47
  const my = e.clientY - rect.top;
49
48
 
50
- // Match `ZoomRouter`: only consume the wheel event if the
51
- // cursor is over the canvas. `preventDefault` is fired
52
- // unconditionally so the page does not scroll while the
53
- // chart is hovered — the worker may still no-op if the
54
- // cursor is outside any facet's plot rect.
49
+ // `preventDefault` is fired unconditionally so the page
50
+ // does not scroll while the chart is hovered — the worker
51
+ // may still no-op if the cursor is outside any facet's
52
+ // plot rect.
55
53
  e.preventDefault();
56
54
  emit({ type: "wheel", mx, my, deltaY: e.deltaY });
57
55
  };
@@ -108,6 +108,15 @@ export class ZoomController {
108
108
  private _lockAxis: "x" | "y" | null = null;
109
109
  private _lockAspect = false;
110
110
 
111
+ // Opt-in "pin" flags. When set, `setBaseDomain` preserves the
112
+ // axis's *absolute* visible center across a base swap — the
113
+ // pre-existing rebase math. Default-cleared: data updates "follow"
114
+ // (preserve normalized translate, so the visible window tracks the
115
+ // data fractionally). Wire to a paused-frame review feature when
116
+ // one exists; no current caller flips these.
117
+ private _xPinned = false;
118
+ private _yPinned = false;
119
+
111
120
  private _element: HTMLElement | null = null;
112
121
  private _layout: PlotLayout | null = null;
113
122
  private _onUpdate: (() => void) | null = null;
@@ -121,9 +130,8 @@ export class ZoomController {
121
130
  private _onPointerMove: ((e: PointerEvent) => void) | null = null;
122
131
  private _onPointerUp: ((e: PointerEvent) => void) | null = null;
123
132
 
124
- // Per-controller mutators used by `ZoomRouter` to apply wheel/pan
125
- // events without going through `attach`. Live below under "Router
126
- // helpers" for the facet-aware zoom path.
133
+ // Per-controller mutators used by `applyWheel` / `applyPan`
134
+ // (zoom-router.ts) for the facet-aware zoom path.
127
135
  get lockedAxis(): "x" | "y" | null {
128
136
  return this._lockAxis;
129
137
  }
@@ -159,28 +167,27 @@ export class ZoomController {
159
167
  }
160
168
 
161
169
  /**
162
- * Update the base (full-data) domain that this controller's
163
- * normalized translate is interpreted against, while preserving
164
- * the *absolute* center of any user-applied pan.
170
+ * Update the base (full-data) domain. Each axis is handled
171
+ * independently:
165
172
  *
166
- * `_normTX` / `_normTY` are stored as fractions of the base
167
- * range, not absolute coordinates. A naive "swap base, keep
168
- * normTranslate" update reinterprets the same fraction against a
169
- * new range, so when an external `draw()` updates the extent
170
- * (via `processCartesianChunk` `setZoomBaseDomain`) the user's
171
- * pan-offset visible center jumps to a different absolute
172
- * position. With concurrent pan events feeding in offsets that
173
- * were computed against the old base, the jump can project the
174
- * visible center past the data entirely, leaving `_fullRender`
175
- * to draw zero glyphs onto a freshly-cleared canvas — a blank
176
- * bitmap reaches the host as a flicker.
173
+ * - If the axis is at default (no user pan or zoom on this axis),
174
+ * just swap the new base in no further math needed.
175
+ * - If the axis has been explicitly *pinned* (`pinAxis("x" | "y")`),
176
+ * preserve its *absolute* visible center across the swap:
177
+ * re-solve `_normT` so the data-space center is unchanged.
178
+ * This is paused-frame-review semantics the user has marked a
179
+ * region of interest and wants it to stay put even as new data
180
+ * flows in. No current caller pins, but the API is here so the
181
+ * default rule below doesn't bake the choice in.
182
+ * - Otherwise (the default "follow"): keep `_normT` as-is and
183
+ * just swap the new base. The visible window's *fractional*
184
+ * position is preserved, so sliding windows slide with the data
185
+ * and extending windows grow proportionally with the data.
177
186
  *
178
- * When the user is in default state (no pan, no zoom — fresh
179
- * controller, or just-reset), no rebase is needed; just swap the
180
- * base and let the chart auto-fit to the new data. Otherwise
181
- * recompute `_normTX` / `_normTY` so the visible center stays at
182
- * the same absolute (data-coordinate) position before and after
183
- * the swap.
187
+ * Per-axis handling matters because `_scaleX/_normTX` and
188
+ * `_scaleY/_normTY` are independent. A user who panned X to scroll
189
+ * through time should not force Y onto the rebase path — Y data
190
+ * updates should still flow through cleanly.
184
191
  */
185
192
  setBaseDomain(
186
193
  xMin: number,
@@ -188,32 +195,57 @@ export class ZoomController {
188
195
  yMin: number,
189
196
  yMax: number,
190
197
  ): void {
191
- if (this.isDefault()) {
198
+ const newRangeX = xMax - xMin;
199
+ if (this.isXDefault() || !this._xPinned) {
200
+ this._baseXMin = xMin;
201
+ this._baseXMax = xMax;
202
+ } else {
203
+ const oldRangeX = this._baseXMax - this._baseXMin;
204
+ const oldCx =
205
+ (this._baseXMin + this._baseXMax) / 2 +
206
+ this._normTX * oldRangeX;
192
207
  this._baseXMin = xMin;
193
208
  this._baseXMax = xMax;
209
+ this._normTX =
210
+ newRangeX > 0 ? (oldCx - (xMin + xMax) / 2) / newRangeX : 0;
211
+ }
212
+
213
+ const newRangeY = yMax - yMin;
214
+ if (this.isYDefault() || !this._yPinned) {
215
+ this._baseYMin = yMin;
216
+ this._baseYMax = yMax;
217
+ } else {
218
+ const oldRangeY = this._baseYMax - this._baseYMin;
219
+ const oldCy =
220
+ (this._baseYMin + this._baseYMax) / 2 +
221
+ this._normTY * oldRangeY;
194
222
  this._baseYMin = yMin;
195
223
  this._baseYMax = yMax;
196
- return;
224
+ this._normTY =
225
+ newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0;
197
226
  }
227
+ }
198
228
 
199
- const oldRangeX = this._baseXMax - this._baseXMin;
200
- const oldRangeY = this._baseYMax - this._baseYMin;
201
- const oldCx =
202
- (this._baseXMin + this._baseXMax) / 2 + this._normTX * oldRangeX;
203
- const oldCy =
204
- (this._baseYMin + this._baseYMax) / 2 + this._normTY * oldRangeY;
205
-
206
- this._baseXMin = xMin;
207
- this._baseXMax = xMax;
208
- this._baseYMin = yMin;
209
- this._baseYMax = yMax;
229
+ /**
230
+ * Mark an axis as "pinned" so subsequent `setBaseDomain` calls
231
+ * preserve its *absolute* visible center (paused-frame-review
232
+ * semantics). Default-cleared on construction; both axes follow
233
+ * data growth fractionally until explicitly pinned.
234
+ */
235
+ pinAxis(axis: "x" | "y"): void {
236
+ if (axis === "x") {
237
+ this._xPinned = true;
238
+ } else {
239
+ this._yPinned = true;
240
+ }
241
+ }
210
242
 
211
- const newRangeX = xMax - xMin;
212
- const newRangeY = yMax - yMin;
213
- this._normTX =
214
- newRangeX > 0 ? (oldCx - (xMin + xMax) / 2) / newRangeX : 0;
215
- this._normTY =
216
- newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0;
243
+ unpinAxis(axis: "x" | "y"): void {
244
+ if (axis === "x") {
245
+ this._xPinned = false;
246
+ } else {
247
+ this._yPinned = false;
248
+ }
217
249
  }
218
250
 
219
251
  /**
@@ -234,13 +266,16 @@ export class ZoomController {
234
266
  }
235
267
  }
236
268
 
269
+ isXDefault(): boolean {
270
+ return this._scaleX === 1 && this._normTX === 0;
271
+ }
272
+
273
+ isYDefault(): boolean {
274
+ return this._scaleY === 1 && this._normTY === 0;
275
+ }
276
+
237
277
  isDefault(): boolean {
238
- return (
239
- this._scaleX === 1 &&
240
- this._scaleY === 1 &&
241
- this._normTX === 0 &&
242
- this._normTY === 0
243
- );
278
+ return this.isXDefault() && this.isYDefault();
244
279
  }
245
280
 
246
281
  getVisibleDomain(): {
@@ -300,14 +335,15 @@ export class ZoomController {
300
335
  return;
301
336
  }
302
337
 
303
- // Data coordinate under cursor before zoom
304
- const domain = this.getVisibleDomain();
305
- const dataX =
306
- domain.xMin +
307
- ((mouseX - plot.x) / plot.width) * (domain.xMax - domain.xMin);
308
- const dataY =
309
- domain.yMax -
310
- ((mouseY - plot.y) / plot.height) * (domain.yMax - domain.yMin);
338
+ // Cursor position as a fraction of the plot rect — the
339
+ // anchor point that should stay visually fixed across the
340
+ // zoom mutation below. `fracY` is 0 at top, 1 at bottom
341
+ // (screen coords); Y data axis is inverted, hence the
342
+ // `0.5 - fracY` form below.
343
+ const fracX = (mouseX - plot.x) / plot.width;
344
+ const fracY = (mouseY - plot.y) / plot.height;
345
+ const oldScaleX = this._scaleX;
346
+ const oldScaleY = this._scaleY;
311
347
 
312
348
  // Zoom factor — skip the locked axis so its scale stays
313
349
  // pinned at 1.
@@ -326,25 +362,26 @@ export class ZoomController {
326
362
  );
327
363
  }
328
364
 
329
- // Adjust translate so the data point under cursor stays put
330
- const newDomain = this.getVisibleDomain();
331
- const newDataX =
332
- newDomain.xMin +
333
- ((mouseX - plot.x) / plot.width) *
334
- (newDomain.xMax - newDomain.xMin);
335
- const newDataY =
336
- newDomain.yMax -
337
- ((mouseY - plot.y) / plot.height) *
338
- (newDomain.yMax - newDomain.yMin);
339
-
340
- const bxRange = this._baseXMax - this._baseXMin;
341
- const byRange = this._baseYMax - this._baseYMin;
342
- if (this._lockAxis !== "x" && bxRange > 0) {
343
- this._normTX += (dataX - newDataX) / bxRange;
365
+ // Cursor-anchored zoom: keep the data point under the
366
+ // cursor visually fixed. Derivation, for X:
367
+ // dataX(scale) = mid + normTX*baseRange
368
+ // + (fracX - 0.5) * baseRange/scale
369
+ // After mutating scale (normTX unchanged), the cursor data
370
+ // coord shifts. Re-anchoring is `normTX +=
371
+ // (oldDataX - newDataX) / baseRange`; the `baseRange`
372
+ // terms cancel to:
373
+ // normTXDelta = (fracX - 0.5) * (1/oldScale - 1/newScale)
374
+ // Base-independent — a concurrent `setBaseDomain` mid-
375
+ // handler can't corrupt the anchor math. Y axis is screen-
376
+ // inverted, hence `(0.5 - fracY)`.
377
+ if (this._lockAxis !== "x" && oldScaleX !== this._scaleX) {
378
+ this._normTX +=
379
+ (fracX - 0.5) * (1 / oldScaleX - 1 / this._scaleX);
344
380
  }
345
381
 
346
- if (this._lockAxis !== "y" && byRange > 0) {
347
- this._normTY += (dataY - newDataY) / byRange;
382
+ if (this._lockAxis !== "y" && oldScaleY !== this._scaleY) {
383
+ this._normTY +=
384
+ (0.5 - fracY) * (1 / oldScaleY - 1 / this._scaleY);
348
385
  }
349
386
 
350
387
  this._onUpdate!();
@@ -379,19 +416,19 @@ export class ZoomController {
379
416
  this._lastPointerX = e.clientX;
380
417
  this._lastPointerY = e.clientY;
381
418
 
382
- const domain = this.getVisibleDomain();
419
+ // Pan as a fraction is `dx / (plotWidth * scaleX)` —
420
+ // independent of the current base range. The chained form
421
+ // (`pixels → data delta → fraction-of-base`) cancels the
422
+ // `baseRange` terms algebraically; computing the cancelled
423
+ // form directly means a concurrent `setBaseDomain` swap
424
+ // mid-gesture cannot corrupt the pan math.
383
425
  const plot = this._layout!.plotRect;
384
- const dataPerPixelX = (domain.xMax - domain.xMin) / plot.width;
385
- const dataPerPixelY = (domain.yMax - domain.yMin) / plot.height;
386
-
387
- const bxRange = this._baseXMax - this._baseXMin;
388
- const byRange = this._baseYMax - this._baseYMin;
389
- if (this._lockAxis !== "x" && bxRange > 0) {
390
- this._normTX -= (dx * dataPerPixelX) / bxRange;
426
+ if (this._lockAxis !== "x" && plot.width > 0) {
427
+ this._normTX -= dx / (plot.width * this._scaleX);
391
428
  }
392
429
 
393
- if (this._lockAxis !== "y" && byRange > 0) {
394
- this._normTY += (dy * dataPerPixelY) / byRange;
430
+ if (this._lockAxis !== "y" && plot.height > 0) {
431
+ this._normTY += dy / (plot.height * this._scaleY);
395
432
  }
396
433
 
397
434
  this._onUpdate!();