@dtour/viewer 0.1.0 → 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.
Files changed (63) hide show
  1. package/dist/Dtour.d.ts +5 -1
  2. package/dist/Dtour.d.ts.map +1 -1
  3. package/dist/DtourViewer.d.ts +4 -1
  4. package/dist/DtourViewer.d.ts.map +1 -1
  5. package/dist/components/AxisOverlay.d.ts +11 -1
  6. package/dist/components/AxisOverlay.d.ts.map +1 -1
  7. package/dist/components/CircularSlider.d.ts +21 -2
  8. package/dist/components/CircularSlider.d.ts.map +1 -1
  9. package/dist/components/DtourToolbar.d.ts +2 -1
  10. package/dist/components/DtourToolbar.d.ts.map +1 -1
  11. package/dist/components/Gallery.d.ts +3 -3
  12. package/dist/components/Gallery.d.ts.map +1 -1
  13. package/dist/components/RevertCameraButton.d.ts +6 -0
  14. package/dist/components/RevertCameraButton.d.ts.map +1 -0
  15. package/dist/components/ui/checkbox.d.ts +6 -0
  16. package/dist/components/ui/checkbox.d.ts.map +1 -0
  17. package/dist/hooks/usePlayback.d.ts +7 -5
  18. package/dist/hooks/usePlayback.d.ts.map +1 -1
  19. package/dist/hooks/useScatter.d.ts.map +1 -1
  20. package/dist/index.d.ts +4 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/layout/gallery-positions.d.ts +3 -1
  23. package/dist/layout/gallery-positions.d.ts.map +1 -1
  24. package/dist/layout/selector-size.d.ts +4 -2
  25. package/dist/layout/selector-size.d.ts.map +1 -1
  26. package/dist/lib/arcball.d.ts +21 -0
  27. package/dist/lib/arcball.d.ts.map +1 -0
  28. package/dist/lib/position-remap.d.ts +16 -0
  29. package/dist/lib/position-remap.d.ts.map +1 -0
  30. package/dist/lib/throttle-debounce.d.ts +28 -0
  31. package/dist/lib/throttle-debounce.d.ts.map +1 -0
  32. package/dist/radial-chart/RadialChart.d.ts +5 -1
  33. package/dist/radial-chart/RadialChart.d.ts.map +1 -1
  34. package/dist/spec.d.ts +32 -0
  35. package/dist/spec.d.ts.map +1 -1
  36. package/dist/state/atoms.d.ts +67 -0
  37. package/dist/state/atoms.d.ts.map +1 -1
  38. package/dist/state/spec-sync.d.ts +2 -0
  39. package/dist/state/spec-sync.d.ts.map +1 -1
  40. package/dist/viewer.css +1 -1
  41. package/dist/viewer.js +11620 -10118
  42. package/package.json +6 -1
  43. package/src/Dtour.tsx +82 -9
  44. package/src/DtourViewer.tsx +480 -100
  45. package/src/components/AxisOverlay.tsx +332 -182
  46. package/src/components/CircularSlider.tsx +363 -174
  47. package/src/components/DtourToolbar.tsx +121 -10
  48. package/src/components/Gallery.tsx +197 -39
  49. package/src/components/RevertCameraButton.tsx +39 -0
  50. package/src/components/ui/checkbox.tsx +32 -0
  51. package/src/hooks/usePlayback.ts +18 -44
  52. package/src/hooks/useScatter.ts +21 -5
  53. package/src/index.ts +16 -3
  54. package/src/layout/gallery-positions.ts +15 -4
  55. package/src/layout/selector-size.ts +24 -10
  56. package/src/lib/arcball.ts +119 -0
  57. package/src/lib/position-remap.ts +51 -0
  58. package/src/lib/throttle-debounce.ts +79 -0
  59. package/src/radial-chart/RadialChart.tsx +45 -6
  60. package/src/spec.ts +143 -0
  61. package/src/state/atoms.ts +65 -0
  62. package/src/state/spec-sync.ts +15 -0
  63. package/src/styles.css +16 -16
@@ -4,19 +4,36 @@ import {
4
4
  OKABE_ITO,
5
5
  computeArcLengths,
6
6
  createScatter,
7
+ createScatterWebGL,
7
8
  interpolateAtPosition,
8
9
  } from '@dtour/scatter';
9
10
  import type { ScatterInstance, ScatterStatus } from '@dtour/scatter';
10
11
  import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
11
12
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
13
  import { AxisOverlay } from './components/AxisOverlay.tsx';
14
+ import type { AxisOverlayHandle } from './components/AxisOverlay.tsx';
13
15
  import { CircularSlider } from './components/CircularSlider.tsx';
16
+ import type { CircularSliderHandle } from './components/CircularSlider.tsx';
14
17
  import { Gallery } from './components/Gallery.tsx';
15
18
  import { LassoOverlay } from './components/LassoOverlay.tsx';
19
+ import { RevertCameraButton } from './components/RevertCameraButton.tsx';
16
20
  import { useAnimatePosition } from './hooks/useAnimatePosition.ts';
17
21
  import { useGrandTour } from './hooks/useGrandTour.ts';
22
+ import { usePlayback } from './hooks/usePlayback.ts';
18
23
  import { useScatter } from './hooks/useScatter.ts';
19
24
  import { computeSelectorSize } from './layout/selector-size.ts';
25
+ import {
26
+ IDENTITY_QUAT,
27
+ type Quat,
28
+ arcballQuat,
29
+ isIdentityQuat,
30
+ multiplyQuat,
31
+ projectToSphere,
32
+ quatToMat3,
33
+ slerp,
34
+ } from './lib/arcball.ts';
35
+ import { tourToVisual, visualToTour } from './lib/position-remap.ts';
36
+ import { throttleAndDebounce } from './lib/throttle-debounce.ts';
20
37
  import { RadialChart } from './radial-chart/RadialChart.tsx';
