@photonviz/core 0.1.1 → 0.2.0

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/dist/index.js CHANGED
@@ -82,10 +82,10 @@ var Axis = class {
82
82
  }
83
83
  ticks = major;
84
84
  }
85
- const fmt = this.config.format ?? ((v) => scale.formatTick(v));
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 ?? fmt(t.value),
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
- nearestByX(x) {
2296
- if (this.count === 0) return null;
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
- nearestByX(x) {
2800
- if (this.count === 0) return null;
2801
- let best = 0, bestDist = Infinity;
2802
- for (let i = 0; i < this.count; i++) {
2803
- const d = Math.abs(this.xs[i] - x);
2804
- if (d < bestDist) {
2805
- bestDist = d;
2806
- best = i;
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
- nearestByX(x) {
3014
- if (this.count === 0) return null;
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.nearestByX === "function";
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 ?? "box";
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.toolbar !== false) {
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.hoverEnabled && this.hoverPx) this.renderHover(region);
3879
- else this.tooltip.style.display = "none";
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
- drawCrosshair(this.axisCtx, region, cursor.x, this.theme);
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 px = pxX(region, this.scaleX.norm(p.x));
3897
- const py = pxY(region, ya.scale.norm(p.y));
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
- if (rows.length === 0) {
3902
- this.tooltip.style.display = "none";
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
- const xfmt = this.axisX.config.format ?? defaultFormat;
3911
- const header = document.createElement("div");
3912
- header.style.opacity = "0.7";
3913
- header.style.marginBottom = "3px";
3914
- header.textContent = `x = ${xfmt(dataX)}`;
3915
- tip.appendChild(header);
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
- const rect = el.getBoundingClientRect();
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
- this.R = this.fixedR;
4261
- return;
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
- let m = 0;
4264
- for (const e of this.entries) m = Math.max(m, e.maxR);
4265
- this.R = m || 1;
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