@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.
- package/LICENSE.md +193 -0
- package/dist/cdn/perspective-viewer-charts.js +2 -2
- package/dist/cdn/perspective-viewer-charts.js.map +3 -3
- package/dist/esm/axis/bar-axis.d.ts +9 -1
- package/dist/esm/axis/categorical-axis.d.ts +0 -2
- package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
- package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
- package/dist/esm/charts/common/expand-domain.d.ts +20 -0
- package/dist/esm/charts/common/tree-chart.d.ts +7 -0
- package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
- package/dist/esm/charts/common/tree-interact.d.ts +46 -0
- package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
- package/dist/esm/charts/series/series-build.d.ts +38 -2
- package/dist/esm/charts/series/series-render.d.ts +1 -4
- package/dist/esm/charts/series/series-type.d.ts +19 -17
- package/dist/esm/charts/series/series.d.ts +16 -0
- package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
- package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
- package/dist/esm/interaction/host-sink-message.d.ts +10 -28
- package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
- package/dist/esm/interaction/zoom-controller.d.ts +31 -20
- package/dist/esm/interaction/zoom-router.d.ts +3 -26
- package/dist/esm/perspective-viewer-charts.js +2 -2
- package/dist/esm/perspective-viewer-charts.js.map +3 -3
- package/dist/esm/plugin/plugin.d.ts +0 -1
- package/dist/esm/theme/palette.d.ts +0 -5
- package/dist/esm/transport/protocol.d.ts +2 -7
- package/dist/esm/worker/renderer.worker.d.ts +2 -4
- package/package.json +45 -45
- package/src/ts/axis/bar-axis.ts +74 -45
- package/src/ts/axis/categorical-axis.ts +0 -2
- package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
- package/src/ts/charts/candlestick/candlestick.ts +10 -29
- package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
- package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
- package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
- package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
- package/src/ts/charts/cartesian/cartesian.ts +43 -4
- package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
- package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
- package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
- package/src/ts/charts/chart-base.ts +20 -6
- package/src/ts/charts/chart.ts +1 -1
- package/src/ts/charts/common/category-axis-resolver.ts +135 -1
- package/src/ts/charts/common/expand-domain.ts +40 -0
- package/src/ts/charts/common/tree-chart.ts +16 -0
- package/src/ts/charts/common/tree-chrome.ts +86 -1
- package/src/ts/charts/common/tree-interact.ts +209 -0
- package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
- package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
- package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
- package/src/ts/charts/series/series-build.ts +394 -21
- package/src/ts/charts/series/series-render.ts +159 -38
- package/src/ts/charts/series/series-type.ts +37 -17
- package/src/ts/charts/series/series.ts +63 -68
- package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
- package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
- package/src/ts/charts/sunburst/sunburst.ts +1 -15
- package/src/ts/charts/treemap/treemap-interact.ts +22 -189
- package/src/ts/charts/treemap/treemap-render.ts +19 -46
- package/src/ts/charts/treemap/treemap.ts +1 -16
- package/src/ts/interaction/host-sink-message.ts +33 -22
- package/src/ts/interaction/raw-event-forwarder.ts +10 -12
- package/src/ts/interaction/zoom-controller.ts +120 -83
- package/src/ts/interaction/zoom-router.ts +3 -126
- package/src/ts/map/tile-layer.ts +13 -13
- package/src/ts/plugin/plugin.ts +100 -184
- package/src/ts/shaders/line-uniform.frag.glsl +2 -1
- package/src/ts/shaders/line-uniform.vert.glsl +19 -0
- package/src/ts/theme/palette.ts +1 -4
- package/src/ts/transport/protocol.ts +3 -8
- package/src/ts/worker/dispatch.ts +0 -1
- 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
|
-
|
|
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.
|
|
120
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
42
|
-
* for these
|
|
43
|
-
* dispatches `CustomEvent`s on the viewer for user
|
|
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: "
|
|
59
|
+
this._send({ kind: "pinTooltip", lines, pos, bounds });
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
dismiss(): void {
|
|
61
|
-
this._send({ kind: "
|
|
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
|
-
|
|
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({
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* Renderer
|
|
20
|
-
*
|
|
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
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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 `
|
|
125
|
-
//
|
|
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
|
|
163
|
-
*
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
this._normTY =
|
|
225
|
+
newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0;
|
|
197
226
|
}
|
|
227
|
+
}
|
|
198
228
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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" &&
|
|
347
|
-
this._normTY +=
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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" &&
|
|
394
|
-
this._normTY +=
|
|
430
|
+
if (this._lockAxis !== "y" && plot.height > 0) {
|
|
431
|
+
this._normTY += dy / (plot.height * this._scaleY);
|
|
395
432
|
}
|
|
396
433
|
|
|
397
434
|
this._onUpdate!();
|