21
38
  import { parseMetrics } from './radial-chart/parse-metrics.ts';
22
39
  import type { RadialTrackConfig } from './radial-chart/types.ts';
@@ -24,16 +41,26 @@ import {
24
41
  activeColumnsAtom,
25
42
  activeIndicesAtom,
26
43
  animationGenAtom,
44
+ arcLengthsAtom,
27
45
  cameraZoomAtom,
28
46
  canvasSizeAtom,
29
47
  currentBasisAtom,
48
+ currentKeyframeAtom,
49
+ embeddedConfigAtom,
50
+ frameLoadingsAtom,
30
51
  guidedSuspendedAtom,
52
+ hoveredKeyframeAtom,
53
+ is3dRotatedAtom,
31
54
  legendSelectionAtom,
32
55
  metadataAtom,
33
56
  pointColorAtom,
57
+ previewCentersAtom,
34
58
  previewCountAtom,
35
59
  previewScaleAtom,
36
60
  resolvedThemeAtom,
61
+ showAxesAtom,
62
+ showFrameLoadingsAtom,
63
+ sliderSpacingAtom,
37
64
  tourByAtom,
38
65
  tourPlayingAtom,
39
66
  tourPositionAtom,
@@ -60,6 +87,9 @@ export type DtourViewerProps = {
60
87
  toolbarHeight?: number | undefined;
61
88
  /** Called when the scatter instance is created (or null on destroy). */
62
89
  onScatterReady?: ((scatter: ScatterInstance | null) => void) | undefined;
90
+ /** Rendering backend. Read once on mount — changing after mount has no effect.
91
+ * Default 'webgpu'. */
92
+ backend?: 'webgpu' | 'webgl' | undefined;
63
93
  };
64
94
 
65
95
  const PREVIEW_PHYSICAL_SIZE = 300; // Physical pixels per preview canvas
@@ -76,14 +106,17 @@ export const DtourViewer = ({
76
106
  onStatus,
77
107
  toolbarHeight = 0,
78
108
  onScatterReady,
109
+ backend = 'webgpu',
79
110
  }: DtourViewerProps) => {
80
111
  const containerRef = useRef<HTMLDivElement>(null);
81
112
  const onScatterReadyRef = useRef(onScatterReady);
82
113
  onScatterReadyRef.current = onScatterReady;
114
+ const [scatter, setScatter] = useState<ScatterInstance | null>(null);
83
115
  const scatterRef = useRef<ScatterInstance | null>(null);
84
- const previewCanvasesRef = useRef<HTMLCanvasElement[]>([]);
116
+ const [previewCanvases, setPreviewCanvases] = useState<HTMLCanvasElement[]>([]);
85
117
  const [position, setPosition] = useAtom(tourPositionAtom);
86
118
  const metadata = useAtomValue(metadataAtom);
119
+ const embeddedConfig = useAtomValue(embeddedConfigAtom);
87
120
  const previewCount = useAtomValue(previewCountAtom);
88
121
  const previewScale = useAtomValue(previewScaleAtom);
89
122
  const viewMode = useAtomValue(viewModeAtom);
@@ -91,15 +124,53 @@ export const DtourViewer = ({
91
124
  const setPlaying = useSetAtom(tourPlayingAtom);
92
125
  const setCanvasSize = useSetAtom(canvasSizeAtom);
93
126
  const store = useStore();
127
+ const currentKeyframe = useAtomValue(currentKeyframeAtom);
128
+ const hoveredKeyframe = useAtomValue(hoveredKeyframeAtom);
129
+ const previewCenters = useAtomValue(previewCentersAtom);
94
130
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
95
131
  const activeIndices = useAtomValue(activeIndicesAtom);
96
132
  const setActiveColumns = useSetAtom(activeColumnsAtom);
97
133
  const lastDataRef = useRef<ArrayBuffer | undefined>(undefined);
98
134
  const prevDimCountRef = useRef<number | null>(null);
99
- const dataRef = useRef(data);
100
- dataRef.current = data;
101
135
  const onStatusRef = useRef(onStatus);
102
136
  onStatusRef.current = onStatus;
137
+ const sliderRef = useRef<CircularSliderHandle>(null);
138
+ const axisOverlayRef = useRef<AxisOverlayHandle>(null);
139
+ const positionRef = useRef(position);
140
+
141
+ // Sync positionRef with atom value (overwritten by direct drives within ~33ms)
142
+ useEffect(() => {
143
+ positionRef.current = position;
144
+ }, [position]);
145
+
146
+ // Throttle+debounce atom write — fires at most every 100ms during playback
147
+ // (throttle) AND once more after the last tick (debounce), so Gallery's
148
+ // currentKeyframe highlight updates during playback (~10fps) while also
149
+ // guaranteeing the final position is flushed.
150
+ const positionFlushRef = useRef(throttleAndDebounce((pos: number) => setPosition(pos), 100));
151
+
152
+ const schedulePositionFlush = useCallback(() => {
153
+ positionFlushRef.current(positionRef.current);
154
+ }, []);
155
+
156
+ // Stable ref so the scatter subscribe callback can access the latest flush fn
157
+ const scheduleFlushRef = useRef(schedulePositionFlush);
158
+ scheduleFlushRef.current = schedulePositionFlush;
159
+
160
+ // Delegate playback rAF to the GPU worker
161
+ usePlayback(scatter);
162
+
163
+ // Flush position to atom immediately when playback stops
164
+ const playing = useAtomValue(tourPlayingAtom);
165
+ const prevPlayingRef = useRef(false);
166
+ useEffect(() => {
167
+ if (prevPlayingRef.current && !playing) {
168
+ positionFlushRef.current.cancel();
169
+ positionFlushRef.current.reset();
170
+ setPosition(positionRef.current);
171
+ }
172
+ prevPlayingRef.current = playing;
173
+ }, [playing, setPosition]);
103
174
 
104
175
  const setCurrentBasis = useSetAtom(currentBasisAtom);
105
176
  const tourBy = useAtomValue(tourByAtom);
@@ -108,10 +179,22 @@ export const DtourViewer = ({
108
179
  numDims: number;
109
180
  } | null>(null);
110
181
 
182
+ const showAxes = useAtomValue(showAxesAtom);
183
+ const spacingMode = useAtomValue(sliderSpacingAtom);
184
+ const setArcLengthsAtom_ = useSetAtom(arcLengthsAtom);
111
185
  const isGuidedMode = viewMode === 'guided';
186
+ const frameLoadings = useAtomValue(frameLoadingsAtom);
187
+ const showFrameLoadings = useAtomValue(showFrameLoadingsAtom);
188
+ const loadingsVisible = showFrameLoadings && frameLoadings !== null && frameLoadings.length > 0;
112
189
 
113
190
  // Resolve views (from props or auto-generated) and precompute arc lengths
114
191
  // so we can track the current tour basis on the main thread.
192
+ // Embedded tour views from Parquet metadata (only when nDims matches the dataset)
193
+ const embeddedViews =
194
+ embeddedConfig?.tour && metadata && embeddedConfig.tour.nDims === metadata.dimCount
195
+ ? embeddedConfig.tour.views
196
+ : null;
197
+
115
198
  const { resolvedViews, arcLengths } = useMemo(() => {
116
199
  if (!metadata || metadata.dimCount < 2) return { resolvedViews: null, arcLengths: null };
117
200
  if (activeIndices.length < 2) return { resolvedViews: null, arcLengths: null };
@@ -121,11 +204,49 @@ export const DtourViewer = ({
121
204
  rb = createPCAViews(pcaResult.eigenvectors, dims, pcaResult.numDims, previewCount);
122
205
  } else if (views && views.length > 0) {
123
206
  rb = views.map((b) => new Float32Array(b));
207
+ } else if (!views && embeddedViews) {
208
+ rb = embeddedViews.map((b) => new Float32Array(b));
124
209
  } else {
125
210
  rb = createDefaultViews(dims, previewCount, activeIndices);
126
211
  }
127
212
  return { resolvedViews: rb, arcLengths: computeArcLengths(rb, dims) };
128
- }, [views, metadata, previewCount, activeIndices, tourBy, pcaResult]);
213
+ }, [views, embeddedViews, metadata, previewCount, activeIndices, tourBy, pcaResult]);
214
+
215
+ // Sync arcLengths atom so Gallery and other components can access it
216
+ useEffect(() => {
217
+ setArcLengthsAtom_(arcLengths);
218
+ }, [arcLengths, setArcLengthsAtom_]);
219
+
220
+ // Refs for spacing mode and arcLengths so the scatter subscribe callback
221
+ // (created once in the init effect) can access the latest values.
222
+ const spacingModeRef = useRef(spacingMode);
223
+ spacingModeRef.current = spacingMode;
224
+ const arcLengthsRef = useRef(arcLengths);
225
+ arcLengthsRef.current = arcLengths;
226
+ const resolvedViewsRef = useRef(resolvedViews);
227
+ resolvedViewsRef.current = resolvedViews;
228
+ const metadataRef = useRef(metadata);
229
+ metadataRef.current = metadata;
230
+ // Pre-allocated scratch buffer for imperative basis interpolation
231
+ const basisScratchRef = useRef(new Float32Array(0));
232
+
233
+ // Imperative axis overlay update — compute the interpolated basis at
234
+ // a given tour position and push directly to SVG via setBasis.
235
+ // Called from playbackTick, slider drag, and wheel scrub.
236
+ const updateAxesImperative = useCallback((tourPos: number) => {
237
+ const rv = resolvedViewsRef.current;
238
+ const al = arcLengthsRef.current;
239
+ const meta = metadataRef.current;
240
+ if (!rv || !al || !meta || !axisOverlayRef.current) return;
241
+ const p = meta.dimCount;
242
+ if (basisScratchRef.current.length !== p * 2) {
243
+ basisScratchRef.current = new Float32Array(p * 2);
244
+ }
245
+ interpolateAtPosition(basisScratchRef.current, rv, al, p, tourPos);
246
+ axisOverlayRef.current.setBasis(basisScratchRef.current);
247
+ }, []);
248
+ const updateAxesRef = useRef(updateAxesImperative);
249
+ updateAxesRef.current = updateAxesImperative;
129
250
 
130
251
  // Keep currentBasisAtom in sync with the tour interpolation so other
131
252
  // modes (manual, grand) can initialize from the current projection.
@@ -188,20 +309,20 @@ export const DtourViewer = ({
188
309
  }, [parsedTracks, legendSelection, pointColor, metadata, resolvedTheme]);
189
310
 
190
311
  // Bridge Jotai atoms (style, camera) → scatter instance
191
- useScatter(scatterRef.current);
312
+ useScatter(scatter);
192
313
 
193
314
  const isToolbarVisible = toolbarHeight > 0 && viewMode !== 'grand';
315
+ const effectiveToolbarHeight = isToolbarVisible ? toolbarHeight : 0;
194
316
 
195
317
  // Animate camera inset when the toolbar appears/disappears (grand toggle).
196
318
  // The shader shifts + scales content to center it below the toolbar.
197
319
  // We also track the current pixel offset for positioning overlays.
198
- const [overlayOffsetY, setOverlayOffsetY] = useState(isToolbarVisible ? toolbarHeight / 2 : 0);
320
+ const [overlayOffsetY, setOverlayOffsetY] = useState(isToolbarVisible ? toolbarHeight : 0);
199
321
  const overlayOffsetRef = useRef(overlayOffsetY);
200
322
  overlayOffsetRef.current = overlayOffsetY;
201
323
  const insetAnimRef = useRef<number | null>(null);
202
324
 
203
325
  useEffect(() => {
204
- const scatter = scatterRef.current;
205
326
  if (!scatter || containerSize.height === 0) return;
206
327
 
207
328
  const targetT = viewMode === 'grand' || toolbarHeight === 0 ? 0 : 1;
@@ -209,7 +330,7 @@ export const DtourViewer = ({
209
330
  const t = toolbarHeight;
210
331
 
211
332
  // Current inset factor: derive from current overlayOffsetY via ref
212
- const startT = t > 0 ? overlayOffsetRef.current / (t / 2) : 0;
333
+ const startT = t > 0 ? overlayOffsetRef.current / t : 0;
213
334
  if (Math.abs(startT - targetT) < 0.001) {
214
335
  // Already at target — just ensure shader is in sync
215
336
  const insetOffsetY = (-targetT * t) / h;
@@ -235,7 +356,7 @@ export const DtourViewer = ({
235
356
  scatter.setCamera({ insetOffsetY, insetZoom } as Parameters<typeof scatter.setCamera>[0]);
236
357
 
237
358
  // Overlay pixel offset
238
- setOverlayOffsetY((currentT * t) / 2);
359
+ setOverlayOffsetY(currentT * t);
239
360
 
240
361
  if (progress < 1) {
241
362
  insetAnimRef.current = requestAnimationFrame(tick);
@@ -253,35 +374,43 @@ export const DtourViewer = ({
253
374
  insetAnimRef.current = null;
254
375
  }
255
376
  };
256
- }, [viewMode, toolbarHeight, containerSize.height]);
377
+ }, [scatter, viewMode, toolbarHeight, containerSize.height]);
257
378
 
258
379
  // Grand mode: Givens-rotation grand tour
259
- useGrandTour(scatterRef.current, viewMode, metadata);
380
+ useGrandTour(scatter, viewMode, metadata);
260
381
 
261
382
  // Largest selector diameter that doesn't overlap any gallery preview
262
383
  const selectorSize = useMemo(
263
384
  () =>
264
385
  computeSelectorSize(
265
386
  containerSize.width,
266
- containerSize.height,
387
+ containerSize.height - effectiveToolbarHeight,
267
388
  previewCount,
268
- isToolbarVisible,
389
+ 0,
269
390
  SELECTOR_PADDING,
270
391
  previewScale,
271
392
  coloredTracks.length,
393
+ loadingsVisible,
272
394
  ),
273
395
  [
274
396
  containerSize.width,
275
397
  containerSize.height,
276
398
  previewCount,
277
- isToolbarVisible,
399
+ effectiveToolbarHeight,
278
400
  previewScale,
279
401
  coloredTracks.length,
402
+ loadingsVisible,
280
403
  ],
281
404
  );
282
405
 
283
- // Initialize scatter — create main + preview canvases imperatively
284
- // (transferControlToOffscreen can only be called once per canvas).
406
+ // Effect AScatter lifecycle: create main canvas + scatter instance.
407
+ // Runs once on mount, cleans up on unmount. No dependencies — backend is
408
+ // a static construction prop, store and setCanvasSize are stable singletons.
409
+ // NOTE: This effect is NOT StrictMode-safe. transferControlToOffscreen()
410
+ // and ArrayBuffer transfers are one-shot ownership operations that cannot
411
+ // survive StrictMode's mount→cleanup→remount cycle. Consumers must either
412
+ // avoid StrictMode or accept a one-time dev-mode data copy.
413
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty — all captured values are stable or refs
285
414
  useEffect(() => {
286
415
  const container = containerRef.current;
287
416
  if (!container) return;
@@ -298,63 +427,101 @@ export const DtourViewer = ({
298
427
  mainCanvas.style.display = 'block';
299
428
  container.prepend(mainCanvas);
300
429
 
301
- // Preview canvases one per tour keyframe view
302
- const previews: HTMLCanvasElement[] = [];
303
- for (let i = 0; i < previewCount; i++) {
304
- const c = document.createElement('canvas');
305
- c.width = PREVIEW_PHYSICAL_SIZE;
306
- c.height = PREVIEW_PHYSICAL_SIZE;
307
- c.style.width = '100%';
308
- c.style.height = '100%';
309
- c.style.display = 'block';
310
- c.style.borderRadius = '2px';
311
- previews.push(c);
312
- }
313
- previewCanvasesRef.current = previews;
314
-
315
- const scatter = createScatter({
316
- canvases: [mainCanvas, ...previews],
430
+ const factory = backend === 'webgl' ? createScatterWebGL : createScatter;
431
+ const instance = factory({
432
+ canvas: mainCanvas,
317
433
  zoom: store.get(cameraZoomAtom),
318
434
  });
319
- scatterRef.current = scatter;
320
- onScatterReadyRef.current?.(scatter);
435
+ scatterRef.current = instance;
436
+ setScatter(instance);
437
+ onScatterReadyRef.current?.(instance);
438
+ // Expose scatter instance for dev tools and benchmark automation
439
+ if (import.meta.env.DEV || (globalThis as Record<string, unknown>).__dtourBenchmarkMode) {
440
+ (globalThis as Record<string, unknown>).scatter = instance;
441
+ }
321
442
 
322
- scatter.subscribe((s: ScatterStatus) => {
443
+ instance.subscribe((s: ScatterStatus) => {
323
444
  onStatusRef.current?.(s);
324
445
  if (s.type === 'pcaResult') {
325
446
  setPcaResult({ eigenvectors: s.eigenvectors, numDims: s.numDims });
326
447
  }
448
+ if (s.type === 'metadata') {
449
+ // Data reload: worker resets 3D state, so clear viewer-side refs too
450
+ if (is3dEnabledRef.current) {
451
+ is3dEnabledRef.current = false;
452
+ quatRef.current = IDENTITY_QUAT;
453
+ residualPCRef.current = null;
454
+ axisOverlayRef.current?.clearRotation3d();
455
+ store.set(is3dRotatedAtom, false);
456
+ }
457
+ }
458
+ if (s.type === 'residualPC') {
459
+ residualPCRef.current = s.residualPC;
460
+ // If already rotating, immediately update axis overlay so the first
461
+ // drag frame isn't missed while waiting for the next pointermove.
462
+ if (!isIdentityQuat(quatRef.current)) {
463
+ axisOverlayRef.current?.setRotation3d(s.residualPC, quatToMat3(quatRef.current));
464
+ }
465
+ }
466
+ if (s.type === 'playbackTick') {
467
+ positionRef.current = s.position;
468
+ const al = arcLengthsRef.current;
469
+ const visual =
470
+ spacingModeRef.current === 'equal' && al ? tourToVisual(s.position, al) : s.position;
471
+ sliderRef.current?.setPosition(visual);
472
+ scheduleFlushRef.current();
473
+ updateAxesRef.current(s.position);
474
+ }
327
475
  });
328
476
 
329
477
  const ro = new ResizeObserver(([entry]) => {
330
478
  if (!entry) return;
331
479
  const { width, height } = entry.contentRect;
332
480
  const curDpr = window.devicePixelRatio || 1;
333
- scatter.resize(0, Math.round(width * curDpr), Math.round(height * curDpr), curDpr);
481
+ instance.resize(0, Math.round(width * curDpr), Math.round(height * curDpr), curDpr);
334
482
  setContainerSize({ width, height });
335
483
  setCanvasSize({ width, height });
336
484
  });
337
485
  ro.observe(container);
338
486
 
339
- // Re-send data to the new scatter instance (e.g. after previewCount change
340
- // or HMR where the scatter is recreated but data hasn't changed).
341
- if (dataRef.current) {
342
- scatter.loadData(dataRef.current.slice(0));
343
- lastDataRef.current = dataRef.current;
344
- } else {
345
- lastDataRef.current = undefined;
346
- }
347
-
348
487
  return () => {
349
488
  ro.disconnect();
350
- scatter.destroy();
489
+ instance.destroy();
351
490
  scatterRef.current = null;
491
+ setScatter(null);
352
492
  onScatterReadyRef.current?.(null);
353
493
  mainCanvas.remove();
354
- for (const c of previews) c.remove();
355
- previewCanvasesRef.current = [];
356
494
  };
357
- }, [previewCount, setCanvasSize, store]);
495
+ }, []);
496
+
497
+ // Effect B — Preview canvas lifecycle: add/remove preview canvases dynamically.
498
+ // Runs when scatter instance or previewCount changes.
499
+ useEffect(() => {
500
+ if (!scatter) return;
501
+
502
+ const previews: HTMLCanvasElement[] = [];
503
+ for (let i = 0; i < previewCount; i++) {
504
+ const c = document.createElement('canvas');
505
+ c.width = PREVIEW_PHYSICAL_SIZE;
506
+ c.height = PREVIEW_PHYSICAL_SIZE;
507
+ c.style.width = '100%';
508
+ c.style.height = '100%';
509
+ c.style.display = 'block';
510
+ c.style.borderRadius = '2px';
511
+ previews.push(c);
512
+ scatter.addPreviewCanvas(i, c);
513
+ }
514
+ setPreviewCanvases(previews);
515
+ scatter.render();
516
+
517
+ return () => {
518
+ for (let i = 0; i < previews.length; i++) {
519
+ scatter.removePreviewCanvas(i);
520
+ previews[i]!.remove();
521
+ }
522
+ setPreviewCanvases([]);
523
+ };
524
+ }, [scatter, previewCount]);
358
525
 
359
526
  // Reset active columns and PCA results when a new dataset loads (different dim count)
360
527
  useEffect(() => {
@@ -368,20 +535,20 @@ export const DtourViewer = ({
368
535
 
369
536
  // Send data when it changes
370
537
  useEffect(() => {
371
- if (!data || !scatterRef.current || data === lastDataRef.current) return;
538
+ if (!data || !scatter || data === lastDataRef.current) return;
539
+ if (data.byteLength === 0) return; // already transferred (detached)
372
540
  lastDataRef.current = data;
373
- scatterRef.current.loadData(data.slice(0));
374
- }, [data]);
541
+ scatter.loadData(data);
542
+ }, [data, scatter]);
375
543
 
376
544
  // Trigger PCA computation when tourBy is 'pca' and data is loaded
377
545
  useEffect(() => {
378
- if (tourBy !== 'pca' || !metadata || metadata.dimCount < 2 || !scatterRef.current) return;
379
- scatterRef.current.computePCA();
380
- }, [tourBy, metadata]);
546
+ if (tourBy !== 'pca' || !metadata || metadata.dimCount < 2 || !scatter) return;
547
+ scatter.computePCA();
548
+ }, [tourBy, metadata, scatter]);
381
549
 
382
- // Set views when available (from props, PCA, or auto-generated from metadata)
550
+ // Set views when available (from props, PCA, embedded, or auto-generated from metadata)
383
551
  useEffect(() => {
384
- const scatter = scatterRef.current;
385
552
  if (!scatter) return;
386
553
  if (tourBy === 'pca' && pcaResult && pcaResult.eigenvectors.length >= 2 && metadata) {
387
554
  const pcaBases = createPCAViews(
@@ -393,6 +560,8 @@ export const DtourViewer = ({
393
560
  scatter.setBases(pcaBases);
394
561
  } else if (views && views.length > 0) {
395
562
  scatter.setBases(views.map((b) => new Float32Array(b)));
563
+ } else if (!views && embeddedViews) {
564
+ scatter.setBases(embeddedViews.map((b) => new Float32Array(b)));
396
565
  } else if (metadata && metadata.dimCount >= 2 && activeIndices.length >= 2) {
397
566
  const defaultViews = createDefaultViews(metadata.dimCount, previewCount, activeIndices);
398
567
  scatter.setBases(defaultViews);
@@ -400,18 +569,21 @@ export const DtourViewer = ({
400
569
  // Safety: explicitly request a full re-render after views are set,
401
570
  // ensuring all preview canvases get painted even if messages race.
402
571
  scatter.render();
403
- }, [views, metadata, previewCount, activeIndices, tourBy, pcaResult]);
572
+ }, [scatter, views, embeddedViews, metadata, previewCount, activeIndices, tourBy, pcaResult]);
404
573
 
405
574
  const { animateTo, cancelAnimation } = useAnimatePosition();
406
575
 
407
- // Slider click → animated seek to the clicked position
576
+ // Slider click → animated seek to the clicked position.
577
+ // The slider reports a visual position; convert to tour position for the GPU.
408
578
  const handlePositionSeek = useCallback(
409
- (pos: number) => {
579
+ (visualPos: number) => {
410
580
  setGuidedSuspended(false);
411
581
  setPlaying(false);
412
- animateTo(pos);
582
+ const tourPos =
583
+ spacingMode === 'equal' && arcLengths ? visualToTour(visualPos, arcLengths) : visualPos;
584
+ animateTo(tourPos);
413
585
  },
414
- [setGuidedSuspended, setPlaying, animateTo],
586
+ [setGuidedSuspended, setPlaying, animateTo, spacingMode, arcLengths],
415
587
  );
416
588
 
417
589
  // Slider drag start → cancel animation, switch to immediate updates
@@ -420,13 +592,21 @@ export const DtourViewer = ({
420
592
  setGuidedSuspended(false);
421
593
  }, [cancelAnimation, setGuidedSuspended]);
422
594
 
423
- // Slider drag move → immediate position update
595
+ // Slider drag move → send directly to GPU, update slider imperatively,
596
+ // debounce atom write to minimize React re-renders during drag.
597
+ // The slider reports a visual position; convert to tour position for the GPU.
424
598
  const handlePositionChange = useCallback(
425
- (pos: number) => {
599
+ (visualPos: number) => {
426
600
  setGuidedSuspended(false);
427
- setPosition(pos);
601
+ const tourPos =
602
+ spacingMode === 'equal' && arcLengths ? visualToTour(visualPos, arcLengths) : visualPos;
603
+ scatterRef.current?.setTourPosition(tourPos);
604
+ sliderRef.current?.setPosition(visualPos);
605
+ positionRef.current = tourPos;
606
+ updateAxesImperative(tourPos);
607
+ schedulePositionFlush();
428
608
  },
429
- [setPosition, setGuidedSuspended],
609
+ [setGuidedSuspended, schedulePositionFlush, spacingMode, arcLengths, updateAxesImperative],
430
610
  );
431
611
 
432
612
  // Wheel → scrub tour position (guided mode) or zoom (Shift+wheel, all modes).
@@ -452,17 +632,202 @@ export const DtourViewer = ({
452
632
  store.set(animationGenAtom, (g) => g + 1);
453
633
  store.set(tourPlayingAtom, false);
454
634
  store.set(guidedSuspendedAtom, false);
455
- store.set(tourPositionAtom, (prev) => {
456
- let next = prev + e.deltaY * 0.002;
635
+ // Send directly to GPU + slider, debounce atom write.
636
+ // In equal mode, scrub in visual space for perceptual uniformity.
637
+ const al = arcLengthsRef.current;
638
+ const mode = spacingModeRef.current;
639
+ if (mode === 'equal' && al) {
640
+ const curVisual = tourToVisual(positionRef.current, al);
641
+ let nextVisual = curVisual + e.deltaY * 0.002;
642
+ nextVisual = nextVisual - Math.floor(nextVisual);
643
+ const nextTour = visualToTour(nextVisual, al);
644
+ positionRef.current = nextTour;
645
+ scatterRef.current?.setTourPosition(nextTour);
646
+ sliderRef.current?.setPosition(nextVisual);
647
+ updateAxesRef.current(nextTour);
648
+ } else {
649
+ let next = positionRef.current + e.deltaY * 0.002;
457
650
  next = next - Math.floor(next);
458
- return next;
459
- });
651
+ positionRef.current = next;
652
+ scatterRef.current?.setTourPosition(next);
653
+ sliderRef.current?.setPosition(next);
654
+ updateAxesRef.current(next);
655
+ }
656
+ scheduleFlushRef.current();
460
657
  };
461
658
  container.addEventListener('wheel', handler, { passive: false });
462
659
  return () => container.removeEventListener('wheel', handler);
463
660
  }, [store]);
464
661
 
465
- const tickCount = views?.length ?? previewCount;
662
+ // ─── 3D camera rotation (manual mode only) ──────────────────────────────
663
+ const setIs3dRotated = useSetAtom(is3dRotatedAtom);
664
+ const is3dRotated = useAtomValue(is3dRotatedAtom);
665
+ const quatRef = useRef<Quat>(IDENTITY_QUAT);
666
+ const is3dEnabledRef = useRef(false);
667
+ const revertAnimRef = useRef<number | null>(null);
668
+ const residualPCRef = useRef<Float32Array | null>(null);
669
+ const backendRef = useRef(backend);
670
+ backendRef.current = backend;
671
+ const effectiveToolbarHeightRef = useRef(effectiveToolbarHeight);
672
+ effectiveToolbarHeightRef.current = effectiveToolbarHeight;
673
+
674
+ // Shift+drag arcball rotation
675
+ useEffect(() => {
676
+ const container = containerRef.current;
677
+ if (!container) return;
678
+
679
+ let dragging = false;
680
+ let lastSphere: [number, number, number] | null = null;
681
+
682
+ const toNdc = (e: PointerEvent): [number, number] => {
683
+ const rect = container.getBoundingClientRect();
684
+ // Map relative to the visible content area (below toolbar)
685
+ const t = effectiveToolbarHeightRef.current;
686
+ const visibleH = rect.height - t;
687
+ const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
688
+ const y = -(((e.clientY - rect.top - t) / visibleH) * 2 - 1);
689
+ return [x, y];
690
+ };
691
+
692
+ const onDown = (e: PointerEvent) => {
693
+ if (e.button !== 0) return;
694
+ // Shift+drag to enter 3D; once active, plain drag also rotates
695
+ if (!e.shiftKey && !store.get(is3dRotatedAtom)) return;
696
+ if (store.get(viewModeAtom) !== 'manual') return;
697
+ if (!store.get(metadataAtom)) return; // data not loaded yet
698
+ if (backendRef.current !== 'webgpu') return; // 3D requires WebGPU
699
+ // Let clicks on buttons (revert, toolbar, etc.) pass through
700
+ if ((e.target as Element).closest('button, a')) return;
701
+ e.preventDefault();
702
+ e.stopPropagation();
703
+ dragging = true;
704
+ container.setPointerCapture(e.pointerId);
705
+ const [nx, ny] = toNdc(e);
706
+ lastSphere = projectToSphere(nx, ny);
707
+
708
+ // Enable 3D on first rotation
709
+ if (!is3dEnabledRef.current) {
710
+ scatterRef.current?.enable3d();
711
+ is3dEnabledRef.current = true;
712
+ }
713
+ };
714
+
715
+ const onMove = (e: PointerEvent) => {
716
+ if (!dragging || !lastSphere) return;
717
+ e.preventDefault();
718
+ const [nx, ny] = toNdc(e);
719
+ const curSphere = projectToSphere(nx, ny);
720
+ const delta = arcballQuat(lastSphere, curSphere);
721
+ quatRef.current = multiplyQuat(delta, quatRef.current);
722
+ lastSphere = curSphere;
723
+
724
+ const mat = quatToMat3(quatRef.current);
725
+ scatterRef.current?.set3dRotation(mat);
726
+
727
+ if (residualPCRef.current) {
728
+ axisOverlayRef.current?.setRotation3d(residualPCRef.current, mat);
729
+ }
730
+
731
+ if (!store.get(is3dRotatedAtom)) {
732
+ store.set(is3dRotatedAtom, true);
733
+ }
734
+ };
735
+
736
+ const endDrag = () => {
737
+ dragging = false;
738
+ lastSphere = null;
739
+ };
740
+
741
+ const onUp = (e: PointerEvent) => {
742
+ if (!dragging) return;
743
+ endDrag();
744
+ container.releasePointerCapture(e.pointerId);
745
+ };
746
+
747
+ container.addEventListener('pointerdown', onDown);
748
+ container.addEventListener('pointermove', onMove);
749
+ container.addEventListener('pointerup', onUp);
750
+ container.addEventListener('pointercancel', endDrag);
751
+ container.addEventListener('lostpointercapture', endDrag);
752
+ return () => {
753
+ container.removeEventListener('pointerdown', onDown);
754
+ container.removeEventListener('pointermove', onMove);
755
+ container.removeEventListener('pointerup', onUp);
756
+ container.removeEventListener('pointercancel', endDrag);
757
+ container.removeEventListener('lostpointercapture', endDrag);
758
+ };
759
+ }, [store]);
760
+
761
+ // Slerp revert animation
762
+ const revertCamera = useCallback(() => {
763
+ if (revertAnimRef.current !== null) cancelAnimationFrame(revertAnimRef.current);
764
+ const startQuat: Quat = [...quatRef.current];
765
+ if (isIdentityQuat(startQuat)) {
766
+ // Already at identity — just disable
767
+ scatterRef.current?.disable3d();
768
+ is3dEnabledRef.current = false;
769
+ quatRef.current = IDENTITY_QUAT;
770
+ residualPCRef.current = null;
771
+ axisOverlayRef.current?.clearRotation3d();
772
+ setIs3dRotated(false);
773
+ return;
774
+ }
775
+ const duration = 300;
776
+ const startTime = performance.now();
777
+ const tick = (now: number) => {
778
+ const elapsed = now - startTime;
779
+ const progress = Math.min(1, elapsed / duration);
780
+ // Ease-out cubic
781
+ const eased = 1 - (1 - progress) ** 3;
782
+ const q = slerp(startQuat, IDENTITY_QUAT, eased);
783
+ quatRef.current = q;
784
+ const mat = quatToMat3(q);
785
+ scatterRef.current?.set3dRotation(mat);
786
+ if (residualPCRef.current) {
787
+ axisOverlayRef.current?.setRotation3d(residualPCRef.current, mat);
788
+ }
789
+ if (progress < 1) {
790
+ revertAnimRef.current = requestAnimationFrame(tick);
791
+ } else {
792
+ revertAnimRef.current = null;
793
+ quatRef.current = IDENTITY_QUAT;
794
+ scatterRef.current?.disable3d();
795
+ is3dEnabledRef.current = false;
796
+ residualPCRef.current = null;
797
+ axisOverlayRef.current?.clearRotation3d();
798
+ setIs3dRotated(false);
799
+ }
800
+ };
801
+ revertAnimRef.current = requestAnimationFrame(tick);
802
+ }, [setIs3dRotated]);
803
+
804
+ // Escape key to revert
805
+ useEffect(() => {
806
+ if (!is3dRotated) return;
807
+ const handler = (e: KeyboardEvent) => {
808
+ if (e.key === 'Escape') {
809
+ e.preventDefault();
810
+ revertCamera();
811
+ }
812
+ };
813
+ window.addEventListener('keydown', handler);
814
+ return () => window.removeEventListener('keydown', handler);
815
+ }, [is3dRotated, revertCamera]);
816
+
817
+ // Reset 3D state when leaving manual mode
818
+ useEffect(() => {
819
+ if (viewMode === 'manual') return;
820
+ if (is3dEnabledRef.current) {
821
+ scatterRef.current?.disable3d();
822
+ is3dEnabledRef.current = false;
823
+ quatRef.current = IDENTITY_QUAT;
824
+ residualPCRef.current = null;
825
+ axisOverlayRef.current?.clearRotation3d();
826
+ setIs3dRotated(false);
827
+ }
828
+ }, [viewMode, setIs3dRotated]);
829
+
830
+ const tickCount = views?.length ?? embeddedViews?.length ?? previewCount;
466
831
  const hasData = !!data && !!metadata;
467
832
 
468
833
  if (import.meta.env.DEV && views && views.length !== previewCount) {
@@ -471,42 +836,45 @@ export const DtourViewer = ({
471
836
  );
472
837
  }
473
838
 
839
+ const overlayHeight = containerSize.height - overlayOffsetY;
840
+
474
841
  return (
475
842
  <div ref={containerRef} className="w-full h-full relative bg-dtour-bg">
476
- {/* Overlay wrapper — translateY matches the shader inset so overlays
477
- stay visually centered in the area below the toolbar. */}
478
- <div className="absolute inset-0" style={{ transform: `translateY(${overlayOffsetY}px)` }}>
843
+ {/* Overlay wrapper — positioned below the toolbar so overlays
844
+ are visually centered in the area below the toolbar. */}
845
+ <div className="absolute left-0 right-0 bottom-0" style={{ top: `${overlayOffsetY}px` }}>
479
846
  {/* Preview gallery — only in guided mode */}
480
- {isGuidedMode &&
481
- hasData &&
482
- containerSize.width > 0 &&
483
- previewCanvasesRef.current.length > 0 && (
484
- <Gallery
485
- previewCanvases={previewCanvasesRef.current}
486
- containerWidth={containerSize.width}
487
- containerHeight={containerSize.height}
488
- isToolbarVisible={isToolbarVisible}
489
- />
490
- )}
491
-
492
- {/* Lasso selection overlay — available in all modes, below circular selector */}
493
- {hasData && containerSize.width > 0 && (
494
- <LassoOverlay
495
- scatter={scatterRef.current}
496
- width={containerSize.width}
497
- height={containerSize.height}
847
+ {isGuidedMode && hasData && containerSize.width > 0 && previewCanvases.length > 0 && (
848
+ <Gallery
849
+ previewCanvases={previewCanvases}
850
+ containerWidth={containerSize.width}
851
+ containerHeight={overlayHeight}
852
+ toolbarHeight={0}
498
853
  />
499
854
  )}
500
855
 
501
- {/* Manual mode axis overlay — rendered after lasso so handles are on top */}
502
- {viewMode === 'manual' && hasData && containerSize.width > 0 && (
503
- <AxisOverlay
504
- scatter={scatterRef.current}
505
- width={containerSize.width}
506
- height={containerSize.height}
507
- />
856
+ {/* Lasso selection overlay — available in all modes (disabled during 3D rotation) */}
857
+ {hasData && containerSize.width > 0 && !is3dRotated && (
858
+ <LassoOverlay scatter={scatter} width={containerSize.width} height={overlayHeight} />
508
859
  )}
509
860
 
861
+ {/* Axis overlay — interactive in manual mode (disabled during 3D rotation),
862
+ read-only in guided when enabled */}
863
+ {(viewMode === 'manual' || (isGuidedMode && showAxes)) &&
864
+ hasData &&
865
+ containerSize.width > 0 && (
866
+ <AxisOverlay
867
+ ref={axisOverlayRef}
868
+ scatter={scatter}
869
+ width={containerSize.width}
870
+ height={overlayHeight}
871
+ readOnly={isGuidedMode || is3dRotated}
872
+ />
873
+ )}
874
+
875
+ {/* Revert camera button — shown when 3D camera is rotated in manual mode */}
876
+ {viewMode === 'manual' && <RevertCameraButton onRevert={revertCamera} />}
877
+
510
878
  {/* Circular selector + radial chart overlay — only in guided mode, above lasso */}
511
879
  {isGuidedMode && hasData && (
512
880
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
@@ -519,18 +887,30 @@ export const DtourViewer = ({
519
887
  position={position}
520
888
  size={selectorSize}
521
889
  innerRadius={selectorSize * 0.4}
890
+ arcLengths={arcLengths}
891
+ spacingMode={spacingMode}
522
892
  />
523
893
  </div>
524
894
  )}
525
895
  {/* Selector — on top for drag interaction */}
526
896
  <div className="pointer-events-none relative z-10">
527
897
  <CircularSlider
528
- value={position}
898
+ ref={sliderRef}
899
+ value={
900
+ spacingMode === 'equal' && arcLengths
901
+ ? tourToVisual(position, arcLengths)
902
+ : position
903
+ }
529
904
  onChange={handlePositionChange}
530
905
  onSeek={handlePositionSeek}
531
906
  onDragStart={handleDragStart}
532
907
  tickCount={tickCount}
533
908
  size={selectorSize}
909
+ arcLengths={arcLengths}
910
+ spacingMode={spacingMode}
911
+ currentKeyframe={currentKeyframe}
912
+ hoveredKeyframe={hoveredKeyframe}
913
+ previewCenters={previewCenters}
534
914
  />
535
915
  </div>
536
916
  </div>