@photonviz/core 0.1.1 → 0.2.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/README.md +70 -0
- package/dist/index.d.ts +198 -18
- package/dist/index.js +721 -73
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -82,10 +82,10 @@ var Axis = class {
|
|
|
82
82
|
}
|
|
83
83
|
ticks = major;
|
|
84
84
|
}
|
|
85
|
-
const
|
|
85
|
+
const fmt2 = this.config.format ?? ((v) => scale.formatTick(v));
|
|
86
86
|
return ticks.filter((t) => t.value >= min && t.value <= max).map((t) => ({
|
|
87
87
|
value: t.value,
|
|
88
|
-
label: t.minor ? "" : t.label ??
|
|
88
|
+
label: t.minor ? "" : t.label ?? fmt2(t.value),
|
|
89
89
|
minor: t.minor ?? false,
|
|
90
90
|
grid: t.grid ?? !t.minor
|
|
91
91
|
}));
|
|
@@ -1954,6 +1954,25 @@ var GpuDecimator = class {
|
|
|
1954
1954
|
}
|
|
1955
1955
|
};
|
|
1956
1956
|
|
|
1957
|
+
// src/layers/pick.ts
|
|
1958
|
+
function pickNearest(xs, ys, count, mode, cursorPx, cursorPy, project, gatePx = Infinity) {
|
|
1959
|
+
if (count === 0) return null;
|
|
1960
|
+
let best = -1;
|
|
1961
|
+
let bestDist = Infinity;
|
|
1962
|
+
for (let i = 0; i < count; i++) {
|
|
1963
|
+
const [px, py] = project(xs[i], ys[i]);
|
|
1964
|
+
const dx = px - cursorPx;
|
|
1965
|
+
const dy = py - cursorPy;
|
|
1966
|
+
const d = mode === "x" ? Math.abs(dx) : mode === "y" ? Math.abs(dy) : Math.hypot(dx, dy);
|
|
1967
|
+
if (d < bestDist) {
|
|
1968
|
+
bestDist = d;
|
|
1969
|
+
best = i;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
if (best < 0 || bestDist > gatePx) return null;
|
|
1973
|
+
return { x: xs[best], y: ys[best], index: best };
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1957
1976
|
// src/layers/line.ts
|
|
1958
1977
|
var GPU_DECIMATE_MIN = 2e5;
|
|
1959
1978
|
var VERT7 = (
|
|
@@ -2292,17 +2311,8 @@ var LineLayer = class {
|
|
|
2292
2311
|
if (this.count === 0) return null;
|
|
2293
2312
|
return { x: this.xBounds, y: this.yBounds };
|
|
2294
2313
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
let best = 0, bestDist = Infinity;
|
|
2298
|
-
for (let i = 0; i < this.count; i++) {
|
|
2299
|
-
const d = Math.abs(this.xs[i] - x);
|
|
2300
|
-
if (d < bestDist) {
|
|
2301
|
-
bestDist = d;
|
|
2302
|
-
best = i;
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
return { x: this.xs[best], y: this.ys[best], index: best };
|
|
2314
|
+
pick(mode, cursorPx, cursorPy, project) {
|
|
2315
|
+
return pickNearest(this.xs, this.ys, this.count, mode, cursorPx, cursorPy, project);
|
|
2306
2316
|
}
|
|
2307
2317
|
/** Replace the series data and re-upload the GPU buffer (for streaming). */
|
|
2308
2318
|
setData(x, y) {
|
|
@@ -2719,6 +2729,7 @@ var ScatterLayer = class {
|
|
|
2719
2729
|
this.name = opts.name ?? this.id;
|
|
2720
2730
|
this.yAxis = opts.yAxis ?? "y";
|
|
2721
2731
|
this.useVertexColor = opts.colorBy != null;
|
|
2732
|
+
this.labels = opts.labels;
|
|
2722
2733
|
const n = Math.min(opts.x.length, opts.y.length);
|
|
2723
2734
|
this.count = n;
|
|
2724
2735
|
this.xs = new Float64Array(n);
|
|
@@ -2796,17 +2807,14 @@ var ScatterLayer = class {
|
|
|
2796
2807
|
if (this.count === 0) return null;
|
|
2797
2808
|
return { x: this.xBounds, y: this.yBounds };
|
|
2798
2809
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
}
|
|
2808
|
-
}
|
|
2809
|
-
return { x: this.xs[best], y: this.ys[best], index: best };
|
|
2810
|
+
pick(mode, cursorPx, cursorPy, project) {
|
|
2811
|
+
const gatePx = this.size / 2 + 4;
|
|
2812
|
+
return pickNearest(this.xs, this.ys, this.count, mode, cursorPx, cursorPy, project, gatePx);
|
|
2813
|
+
}
|
|
2814
|
+
/** User-supplied text for a point, shown when it is clicked. */
|
|
2815
|
+
infoAt(index) {
|
|
2816
|
+
const label = this.labels?.[index];
|
|
2817
|
+
return label != null ? [label] : null;
|
|
2810
2818
|
}
|
|
2811
2819
|
/** Replace point positions and re-upload (for streaming). Keeps uniform color. */
|
|
2812
2820
|
setData(x, y) {
|
|
@@ -3010,17 +3018,8 @@ var StemLayer = class {
|
|
|
3010
3018
|
if (this.count === 0) return null;
|
|
3011
3019
|
return { x: this.xBounds, y: this.yBounds };
|
|
3012
3020
|
}
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
let best = 0, bestDist = Infinity;
|
|
3016
|
-
for (let i = 0; i < this.count; i++) {
|
|
3017
|
-
const d = Math.abs(this.xs[i] - x);
|
|
3018
|
-
if (d < bestDist) {
|
|
3019
|
-
bestDist = d;
|
|
3020
|
-
best = i;
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
return { x: this.xs[best], y: this.ys[best], index: best };
|
|
3021
|
+
pick(mode, cursorPx, cursorPy, project) {
|
|
3022
|
+
return pickNearest(this.xs, this.ys, this.count, mode, cursorPx, cursorPy, project);
|
|
3024
3023
|
}
|
|
3025
3024
|
draw(state) {
|
|
3026
3025
|
if (this.count === 0) return;
|
|
@@ -3182,6 +3181,27 @@ function drawCrosshair(ctx, region, px, theme) {
|
|
|
3182
3181
|
ctx.stroke();
|
|
3183
3182
|
ctx.restore();
|
|
3184
3183
|
}
|
|
3184
|
+
function drawCrosshairXY(ctx, region, px, py, theme) {
|
|
3185
|
+
const left = region.left;
|
|
3186
|
+
const right = region.left + region.width;
|
|
3187
|
+
const top = region.top;
|
|
3188
|
+
const bottom = region.top + region.height;
|
|
3189
|
+
if (px < left || px > right || py < top || py > bottom) return;
|
|
3190
|
+
ctx.save();
|
|
3191
|
+
ctx.strokeStyle = theme.text;
|
|
3192
|
+
ctx.globalAlpha = 0.4;
|
|
3193
|
+
ctx.setLineDash([3, 3]);
|
|
3194
|
+
ctx.lineWidth = 1;
|
|
3195
|
+
const x = Math.round(px) + 0.5;
|
|
3196
|
+
const y = Math.round(py) + 0.5;
|
|
3197
|
+
ctx.beginPath();
|
|
3198
|
+
ctx.moveTo(x, top);
|
|
3199
|
+
ctx.lineTo(x, bottom);
|
|
3200
|
+
ctx.moveTo(left, y);
|
|
3201
|
+
ctx.lineTo(right, y);
|
|
3202
|
+
ctx.stroke();
|
|
3203
|
+
ctx.restore();
|
|
3204
|
+
}
|
|
3185
3205
|
function drawMarker(ctx, px, py, color) {
|
|
3186
3206
|
ctx.save();
|
|
3187
3207
|
ctx.fillStyle = color;
|
|
@@ -3441,7 +3461,7 @@ function createToolbar(container, host, dark) {
|
|
|
3441
3461
|
var DEFAULT_MARGIN = { top: 16, right: 16, bottom: 40, left: 56 };
|
|
3442
3462
|
var Y_AXIS_GAP = 52;
|
|
3443
3463
|
function isPickable(layer) {
|
|
3444
|
-
return typeof layer.
|
|
3464
|
+
return typeof layer.pick === "function";
|
|
3445
3465
|
}
|
|
3446
3466
|
function padDomain(min, max, log, frac) {
|
|
3447
3467
|
if (log) {
|
|
@@ -3453,6 +3473,19 @@ function padDomain(min, max, log, frac) {
|
|
|
3453
3473
|
const pad = (max - min) * frac || 1;
|
|
3454
3474
|
return [min - pad, max + pad];
|
|
3455
3475
|
}
|
|
3476
|
+
function clampAxis(domain, bounds) {
|
|
3477
|
+
const [lo, hi] = domain;
|
|
3478
|
+
const span = hi - lo;
|
|
3479
|
+
const [dlo, dhi] = bounds;
|
|
3480
|
+
if (span >= dhi - dlo) {
|
|
3481
|
+
const loMin = dhi - span;
|
|
3482
|
+
const clampedLo2 = Math.min(Math.max(lo, loMin), dlo);
|
|
3483
|
+
return [clampedLo2, clampedLo2 + span];
|
|
3484
|
+
}
|
|
3485
|
+
let clampedLo = Math.max(lo, dlo);
|
|
3486
|
+
if (clampedLo + span > dhi) clampedLo = dhi - span;
|
|
3487
|
+
return [clampedLo, clampedLo + span];
|
|
3488
|
+
}
|
|
3456
3489
|
var Plot = class {
|
|
3457
3490
|
constructor(container, options = {}) {
|
|
3458
3491
|
/** Named y axes, insertion-ordered. `"y"` is always the primary. */
|
|
@@ -3463,6 +3496,10 @@ var Plot = class {
|
|
|
3463
3496
|
this.modeChangeCbs = [];
|
|
3464
3497
|
this.toolbarHandle = null;
|
|
3465
3498
|
this.hoverPx = null;
|
|
3499
|
+
/** Cursor position while the pointer is pressed, when `crosshair`. */
|
|
3500
|
+
this.pressPx = null;
|
|
3501
|
+
/** A point clicked to pin its details, until another click clears it. */
|
|
3502
|
+
this.selected = null;
|
|
3466
3503
|
this.container = container;
|
|
3467
3504
|
if (getComputedStyle(container).position === "static") {
|
|
3468
3505
|
container.style.position = "relative";
|
|
@@ -3493,8 +3530,14 @@ var Plot = class {
|
|
|
3493
3530
|
this.isDark = options.theme === "dark";
|
|
3494
3531
|
this.theme = options.theme === "dark" ? darkTheme : options.theme === "light" || options.theme == null ? lightTheme : options.theme;
|
|
3495
3532
|
this.baseMargin = { ...DEFAULT_MARGIN, ...options.margin };
|
|
3496
|
-
this.mode = options.mode ?? "
|
|
3533
|
+
this.mode = options.mode ?? "pan";
|
|
3497
3534
|
this.hoverEnabled = options.hover !== false;
|
|
3535
|
+
this.pickMode = options.pick ?? "x";
|
|
3536
|
+
this.pointInfo = options.pointInfo ?? "click";
|
|
3537
|
+
this.crosshair = options.crosshair ?? true;
|
|
3538
|
+
this.equalAspect = options.equalAspect ?? false;
|
|
3539
|
+
this.boundedPan = options.boundedPan ?? false;
|
|
3540
|
+
this.hoverReadout = options.hoverReadout;
|
|
3498
3541
|
this.selectionDiv = document.createElement("div");
|
|
3499
3542
|
Object.assign(this.selectionDiv.style, {
|
|
3500
3543
|
position: "absolute",
|
|
@@ -3523,11 +3566,28 @@ var Plot = class {
|
|
|
3523
3566
|
boxShadow: "0 4px 12px rgba(0,0,0,0.18)"
|
|
3524
3567
|
});
|
|
3525
3568
|
container.appendChild(this.tooltip);
|
|
3569
|
+
this.infoBox = document.createElement("div");
|
|
3570
|
+
Object.assign(this.infoBox.style, {
|
|
3571
|
+
position: "absolute",
|
|
3572
|
+
display: "none",
|
|
3573
|
+
zIndex: "6",
|
|
3574
|
+
pointerEvents: "none",
|
|
3575
|
+
padding: "6px 8px",
|
|
3576
|
+
borderRadius: "6px",
|
|
3577
|
+
font: "12px system-ui, -apple-system, sans-serif",
|
|
3578
|
+
lineHeight: "1.4",
|
|
3579
|
+
whiteSpace: "nowrap",
|
|
3580
|
+
background: this.isDark ? "rgba(15,23,42,0.96)" : "rgba(255,255,255,0.98)",
|
|
3581
|
+
color: this.isDark ? "#e2e8f0" : "#1e293b",
|
|
3582
|
+
border: `1px solid ${this.isDark ? "rgba(148,163,184,0.4)" : "rgba(100,116,139,0.4)"}`,
|
|
3583
|
+
boxShadow: "0 6px 18px rgba(0,0,0,0.25)"
|
|
3584
|
+
});
|
|
3585
|
+
container.appendChild(this.infoBox);
|
|
3526
3586
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
3527
3587
|
this.resizeObserver.observe(container);
|
|
3528
3588
|
this.resize();
|
|
3529
3589
|
if (options.interactive !== false) this.attachInteraction();
|
|
3530
|
-
if (options.
|
|
3590
|
+
if (options.showToolbar !== false) {
|
|
3531
3591
|
this.toolbarHandle = createToolbar(
|
|
3532
3592
|
container,
|
|
3533
3593
|
{
|
|
@@ -3605,6 +3665,34 @@ var Plot = class {
|
|
|
3605
3665
|
this.requestRender();
|
|
3606
3666
|
return layer;
|
|
3607
3667
|
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Register a layer built outside core (e.g. `@photonviz/map`). Use with
|
|
3670
|
+
* {@link context} to construct the layer against this plot's WebGL2 context.
|
|
3671
|
+
*/
|
|
3672
|
+
add(layer) {
|
|
3673
|
+
return this.register(layer);
|
|
3674
|
+
}
|
|
3675
|
+
/** The shared WebGL2 context, for constructing custom layers. */
|
|
3676
|
+
get context() {
|
|
3677
|
+
return this.gl;
|
|
3678
|
+
}
|
|
3679
|
+
/**
|
|
3680
|
+
* Convert a client (screen) coordinate to data space — the shared x and the
|
|
3681
|
+
* primary y. Returns null if the point is outside the plot region. Useful for
|
|
3682
|
+
* custom hit-testing (e.g. map feature picking on click).
|
|
3683
|
+
*/
|
|
3684
|
+
dataAt(clientX, clientY) {
|
|
3685
|
+
const rect = this.axisCanvas.getBoundingClientRect();
|
|
3686
|
+
const region = plotRegion(this.layout());
|
|
3687
|
+
const px = clientX - rect.left;
|
|
3688
|
+
const py = clientY - rect.top;
|
|
3689
|
+
if (px < region.left || px > region.left + region.width || py < region.top || py > region.top + region.height) {
|
|
3690
|
+
return null;
|
|
3691
|
+
}
|
|
3692
|
+
const nx = (px - region.left) / region.width;
|
|
3693
|
+
const ny = 1 - (py - region.top) / region.height;
|
|
3694
|
+
return { x: this.scaleX.invert(nx), y: this.primaryY().scale.invert(ny) };
|
|
3695
|
+
}
|
|
3608
3696
|
addLine(opts) {
|
|
3609
3697
|
return this.register(new LineLayer(this.gl, opts));
|
|
3610
3698
|
}
|
|
@@ -3786,6 +3874,7 @@ var Plot = class {
|
|
|
3786
3874
|
this.toolbarHandle?.destroy();
|
|
3787
3875
|
this.selectionDiv.remove();
|
|
3788
3876
|
this.tooltip.remove();
|
|
3877
|
+
this.infoBox.remove();
|
|
3789
3878
|
for (const l of this.layers) l.dispose();
|
|
3790
3879
|
this.container.removeChild(this.gridCanvas);
|
|
3791
3880
|
this.container.removeChild(this.dataCanvas);
|
|
@@ -3820,6 +3909,8 @@ var Plot = class {
|
|
|
3820
3909
|
render() {
|
|
3821
3910
|
const layout = this.layout();
|
|
3822
3911
|
const region = plotRegion(layout);
|
|
3912
|
+
if (this.equalAspect) this.applyAspect(region);
|
|
3913
|
+
if (this.boundedPan) this.clampView();
|
|
3823
3914
|
const primary = this.primaryY();
|
|
3824
3915
|
const ticksX = this.axisX.resolve(this.scaleX);
|
|
3825
3916
|
const ticksYPrimary = primary.axis.resolve(primary.scale);
|
|
@@ -3875,8 +3966,16 @@ var Plot = class {
|
|
|
3875
3966
|
titleX: pos.titleX
|
|
3876
3967
|
});
|
|
3877
3968
|
}
|
|
3878
|
-
if (this.
|
|
3879
|
-
|
|
3969
|
+
if (this.crosshair && this.pressPx) {
|
|
3970
|
+
drawCrosshairXY(this.axisCtx, region, this.pressPx.x, this.pressPx.y, this.theme);
|
|
3971
|
+
}
|
|
3972
|
+
if (this.hoverEnabled && this.hoverPx) {
|
|
3973
|
+
this.renderHover(region);
|
|
3974
|
+
} else {
|
|
3975
|
+
this.tooltip.style.display = "none";
|
|
3976
|
+
if (this.pointInfo === "hover") this.selected = null;
|
|
3977
|
+
}
|
|
3978
|
+
this.updateInfoBox(region);
|
|
3880
3979
|
}
|
|
3881
3980
|
renderHover(region) {
|
|
3882
3981
|
const cursor = this.hoverPx;
|
|
@@ -3886,33 +3985,49 @@ var Plot = class {
|
|
|
3886
3985
|
}
|
|
3887
3986
|
const nx = (cursor.x - region.left) / region.width;
|
|
3888
3987
|
const dataX = this.scaleX.invert(nx);
|
|
3889
|
-
|
|
3988
|
+
const ny = 1 - (cursor.y - region.top) / region.height;
|
|
3989
|
+
const dataY = this.primaryY().scale.invert(ny);
|
|
3990
|
+
if (this.crosshair) {
|
|
3991
|
+
drawCrosshairXY(this.axisCtx, region, cursor.x, cursor.y, this.theme);
|
|
3992
|
+
} else {
|
|
3993
|
+
drawCrosshair(this.axisCtx, region, cursor.x, this.theme);
|
|
3994
|
+
}
|
|
3890
3995
|
const rows = [];
|
|
3891
3996
|
for (const layer of this.layers) {
|
|
3892
3997
|
if (!isPickable(layer)) continue;
|
|
3893
|
-
const p = layer.nearestByX(dataX);
|
|
3894
|
-
if (!p) continue;
|
|
3895
3998
|
const ya = this.yAxes.get(layer.yAxis);
|
|
3896
|
-
const
|
|
3897
|
-
|
|
3999
|
+
const project = (x, y) => [
|
|
4000
|
+
pxX(region, this.scaleX.norm(x)),
|
|
4001
|
+
pxY(region, ya.scale.norm(y))
|
|
4002
|
+
];
|
|
4003
|
+
const p = layer.pick(this.pickMode, cursor.x, cursor.y, project);
|
|
4004
|
+
if (!p) continue;
|
|
4005
|
+
const [px, py] = project(p.x, p.y);
|
|
3898
4006
|
drawMarker(this.axisCtx, px, py, layer.colorCss);
|
|
3899
4007
|
rows.push({ layer, x: p.x, y: p.y });
|
|
3900
4008
|
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
return;
|
|
3904
|
-
}
|
|
3905
|
-
this.updateTooltip(rows, cursor, dataX);
|
|
4009
|
+
this.updateTooltip(rows, cursor, dataX, dataY);
|
|
4010
|
+
if (this.pointInfo === "hover") this.selected = this.pickPoint(cursor.x, cursor.y);
|
|
3906
4011
|
}
|
|
3907
|
-
updateTooltip(rows, cursor, dataX) {
|
|
4012
|
+
updateTooltip(rows, cursor, dataX, dataY) {
|
|
3908
4013
|
const tip = this.tooltip;
|
|
3909
4014
|
tip.replaceChildren();
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
4015
|
+
if (this.hoverReadout) {
|
|
4016
|
+
for (const line of this.hoverReadout(dataX, dataY)) {
|
|
4017
|
+
const el = document.createElement("div");
|
|
4018
|
+
el.style.opacity = "0.7";
|
|
4019
|
+
el.style.marginBottom = "3px";
|
|
4020
|
+
el.textContent = `${line.label} ${line.value}`;
|
|
4021
|
+
tip.appendChild(el);
|
|
4022
|
+
}
|
|
4023
|
+
} else {
|
|
4024
|
+
const xfmt = this.axisX.config.format ?? defaultFormat;
|
|
4025
|
+
const header = document.createElement("div");
|
|
4026
|
+
header.style.opacity = "0.7";
|
|
4027
|
+
header.style.marginBottom = "3px";
|
|
4028
|
+
header.textContent = `x = ${xfmt(dataX)}`;
|
|
4029
|
+
tip.appendChild(header);
|
|
4030
|
+
}
|
|
3916
4031
|
for (const r of rows) {
|
|
3917
4032
|
const yfmt = this.yAxes.get(r.layer.yAxis).axis.config.format ?? defaultFormat;
|
|
3918
4033
|
const row = document.createElement("div");
|
|
@@ -4017,12 +4132,16 @@ var Plot = class {
|
|
|
4017
4132
|
let lastY = 0;
|
|
4018
4133
|
let startX = 0;
|
|
4019
4134
|
let startY = 0;
|
|
4135
|
+
let downX = 0;
|
|
4136
|
+
let downY = 0;
|
|
4020
4137
|
el.addEventListener("pointerdown", (e) => {
|
|
4021
4138
|
el.setPointerCapture(e.pointerId);
|
|
4022
4139
|
this.hoverPx = null;
|
|
4023
4140
|
const rect = el.getBoundingClientRect();
|
|
4024
4141
|
const px = e.clientX - rect.left;
|
|
4025
4142
|
const py = e.clientY - rect.top;
|
|
4143
|
+
downX = px;
|
|
4144
|
+
downY = py;
|
|
4026
4145
|
const zone = this.zoneAt(px, py);
|
|
4027
4146
|
if (zone.type === "x") {
|
|
4028
4147
|
axisDrag = "x";
|
|
@@ -4035,15 +4154,18 @@ var Plot = class {
|
|
|
4035
4154
|
lastX = e.clientX;
|
|
4036
4155
|
lastY = e.clientY;
|
|
4037
4156
|
el.style.cursor = "grabbing";
|
|
4157
|
+
if (this.crosshair) this.setPress(px, py);
|
|
4038
4158
|
} else {
|
|
4039
4159
|
selecting = true;
|
|
4040
4160
|
startX = px;
|
|
4041
4161
|
startY = py;
|
|
4162
|
+
if (this.crosshair) this.setPress(px, py);
|
|
4042
4163
|
}
|
|
4043
4164
|
});
|
|
4044
4165
|
el.addEventListener("pointermove", (e) => {
|
|
4045
4166
|
const rect = el.getBoundingClientRect();
|
|
4046
4167
|
const region = plotRegion(this.layout());
|
|
4168
|
+
if (this.pressPx) this.setPress(e.clientX - rect.left, e.clientY - rect.top);
|
|
4047
4169
|
if (axisDrag === "x") {
|
|
4048
4170
|
this.panX(e.clientX - lastX, region);
|
|
4049
4171
|
lastX = e.clientX;
|
|
@@ -4079,6 +4201,14 @@ var Plot = class {
|
|
|
4079
4201
|
el.addEventListener("pointerleave", () => this.setHover(null));
|
|
4080
4202
|
const end = (e) => {
|
|
4081
4203
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
4204
|
+
if (this.pressPx) {
|
|
4205
|
+
this.pressPx = null;
|
|
4206
|
+
this.requestRender();
|
|
4207
|
+
}
|
|
4208
|
+
const rect = el.getBoundingClientRect();
|
|
4209
|
+
const upPx = e.clientX - rect.left;
|
|
4210
|
+
const upPy = e.clientY - rect.top;
|
|
4211
|
+
const isClick = e.type === "pointerup" && !axisDrag && Math.hypot(upPx - downX, upPy - downY) < 4;
|
|
4082
4212
|
if (axisDrag) {
|
|
4083
4213
|
axisDrag = null;
|
|
4084
4214
|
} else if (panning) {
|
|
@@ -4086,10 +4216,10 @@ var Plot = class {
|
|
|
4086
4216
|
this.updateCursor();
|
|
4087
4217
|
} else if (selecting) {
|
|
4088
4218
|
selecting = false;
|
|
4089
|
-
|
|
4090
|
-
this.applySelection(startX, startY, e.clientX - rect.left, e.clientY - rect.top);
|
|
4219
|
+
if (!isClick) this.applySelection(startX, startY, upPx, upPy);
|
|
4091
4220
|
this.selectionDiv.style.display = "none";
|
|
4092
4221
|
}
|
|
4222
|
+
if (isClick) this.handleClick(upPx, upPy);
|
|
4093
4223
|
};
|
|
4094
4224
|
el.addEventListener("pointerup", end);
|
|
4095
4225
|
el.addEventListener("pointercancel", end);
|
|
@@ -4100,6 +4230,160 @@ var Plot = class {
|
|
|
4100
4230
|
this.hoverPx = px;
|
|
4101
4231
|
this.requestRender();
|
|
4102
4232
|
}
|
|
4233
|
+
/** Update the press-crosshair position and redraw. */
|
|
4234
|
+
setPress(x, y) {
|
|
4235
|
+
this.pressPx = { x, y };
|
|
4236
|
+
this.requestRender();
|
|
4237
|
+
}
|
|
4238
|
+
/** Nearest point (2D, within a small radius) under the cursor, or null. */
|
|
4239
|
+
pickPoint(cursorPx, cursorPy) {
|
|
4240
|
+
const region = plotRegion(this.layout());
|
|
4241
|
+
let hit = null;
|
|
4242
|
+
let hitDist = Infinity;
|
|
4243
|
+
for (const layer of this.layers) {
|
|
4244
|
+
if (!isPickable(layer)) continue;
|
|
4245
|
+
const ya = this.yAxes.get(layer.yAxis);
|
|
4246
|
+
const project = (x, y) => [
|
|
4247
|
+
pxX(region, this.scaleX.norm(x)),
|
|
4248
|
+
pxY(region, ya.scale.norm(y))
|
|
4249
|
+
];
|
|
4250
|
+
const p = layer.pick("xy", cursorPx, cursorPy, project);
|
|
4251
|
+
if (!p) continue;
|
|
4252
|
+
const [ppx, ppy] = project(p.x, p.y);
|
|
4253
|
+
const d = Math.hypot(ppx - cursorPx, ppy - cursorPy);
|
|
4254
|
+
if (d < hitDist) {
|
|
4255
|
+
hitDist = d;
|
|
4256
|
+
hit = { layer, x: p.x, y: p.y, index: p.index };
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
return hit && hitDist <= 14 ? hit : null;
|
|
4260
|
+
}
|
|
4261
|
+
/** Click handler: in `pointInfo:"click"` mode, pin the point under the cursor. */
|
|
4262
|
+
handleClick(cursorPx, cursorPy) {
|
|
4263
|
+
if (this.pointInfo !== "click") return;
|
|
4264
|
+
this.selected = this.pickPoint(cursorPx, cursorPy);
|
|
4265
|
+
this.requestRender();
|
|
4266
|
+
}
|
|
4267
|
+
/** Draw the pinned point's marker and position its info box (or hide it). */
|
|
4268
|
+
updateInfoBox(region) {
|
|
4269
|
+
const box = this.infoBox;
|
|
4270
|
+
if (!this.selected) {
|
|
4271
|
+
box.style.display = "none";
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
const { layer, x, y, index } = this.selected;
|
|
4275
|
+
const ya = this.yAxes.get(layer.yAxis);
|
|
4276
|
+
const px = pxX(region, this.scaleX.norm(x));
|
|
4277
|
+
const py = pxY(region, ya.scale.norm(y));
|
|
4278
|
+
if (px < region.left || px > region.left + region.width || py < region.top || py > region.top + region.height) {
|
|
4279
|
+
box.style.display = "none";
|
|
4280
|
+
return;
|
|
4281
|
+
}
|
|
4282
|
+
drawMarker(this.axisCtx, px, py, layer.colorCss);
|
|
4283
|
+
box.replaceChildren();
|
|
4284
|
+
const info = layer.infoAt ? layer.infoAt(index) : null;
|
|
4285
|
+
const title = document.createElement("div");
|
|
4286
|
+
title.style.fontWeight = "600";
|
|
4287
|
+
title.style.marginBottom = "2px";
|
|
4288
|
+
title.textContent = info && info.length ? info[0] : layer.name;
|
|
4289
|
+
box.appendChild(title);
|
|
4290
|
+
if (info && info.length > 1) {
|
|
4291
|
+
for (let i = 1; i < info.length; i++) {
|
|
4292
|
+
const row = document.createElement("div");
|
|
4293
|
+
row.textContent = info[i];
|
|
4294
|
+
box.appendChild(row);
|
|
4295
|
+
}
|
|
4296
|
+
} else {
|
|
4297
|
+
const lines = this.hoverReadout ? this.hoverReadout(x, y) : [
|
|
4298
|
+
{ label: "x", value: (this.axisX.config.format ?? defaultFormat)(x) },
|
|
4299
|
+
{ label: "y", value: (ya.axis.config.format ?? defaultFormat)(y) }
|
|
4300
|
+
];
|
|
4301
|
+
for (const ln of lines) {
|
|
4302
|
+
const row = document.createElement("div");
|
|
4303
|
+
row.textContent = `${ln.label} ${ln.value}`;
|
|
4304
|
+
box.appendChild(row);
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
box.style.display = "block";
|
|
4308
|
+
const cw = this.container.clientWidth;
|
|
4309
|
+
const tw = box.offsetWidth;
|
|
4310
|
+
const th = box.offsetHeight;
|
|
4311
|
+
let left = px + 12;
|
|
4312
|
+
if (left + tw > cw) left = px - tw - 12;
|
|
4313
|
+
let top = py + 12;
|
|
4314
|
+
if (top + th > this.container.clientHeight) top = py - th - 12;
|
|
4315
|
+
box.style.left = `${Math.max(0, left)}px`;
|
|
4316
|
+
box.style.top = `${Math.max(0, top)}px`;
|
|
4317
|
+
}
|
|
4318
|
+
/** Data-space x extent across all layers, or null if empty. */
|
|
4319
|
+
layerBoundsX() {
|
|
4320
|
+
let lo = Infinity;
|
|
4321
|
+
let hi = -Infinity;
|
|
4322
|
+
let any = false;
|
|
4323
|
+
for (const l of this.layers) {
|
|
4324
|
+
const b = l.bounds();
|
|
4325
|
+
if (!b) continue;
|
|
4326
|
+
any = true;
|
|
4327
|
+
lo = Math.min(lo, b.x[0]);
|
|
4328
|
+
hi = Math.max(hi, b.x[1]);
|
|
4329
|
+
}
|
|
4330
|
+
return any ? [lo, hi] : null;
|
|
4331
|
+
}
|
|
4332
|
+
/** Data-space y extent across the layers bound to axis `id`, or null. */
|
|
4333
|
+
layerBoundsY(id) {
|
|
4334
|
+
let lo = Infinity;
|
|
4335
|
+
let hi = -Infinity;
|
|
4336
|
+
let any = false;
|
|
4337
|
+
for (const l of this.layers) {
|
|
4338
|
+
if (l.yAxis !== id) continue;
|
|
4339
|
+
const b = l.bounds();
|
|
4340
|
+
if (!b) continue;
|
|
4341
|
+
any = true;
|
|
4342
|
+
lo = Math.min(lo, b.y[0]);
|
|
4343
|
+
hi = Math.max(hi, b.y[1]);
|
|
4344
|
+
}
|
|
4345
|
+
return any ? [lo, hi] : null;
|
|
4346
|
+
}
|
|
4347
|
+
/** Keep the view inside the data bounds (used when `boundedPan`). */
|
|
4348
|
+
clampView() {
|
|
4349
|
+
if (!this.scaleX.log) {
|
|
4350
|
+
const bx = this.layerBoundsX();
|
|
4351
|
+
if (bx) this.scaleX.domain = clampAxis(this.scaleX.domain, bx);
|
|
4352
|
+
}
|
|
4353
|
+
for (const ya of this.yAxes.values()) {
|
|
4354
|
+
if (ya.scale.log) continue;
|
|
4355
|
+
const by = this.layerBoundsY(ya.id);
|
|
4356
|
+
if (by) ya.scale.domain = clampAxis(ya.scale.domain, by);
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
/**
|
|
4360
|
+
* Expand the looser axis so both axes share the same data-units-per-pixel,
|
|
4361
|
+
* preventing distortion (maps set `equalAspect`). Linear axes only; balances
|
|
4362
|
+
* the primary y against x, and is idempotent once balanced.
|
|
4363
|
+
*/
|
|
4364
|
+
applyAspect(region) {
|
|
4365
|
+
if (this.scaleX.log) return;
|
|
4366
|
+
const primary = this.primaryY();
|
|
4367
|
+
if (primary.scale.log) return;
|
|
4368
|
+
const w = region.width;
|
|
4369
|
+
const h = region.height;
|
|
4370
|
+
if (w <= 0 || h <= 0) return;
|
|
4371
|
+
const [xlo, xhi] = this.scaleX.domain;
|
|
4372
|
+
const [ylo, yhi] = primary.scale.domain;
|
|
4373
|
+
const uppX = (xhi - xlo) / w;
|
|
4374
|
+
const uppY = (yhi - ylo) / h;
|
|
4375
|
+
if (uppX <= 0 || uppY <= 0) return;
|
|
4376
|
+
if (Math.abs(uppX - uppY) <= 1e-9 * Math.max(uppX, uppY)) return;
|
|
4377
|
+
if (uppX > uppY) {
|
|
4378
|
+
const target = uppX * h;
|
|
4379
|
+
const cy = (ylo + yhi) / 2;
|
|
4380
|
+
primary.scale.domain = [cy - target / 2, cy + target / 2];
|
|
4381
|
+
} else {
|
|
4382
|
+
const target = uppY * w;
|
|
4383
|
+
const cx = (xlo + xhi) / 2;
|
|
4384
|
+
this.scaleX.domain = [cx - target / 2, cx + target / 2];
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4103
4387
|
drawSelection(x0, y0, x1, y1) {
|
|
4104
4388
|
const region = plotRegion(this.layout());
|
|
4105
4389
|
const lock = this.axisLock();
|
|
@@ -4184,29 +4468,80 @@ var PolarPlot = class {
|
|
|
4184
4468
|
constructor(container, options = {}) {
|
|
4185
4469
|
this.entries = [];
|
|
4186
4470
|
this.R = 1;
|
|
4471
|
+
this.baseR = 1;
|
|
4187
4472
|
this.dpr = 1;
|
|
4188
4473
|
this.frameRequested = false;
|
|
4474
|
+
/** Multiplier on the fitted radius: <1 zooms in (smaller r-domain), >1 out. */
|
|
4475
|
+
this.zoom = 1;
|
|
4476
|
+
/** Angular offset (radians, CCW) applied to all series + gridlines. */
|
|
4477
|
+
this.rotation = 0;
|
|
4478
|
+
this.hoverPx = null;
|
|
4479
|
+
this.homeButton = null;
|
|
4480
|
+
/** A point clicked to pin its details, until another click clears it. */
|
|
4481
|
+
this.selected = null;
|
|
4189
4482
|
this.container = container;
|
|
4190
4483
|
if (getComputedStyle(container).position === "static") container.style.position = "relative";
|
|
4191
|
-
this.gridCanvas = this.makeCanvas(0);
|
|
4192
|
-
this.dataCanvas = this.makeCanvas(1);
|
|
4484
|
+
this.gridCanvas = this.makeCanvas(0, false);
|
|
4485
|
+
this.dataCanvas = this.makeCanvas(1, false);
|
|
4486
|
+
this.overlayCanvas = this.makeCanvas(2, true);
|
|
4193
4487
|
this.gridCtx = this.gridCanvas.getContext("2d");
|
|
4194
4488
|
this.dataCtx = this.dataCanvas.getContext("2d");
|
|
4489
|
+
this.overlayCtx = this.overlayCanvas.getContext("2d");
|
|
4195
4490
|
const s = getSharedGL();
|
|
4196
4491
|
this.gl = s.gl;
|
|
4197
4492
|
this.sharedCanvas = s.canvas;
|
|
4493
|
+
this.isDark = options.theme === "dark";
|
|
4198
4494
|
this.theme = options.theme === "dark" ? darkTheme : options.theme === "light" || options.theme == null ? lightTheme : options.theme;
|
|
4199
4495
|
this.toRad = options.angleUnit === "deg" ? Math.PI / 180 : 1;
|
|
4200
4496
|
this.fixedR = options.maxRadius;
|
|
4201
4497
|
this.margin = options.margin ?? 28;
|
|
4498
|
+
this.interactive = options.interactive !== false;
|
|
4499
|
+
this.hoverEnabled = options.hover !== false;
|
|
4500
|
+
this.pointInfo = options.pointInfo ?? "click";
|
|
4501
|
+
this.tooltip = document.createElement("div");
|
|
4502
|
+
Object.assign(this.tooltip.style, {
|
|
4503
|
+
position: "absolute",
|
|
4504
|
+
display: "none",
|
|
4505
|
+
zIndex: "4",
|
|
4506
|
+
pointerEvents: "none",
|
|
4507
|
+
padding: "6px 8px",
|
|
4508
|
+
borderRadius: "6px",
|
|
4509
|
+
font: "12px system-ui, -apple-system, sans-serif",
|
|
4510
|
+
lineHeight: "1.4",
|
|
4511
|
+
whiteSpace: "nowrap",
|
|
4512
|
+
background: this.isDark ? "rgba(15,23,42,0.92)" : "rgba(255,255,255,0.96)",
|
|
4513
|
+
color: this.isDark ? "#e2e8f0" : "#1e293b",
|
|
4514
|
+
border: `1px solid ${this.isDark ? "rgba(148,163,184,0.25)" : "rgba(100,116,139,0.25)"}`,
|
|
4515
|
+
boxShadow: "0 4px 12px rgba(0,0,0,0.18)"
|
|
4516
|
+
});
|
|
4517
|
+
container.appendChild(this.tooltip);
|
|
4518
|
+
this.infoBox = document.createElement("div");
|
|
4519
|
+
Object.assign(this.infoBox.style, {
|
|
4520
|
+
position: "absolute",
|
|
4521
|
+
display: "none",
|
|
4522
|
+
zIndex: "6",
|
|
4523
|
+
pointerEvents: "none",
|
|
4524
|
+
padding: "6px 8px",
|
|
4525
|
+
borderRadius: "6px",
|
|
4526
|
+
font: "12px system-ui, -apple-system, sans-serif",
|
|
4527
|
+
lineHeight: "1.4",
|
|
4528
|
+
whiteSpace: "nowrap",
|
|
4529
|
+
background: this.isDark ? "rgba(15,23,42,0.96)" : "rgba(255,255,255,0.98)",
|
|
4530
|
+
color: this.isDark ? "#e2e8f0" : "#1e293b",
|
|
4531
|
+
border: `1px solid ${this.isDark ? "rgba(148,163,184,0.4)" : "rgba(100,116,139,0.4)"}`,
|
|
4532
|
+
boxShadow: "0 6px 18px rgba(0,0,0,0.25)"
|
|
4533
|
+
});
|
|
4534
|
+
container.appendChild(this.infoBox);
|
|
4202
4535
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
4203
4536
|
this.resizeObserver.observe(container);
|
|
4204
4537
|
this.resize();
|
|
4538
|
+
if (this.interactive) this.attachInteraction();
|
|
4539
|
+
if (options.showToolbar !== false) this.homeButton = this.makeHomeButton();
|
|
4205
4540
|
}
|
|
4206
|
-
makeCanvas(z) {
|
|
4541
|
+
makeCanvas(z, interactive) {
|
|
4207
4542
|
const c = document.createElement("canvas");
|
|
4208
4543
|
Object.assign(c.style, { position: "absolute", inset: "0", width: "100%", height: "100%", zIndex: String(z) });
|
|
4209
|
-
c.style.pointerEvents = "none";
|
|
4544
|
+
if (!interactive) c.style.pointerEvents = "none";
|
|
4210
4545
|
this.container.appendChild(c);
|
|
4211
4546
|
return c;
|
|
4212
4547
|
}
|
|
@@ -4214,9 +4549,10 @@ var PolarPlot = class {
|
|
|
4214
4549
|
const n = Math.min(theta.length, r.length);
|
|
4215
4550
|
const m = closed && n > 0 ? n + 1 : n;
|
|
4216
4551
|
const x = new Float64Array(m), y = new Float64Array(m);
|
|
4552
|
+
const rot = this.rotation;
|
|
4217
4553
|
let maxR = 0;
|
|
4218
4554
|
for (let i = 0; i < n; i++) {
|
|
4219
|
-
const a = theta[i] * this.toRad, rr = r[i];
|
|
4555
|
+
const a = theta[i] * this.toRad + rot, rr = r[i];
|
|
4220
4556
|
x[i] = rr * Math.cos(a);
|
|
4221
4557
|
y[i] = rr * Math.sin(a);
|
|
4222
4558
|
if (Math.abs(rr) > maxR) maxR = Math.abs(rr);
|
|
@@ -4231,7 +4567,7 @@ var PolarPlot = class {
|
|
|
4231
4567
|
const closed = opts.closed ?? false;
|
|
4232
4568
|
const { x, y, maxR } = this.toXY(opts.theta, opts.r, closed);
|
|
4233
4569
|
const layer = new LineLayer(this.gl, { x, y, color: opts.color, width: opts.width ?? 2, decimate: false });
|
|
4234
|
-
const entry = { layer, closed, maxR };
|
|
4570
|
+
const entry = { layer, closed, maxR, theta: opts.theta, r: opts.r };
|
|
4235
4571
|
this.entries.push(entry);
|
|
4236
4572
|
this.refit();
|
|
4237
4573
|
return this.handle(entry);
|
|
@@ -4239,7 +4575,7 @@ var PolarPlot = class {
|
|
|
4239
4575
|
addScatter(opts) {
|
|
4240
4576
|
const { x, y, maxR } = this.toXY(opts.theta, opts.r, false);
|
|
4241
4577
|
const layer = new ScatterLayer(this.gl, { x, y, color: opts.color, size: opts.size ?? 5 });
|
|
4242
|
-
const entry = { layer, closed: false, maxR };
|
|
4578
|
+
const entry = { layer, closed: false, maxR, theta: opts.theta, r: opts.r, labels: opts.labels };
|
|
4243
4579
|
this.entries.push(entry);
|
|
4244
4580
|
this.refit();
|
|
4245
4581
|
return this.handle(entry);
|
|
@@ -4247,6 +4583,8 @@ var PolarPlot = class {
|
|
|
4247
4583
|
handle(entry) {
|
|
4248
4584
|
return {
|
|
4249
4585
|
setData: (theta, r) => {
|
|
4586
|
+
entry.theta = theta;
|
|
4587
|
+
entry.r = r;
|
|
4250
4588
|
const { x, y, maxR } = this.toXY(theta, r, entry.closed);
|
|
4251
4589
|
entry.layer.setData(x, y);
|
|
4252
4590
|
entry.maxR = maxR;
|
|
@@ -4256,29 +4594,63 @@ var PolarPlot = class {
|
|
|
4256
4594
|
};
|
|
4257
4595
|
}
|
|
4258
4596
|
refit() {
|
|
4597
|
+
let base;
|
|
4259
4598
|
if (this.fixedR != null) {
|
|
4260
|
-
|
|
4261
|
-
|
|
4599
|
+
base = this.fixedR;
|
|
4600
|
+
} else {
|
|
4601
|
+
let m = 0;
|
|
4602
|
+
for (const e of this.entries) m = Math.max(m, e.maxR);
|
|
4603
|
+
base = m || 1;
|
|
4604
|
+
}
|
|
4605
|
+
this.baseR = base;
|
|
4606
|
+
this.R = base * this.zoom;
|
|
4607
|
+
}
|
|
4608
|
+
/** Re-project every series with the current rotation (called when rotation changes). */
|
|
4609
|
+
retransform() {
|
|
4610
|
+
for (const e of this.entries) {
|
|
4611
|
+
const { x, y } = this.toXY(e.theta, e.r, e.closed);
|
|
4612
|
+
e.layer.setData(x, y);
|
|
4262
4613
|
}
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4614
|
+
}
|
|
4615
|
+
// ---- Interaction control --------------------------------------------------
|
|
4616
|
+
/** Reset zoom (r-domain) and rotation back to the initial view. */
|
|
4617
|
+
home() {
|
|
4618
|
+
this.zoom = 1;
|
|
4619
|
+
this.rotation = 0;
|
|
4620
|
+
this.refit();
|
|
4621
|
+
this.retransform();
|
|
4622
|
+
this.requestRender();
|
|
4623
|
+
}
|
|
4624
|
+
/** Current rotation offset in radians (CCW). */
|
|
4625
|
+
getRotation() {
|
|
4626
|
+
return this.rotation;
|
|
4627
|
+
}
|
|
4628
|
+
/** Set the rotation offset (radians, CCW) and redraw. */
|
|
4629
|
+
setRotation(rad) {
|
|
4630
|
+
this.rotation = rad;
|
|
4631
|
+
this.retransform();
|
|
4632
|
+
this.requestRender();
|
|
4266
4633
|
}
|
|
4267
4634
|
destroy() {
|
|
4268
4635
|
this.resizeObserver.disconnect();
|
|
4269
4636
|
for (const e of this.entries) e.layer.dispose();
|
|
4637
|
+
this.tooltip.remove();
|
|
4638
|
+
this.infoBox.remove();
|
|
4639
|
+
this.homeButton?.remove();
|
|
4270
4640
|
this.container.removeChild(this.gridCanvas);
|
|
4271
4641
|
this.container.removeChild(this.dataCanvas);
|
|
4642
|
+
this.container.removeChild(this.overlayCanvas);
|
|
4272
4643
|
}
|
|
4273
4644
|
resize() {
|
|
4274
4645
|
this.dpr = window.devicePixelRatio || 1;
|
|
4275
4646
|
const w = this.container.clientWidth, h = this.container.clientHeight;
|
|
4276
|
-
for (const c of [this.gridCanvas, this.dataCanvas]) {
|
|
4647
|
+
for (const c of [this.gridCanvas, this.dataCanvas, this.overlayCanvas]) {
|
|
4277
4648
|
c.width = Math.max(1, Math.round(w * this.dpr));
|
|
4278
4649
|
c.height = Math.max(1, Math.round(h * this.dpr));
|
|
4279
4650
|
}
|
|
4280
4651
|
this.gridCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
4281
4652
|
this.dataCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
4653
|
+
this.overlayCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
4282
4654
|
this.render();
|
|
4283
4655
|
}
|
|
4284
4656
|
requestRender() {
|
|
@@ -4320,6 +4692,14 @@ var PolarPlot = class {
|
|
|
4320
4692
|
this.dataCtx.clearRect(0, 0, w, h);
|
|
4321
4693
|
this.dataCtx.drawImage(this.sharedCanvas, 0, 0, w, h);
|
|
4322
4694
|
this.drawGrid(sq);
|
|
4695
|
+
this.overlayCtx.clearRect(0, 0, w, h);
|
|
4696
|
+
if (this.hoverEnabled && this.hoverPx) {
|
|
4697
|
+
this.renderHover(sq);
|
|
4698
|
+
} else {
|
|
4699
|
+
this.tooltip.style.display = "none";
|
|
4700
|
+
if (this.pointInfo === "hover") this.selected = null;
|
|
4701
|
+
}
|
|
4702
|
+
this.updateInfoBox(sq);
|
|
4323
4703
|
}
|
|
4324
4704
|
drawGrid(sq) {
|
|
4325
4705
|
const ctx = this.gridCtx;
|
|
@@ -4333,7 +4713,7 @@ var PolarPlot = class {
|
|
|
4333
4713
|
ctx.strokeStyle = this.theme.grid;
|
|
4334
4714
|
ctx.lineWidth = 1;
|
|
4335
4715
|
for (let deg = 0; deg < 360; deg += 30) {
|
|
4336
|
-
const a = deg * Math.PI / 180;
|
|
4716
|
+
const a = deg * Math.PI / 180 + this.rotation;
|
|
4337
4717
|
const ex = cx + Math.cos(a) * pr, ey = cy - Math.sin(a) * pr;
|
|
4338
4718
|
ctx.beginPath();
|
|
4339
4719
|
ctx.moveTo(cx, cy);
|
|
@@ -4354,7 +4734,270 @@ var PolarPlot = class {
|
|
|
4354
4734
|
}
|
|
4355
4735
|
ctx.restore();
|
|
4356
4736
|
}
|
|
4737
|
+
// ---- Hover ----------------------------------------------------------------
|
|
4738
|
+
setHover(px) {
|
|
4739
|
+
const had = this.hoverPx !== null;
|
|
4740
|
+
if (!px && !had) return;
|
|
4741
|
+
this.hoverPx = px;
|
|
4742
|
+
this.requestRender();
|
|
4743
|
+
}
|
|
4744
|
+
/** Nearest data point (across all series) to a cursor position, in CSS px. */
|
|
4745
|
+
pickNearest(sq, cursorX, cursorY) {
|
|
4746
|
+
const pr = sq.side / 2;
|
|
4747
|
+
const { cx, cy } = sq;
|
|
4748
|
+
const rot = this.rotation;
|
|
4749
|
+
let best = null;
|
|
4750
|
+
for (const e of this.entries) {
|
|
4751
|
+
const n = Math.min(e.theta.length, e.r.length);
|
|
4752
|
+
for (let i = 0; i < n; i++) {
|
|
4753
|
+
const a = e.theta[i] * this.toRad + rot, rr = e.r[i];
|
|
4754
|
+
const px = cx + rr * Math.cos(a) / this.R * pr;
|
|
4755
|
+
const py = cy - rr * Math.sin(a) / this.R * pr;
|
|
4756
|
+
const dx = px - cursorX, dy = py - cursorY;
|
|
4757
|
+
const d2 = dx * dx + dy * dy;
|
|
4758
|
+
if (!best || d2 < best.d2) best = { entry: e, index: i, px, py, theta: e.theta[i], r: rr, d2 };
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return best;
|
|
4762
|
+
}
|
|
4763
|
+
/** Draw a filled, white-ringed marker on the overlay at a screen position. */
|
|
4764
|
+
drawPointMarker(px, py, color) {
|
|
4765
|
+
const ctx = this.overlayCtx;
|
|
4766
|
+
ctx.save();
|
|
4767
|
+
ctx.fillStyle = color;
|
|
4768
|
+
ctx.strokeStyle = "#ffffff";
|
|
4769
|
+
ctx.lineWidth = 1.5;
|
|
4770
|
+
ctx.beginPath();
|
|
4771
|
+
ctx.arc(px, py, 4, 0, Math.PI * 2);
|
|
4772
|
+
ctx.fill();
|
|
4773
|
+
ctx.stroke();
|
|
4774
|
+
ctx.restore();
|
|
4775
|
+
}
|
|
4776
|
+
renderHover(sq) {
|
|
4777
|
+
const cursor = this.hoverPx;
|
|
4778
|
+
const best = this.pickNearest(sq, cursor.x, cursor.y);
|
|
4779
|
+
const within = best != null && best.d2 <= 24 * 24;
|
|
4780
|
+
if (this.pointInfo === "hover") {
|
|
4781
|
+
this.selected = within ? { entry: best.entry, index: best.index } : null;
|
|
4782
|
+
}
|
|
4783
|
+
if (!within) {
|
|
4784
|
+
this.tooltip.style.display = "none";
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
this.drawPointMarker(best.px, best.py, best.entry.layer.colorCss);
|
|
4788
|
+
this.updateTooltip(best.entry, best.theta, best.r, cursor);
|
|
4789
|
+
}
|
|
4790
|
+
/** Click handler: pin the nearest point's details, or clear on empty space. */
|
|
4791
|
+
handleClick(sq, cursorX, cursorY) {
|
|
4792
|
+
if (this.pointInfo !== "click") return;
|
|
4793
|
+
const hit = this.pickNearest(sq, cursorX, cursorY);
|
|
4794
|
+
this.selected = hit && hit.d2 <= 14 * 14 ? { entry: hit.entry, index: hit.index } : null;
|
|
4795
|
+
this.requestRender();
|
|
4796
|
+
}
|
|
4797
|
+
/** Draw the pinned point's marker and position its info box (or hide it). */
|
|
4798
|
+
updateInfoBox(sq) {
|
|
4799
|
+
const box = this.infoBox;
|
|
4800
|
+
if (!this.selected) {
|
|
4801
|
+
box.style.display = "none";
|
|
4802
|
+
return;
|
|
4803
|
+
}
|
|
4804
|
+
const { entry, index } = this.selected;
|
|
4805
|
+
if (index >= Math.min(entry.theta.length, entry.r.length)) {
|
|
4806
|
+
this.selected = null;
|
|
4807
|
+
box.style.display = "none";
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4810
|
+
const pr = sq.side / 2;
|
|
4811
|
+
const a = entry.theta[index] * this.toRad + this.rotation, rr = entry.r[index];
|
|
4812
|
+
const px = sq.cx + rr * Math.cos(a) / this.R * pr;
|
|
4813
|
+
const py = sq.cy - rr * Math.sin(a) / this.R * pr;
|
|
4814
|
+
if (px < sq.left || px > sq.left + sq.side || py < sq.top || py > sq.top + sq.side) {
|
|
4815
|
+
box.style.display = "none";
|
|
4816
|
+
return;
|
|
4817
|
+
}
|
|
4818
|
+
this.drawPointMarker(px, py, entry.layer.colorCss);
|
|
4819
|
+
box.replaceChildren();
|
|
4820
|
+
const label = entry.labels && index < entry.labels.length ? entry.labels[index] : void 0;
|
|
4821
|
+
const title = document.createElement("div");
|
|
4822
|
+
title.style.fontWeight = "600";
|
|
4823
|
+
title.style.marginBottom = "2px";
|
|
4824
|
+
title.textContent = label != null && label !== "" ? label : entry.layer.name;
|
|
4825
|
+
box.appendChild(title);
|
|
4826
|
+
const unit = this.toRad === 1 ? "" : "\xB0";
|
|
4827
|
+
const rRow = document.createElement("div");
|
|
4828
|
+
rRow.textContent = `r = ${fmt(rr)}`;
|
|
4829
|
+
const tRow = document.createElement("div");
|
|
4830
|
+
tRow.textContent = `\u03B8 = ${fmt(entry.theta[index])}${unit}`;
|
|
4831
|
+
box.appendChild(rRow);
|
|
4832
|
+
box.appendChild(tRow);
|
|
4833
|
+
box.style.display = "block";
|
|
4834
|
+
const cw = this.container.clientWidth, ch = this.container.clientHeight;
|
|
4835
|
+
const tw = box.offsetWidth, th = box.offsetHeight;
|
|
4836
|
+
let left = px + 12;
|
|
4837
|
+
if (left + tw > cw) left = px - tw - 12;
|
|
4838
|
+
let top = py + 12;
|
|
4839
|
+
if (top + th > ch) top = py - th - 12;
|
|
4840
|
+
box.style.left = `${Math.max(0, left)}px`;
|
|
4841
|
+
box.style.top = `${Math.max(0, top)}px`;
|
|
4842
|
+
}
|
|
4843
|
+
updateTooltip(entry, theta, r, cursor) {
|
|
4844
|
+
const tip = this.tooltip;
|
|
4845
|
+
tip.replaceChildren();
|
|
4846
|
+
const head = document.createElement("div");
|
|
4847
|
+
head.style.display = "flex";
|
|
4848
|
+
head.style.alignItems = "center";
|
|
4849
|
+
head.style.gap = "6px";
|
|
4850
|
+
head.style.marginBottom = "3px";
|
|
4851
|
+
const dot = document.createElement("span");
|
|
4852
|
+
Object.assign(dot.style, {
|
|
4853
|
+
width: "8px",
|
|
4854
|
+
height: "8px",
|
|
4855
|
+
borderRadius: "50%",
|
|
4856
|
+
background: entry.layer.colorCss,
|
|
4857
|
+
flex: "0 0 auto"
|
|
4858
|
+
});
|
|
4859
|
+
const nameEl = document.createElement("span");
|
|
4860
|
+
nameEl.textContent = entry.layer.name;
|
|
4861
|
+
head.appendChild(dot);
|
|
4862
|
+
head.appendChild(nameEl);
|
|
4863
|
+
tip.appendChild(head);
|
|
4864
|
+
const unit = this.toRad === 1 ? "" : "\xB0";
|
|
4865
|
+
const rRow = document.createElement("div");
|
|
4866
|
+
rRow.textContent = `r = ${fmt(r)}`;
|
|
4867
|
+
const tRow = document.createElement("div");
|
|
4868
|
+
tRow.textContent = `\u03B8 = ${fmt(theta)}${unit}`;
|
|
4869
|
+
tip.appendChild(rRow);
|
|
4870
|
+
tip.appendChild(tRow);
|
|
4871
|
+
tip.style.display = "block";
|
|
4872
|
+
const cw = this.container.clientWidth;
|
|
4873
|
+
const tw = tip.offsetWidth, th = tip.offsetHeight;
|
|
4874
|
+
let left = cursor.x + 14;
|
|
4875
|
+
if (left + tw > cw) left = cursor.x - tw - 14;
|
|
4876
|
+
let top = cursor.y + 14;
|
|
4877
|
+
if (top + th > this.container.clientHeight) top = cursor.y - th - 14;
|
|
4878
|
+
tip.style.left = `${Math.max(0, left)}px`;
|
|
4879
|
+
tip.style.top = `${Math.max(0, top)}px`;
|
|
4880
|
+
}
|
|
4881
|
+
// ---- Interaction ----------------------------------------------------------
|
|
4882
|
+
attachInteraction() {
|
|
4883
|
+
const el = this.overlayCanvas;
|
|
4884
|
+
el.style.touchAction = "none";
|
|
4885
|
+
el.style.cursor = "grab";
|
|
4886
|
+
el.addEventListener(
|
|
4887
|
+
"wheel",
|
|
4888
|
+
(e) => {
|
|
4889
|
+
e.preventDefault();
|
|
4890
|
+
const factor = Math.exp(e.deltaY * 1e-3);
|
|
4891
|
+
this.zoom = Math.max(1e-3, Math.min(1e3, this.zoom * factor));
|
|
4892
|
+
this.R = this.baseR * this.zoom;
|
|
4893
|
+
this.requestRender();
|
|
4894
|
+
},
|
|
4895
|
+
{ passive: false }
|
|
4896
|
+
);
|
|
4897
|
+
let rotating = false;
|
|
4898
|
+
let lastAngle = 0;
|
|
4899
|
+
let downX = 0, downY = 0;
|
|
4900
|
+
const cursorAngle = (px, py) => {
|
|
4901
|
+
const sq = this.square();
|
|
4902
|
+
return Math.atan2(sq.cy - py, px - sq.cx);
|
|
4903
|
+
};
|
|
4904
|
+
el.addEventListener("pointerdown", (e) => {
|
|
4905
|
+
el.setPointerCapture(e.pointerId);
|
|
4906
|
+
const rect = el.getBoundingClientRect();
|
|
4907
|
+
downX = e.clientX - rect.left;
|
|
4908
|
+
downY = e.clientY - rect.top;
|
|
4909
|
+
rotating = true;
|
|
4910
|
+
lastAngle = cursorAngle(downX, downY);
|
|
4911
|
+
this.hoverPx = null;
|
|
4912
|
+
el.style.cursor = "grabbing";
|
|
4913
|
+
});
|
|
4914
|
+
el.addEventListener("pointermove", (e) => {
|
|
4915
|
+
const rect = el.getBoundingClientRect();
|
|
4916
|
+
const px = e.clientX - rect.left;
|
|
4917
|
+
const py = e.clientY - rect.top;
|
|
4918
|
+
if (rotating) {
|
|
4919
|
+
const ang = cursorAngle(px, py);
|
|
4920
|
+
let d = ang - lastAngle;
|
|
4921
|
+
if (d > Math.PI) d -= 2 * Math.PI;
|
|
4922
|
+
else if (d < -Math.PI) d += 2 * Math.PI;
|
|
4923
|
+
this.rotation += d;
|
|
4924
|
+
lastAngle = ang;
|
|
4925
|
+
this.retransform();
|
|
4926
|
+
this.requestRender();
|
|
4927
|
+
} else if (this.hoverEnabled) {
|
|
4928
|
+
this.setHover({ x: px, y: py });
|
|
4929
|
+
}
|
|
4930
|
+
});
|
|
4931
|
+
el.addEventListener("pointerleave", () => this.setHover(null));
|
|
4932
|
+
const end = (e, click) => {
|
|
4933
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
4934
|
+
if (rotating) {
|
|
4935
|
+
rotating = false;
|
|
4936
|
+
el.style.cursor = "grab";
|
|
4937
|
+
}
|
|
4938
|
+
if (!click) return;
|
|
4939
|
+
const rect = el.getBoundingClientRect();
|
|
4940
|
+
const px = e.clientX - rect.left, py = e.clientY - rect.top;
|
|
4941
|
+
if (Math.hypot(px - downX, py - downY) < 4) this.handleClick(this.square(), px, py);
|
|
4942
|
+
};
|
|
4943
|
+
el.addEventListener("pointerup", (e) => end(e, true));
|
|
4944
|
+
el.addEventListener("pointercancel", (e) => end(e, false));
|
|
4945
|
+
}
|
|
4946
|
+
makeHomeButton() {
|
|
4947
|
+
const dark = this.isDark;
|
|
4948
|
+
const btn = document.createElement("button");
|
|
4949
|
+
btn.type = "button";
|
|
4950
|
+
btn.title = "Reset view (Home)";
|
|
4951
|
+
btn.setAttribute("aria-label", "Reset view (Home)");
|
|
4952
|
+
Object.assign(btn.style, {
|
|
4953
|
+
position: "absolute",
|
|
4954
|
+
top: "8px",
|
|
4955
|
+
right: "8px",
|
|
4956
|
+
zIndex: "5",
|
|
4957
|
+
display: "inline-flex",
|
|
4958
|
+
alignItems: "center",
|
|
4959
|
+
justifyContent: "center",
|
|
4960
|
+
width: "26px",
|
|
4961
|
+
height: "26px",
|
|
4962
|
+
padding: "0",
|
|
4963
|
+
borderRadius: "6px",
|
|
4964
|
+
background: dark ? "rgba(15,23,42,0.85)" : "rgba(255,255,255,0.9)",
|
|
4965
|
+
color: dark ? "#cbd5e1" : "#475569",
|
|
4966
|
+
border: `1px solid ${dark ? "rgba(148,163,184,0.25)" : "rgba(100,116,139,0.25)"}`,
|
|
4967
|
+
backdropFilter: "blur(6px)",
|
|
4968
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
|
4969
|
+
cursor: "pointer"
|
|
4970
|
+
});
|
|
4971
|
+
const svgNS = "http://www.w3.org/2000/svg";
|
|
4972
|
+
const svg = document.createElementNS(svgNS, "svg");
|
|
4973
|
+
svg.setAttribute("width", "16");
|
|
4974
|
+
svg.setAttribute("height", "16");
|
|
4975
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
4976
|
+
svg.setAttribute("aria-hidden", "true");
|
|
4977
|
+
const mkPath = (d) => {
|
|
4978
|
+
const p = document.createElementNS(svgNS, "path");
|
|
4979
|
+
p.setAttribute("d", d);
|
|
4980
|
+
p.setAttribute("fill", "none");
|
|
4981
|
+
p.setAttribute("stroke", "currentColor");
|
|
4982
|
+
p.setAttribute("stroke-width", "1.5");
|
|
4983
|
+
p.setAttribute("stroke-linecap", "round");
|
|
4984
|
+
p.setAttribute("stroke-linejoin", "round");
|
|
4985
|
+
return p;
|
|
4986
|
+
};
|
|
4987
|
+
svg.appendChild(mkPath("M2 7.5 8 2l6 5.5"));
|
|
4988
|
+
svg.appendChild(mkPath("M4 7v6.5h3.2V10h1.6v3.5H12V7"));
|
|
4989
|
+
btn.appendChild(svg);
|
|
4990
|
+
btn.addEventListener("click", () => this.home());
|
|
4991
|
+
this.container.appendChild(btn);
|
|
4992
|
+
return btn;
|
|
4993
|
+
}
|
|
4357
4994
|
};
|
|
4995
|
+
function fmt(v) {
|
|
4996
|
+
if (!Number.isFinite(v)) return String(v);
|
|
4997
|
+
const a = Math.abs(v);
|
|
4998
|
+
if (a !== 0 && (a < 1e-3 || a >= 1e5)) return v.toExponential(2);
|
|
4999
|
+
return String(Math.round(v * 1e3) / 1e3);
|
|
5000
|
+
}
|
|
4358
5001
|
|
|
4359
5002
|
// src/plot3d/mat4.ts
|
|
4360
5003
|
function identity() {
|
|
@@ -5072,10 +5715,13 @@ export {
|
|
|
5072
5715
|
ScatterLayer,
|
|
5073
5716
|
StemLayer,
|
|
5074
5717
|
SurfaceLayer,
|
|
5718
|
+
TRANSFORM_GLSL,
|
|
5719
|
+
TRANSFORM_UNIFORMS,
|
|
5075
5720
|
TimeScale,
|
|
5076
5721
|
autoTicks,
|
|
5077
5722
|
boxStats,
|
|
5078
5723
|
colormap,
|
|
5724
|
+
createProgram,
|
|
5079
5725
|
createToolbar,
|
|
5080
5726
|
darkTheme,
|
|
5081
5727
|
defaultFormat,
|
|
@@ -5087,8 +5733,10 @@ export {
|
|
|
5087
5733
|
parseColor,
|
|
5088
5734
|
quantileSorted,
|
|
5089
5735
|
resolveTicks,
|
|
5736
|
+
setTransformUniforms,
|
|
5090
5737
|
spectrogram,
|
|
5091
5738
|
toColorCss,
|
|
5739
|
+
uniformLocations,
|
|
5092
5740
|
withMinorTicks
|
|
5093
5741
|
};
|
|
5094
5742
|
//# sourceMappingURL=index.js.map
|