@dtour/viewer 0.1.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 (114) hide show
  1. package/dist/Dtour.d.ts +46 -0
  2. package/dist/Dtour.d.ts.map +1 -0
  3. package/dist/DtourViewer.d.ts +24 -0
  4. package/dist/DtourViewer.d.ts.map +1 -0
  5. package/dist/components/AxisOverlay.d.ts +9 -0
  6. package/dist/components/AxisOverlay.d.ts.map +1 -0
  7. package/dist/components/CircularSlider.d.ts +16 -0
  8. package/dist/components/CircularSlider.d.ts.map +1 -0
  9. package/dist/components/ColorLegend.d.ts +2 -0
  10. package/dist/components/ColorLegend.d.ts.map +1 -0
  11. package/dist/components/DtourToolbar.d.ts +5 -0
  12. package/dist/components/DtourToolbar.d.ts.map +1 -0
  13. package/dist/components/Gallery.d.ts +12 -0
  14. package/dist/components/Gallery.d.ts.map +1 -0
  15. package/dist/components/LassoOverlay.d.ts +9 -0
  16. package/dist/components/LassoOverlay.d.ts.map +1 -0
  17. package/dist/components/Logo.d.ts +2 -0
  18. package/dist/components/Logo.d.ts.map +1 -0
  19. package/dist/components/ui/button.d.ts +12 -0
  20. package/dist/components/ui/button.d.ts.map +1 -0
  21. package/dist/components/ui/dropdown-menu.d.ts +10 -0
  22. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  23. package/dist/components/ui/slider.d.ts +6 -0
  24. package/dist/components/ui/slider.d.ts.map +1 -0
  25. package/dist/components/ui/tooltip.d.ts +8 -0
  26. package/dist/components/ui/tooltip.d.ts.map +1 -0
  27. package/dist/hooks/useAnimatePosition.d.ts +13 -0
  28. package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
  29. package/dist/hooks/useGrandTour.d.ts +14 -0
  30. package/dist/hooks/useGrandTour.d.ts.map +1 -0
  31. package/dist/hooks/useLongPressIndicator.d.ts +5 -0
  32. package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
  33. package/dist/hooks/useModeCycling.d.ts +12 -0
  34. package/dist/hooks/useModeCycling.d.ts.map +1 -0
  35. package/dist/hooks/usePlayback.d.ts +9 -0
  36. package/dist/hooks/usePlayback.d.ts.map +1 -0
  37. package/dist/hooks/useScatter.d.ts +10 -0
  38. package/dist/hooks/useScatter.d.ts.map +1 -0
  39. package/dist/hooks/useSystemTheme.d.ts +6 -0
  40. package/dist/hooks/useSystemTheme.d.ts.map +1 -0
  41. package/dist/index.d.ts +16 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/layout/gallery-positions.d.ts +38 -0
  44. package/dist/layout/gallery-positions.d.ts.map +1 -0
  45. package/dist/layout/selector-size.d.ts +15 -0
  46. package/dist/layout/selector-size.d.ts.map +1 -0
  47. package/dist/lib/color-utils.d.ts +7 -0
  48. package/dist/lib/color-utils.d.ts.map +1 -0
  49. package/dist/lib/gram-schmidt.d.ts +9 -0
  50. package/dist/lib/gram-schmidt.d.ts.map +1 -0
  51. package/dist/lib/utils.d.ts +3 -0
  52. package/dist/lib/utils.d.ts.map +1 -0
  53. package/dist/portal-container.d.ts +10 -0
  54. package/dist/portal-container.d.ts.map +1 -0
  55. package/dist/radial-chart/RadialChart.d.ts +13 -0
  56. package/dist/radial-chart/RadialChart.d.ts.map +1 -0
  57. package/dist/radial-chart/arc-path.d.ts +23 -0
  58. package/dist/radial-chart/arc-path.d.ts.map +1 -0
  59. package/dist/radial-chart/index.d.ts +5 -0
  60. package/dist/radial-chart/index.d.ts.map +1 -0
  61. package/dist/radial-chart/parse-metrics.d.ts +10 -0
  62. package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
  63. package/dist/radial-chart/types.d.ts +23 -0
  64. package/dist/radial-chart/types.d.ts.map +1 -0
  65. package/dist/spec.d.ts +42 -0
  66. package/dist/spec.d.ts.map +1 -0
  67. package/dist/state/atoms.d.ts +150 -0
  68. package/dist/state/atoms.d.ts.map +1 -0
  69. package/dist/state/spec-sync.d.ts +5 -0
  70. package/dist/state/spec-sync.d.ts.map +1 -0
  71. package/dist/viewer.css +3 -0
  72. package/dist/viewer.js +14501 -0
  73. package/dist/views.d.ts +30 -0
  74. package/dist/views.d.ts.map +1 -0
  75. package/package.json +48 -0
  76. package/src/Dtour.tsx +300 -0
  77. package/src/DtourViewer.tsx +541 -0
  78. package/src/components/AxisOverlay.tsx +224 -0
  79. package/src/components/CircularSlider.tsx +202 -0
  80. package/src/components/ColorLegend.tsx +178 -0
  81. package/src/components/DtourToolbar.tsx +642 -0
  82. package/src/components/Gallery.tsx +166 -0
  83. package/src/components/LassoOverlay.tsx +240 -0
  84. package/src/components/Logo.tsx +37 -0
  85. package/src/components/ui/button.tsx +36 -0
  86. package/src/components/ui/dropdown-menu.tsx +92 -0
  87. package/src/components/ui/slider.tsx +89 -0
  88. package/src/components/ui/tooltip.tsx +45 -0
  89. package/src/hooks/useAnimatePosition.ts +102 -0
  90. package/src/hooks/useGrandTour.ts +176 -0
  91. package/src/hooks/useLongPressIndicator.ts +342 -0
  92. package/src/hooks/useModeCycling.ts +64 -0
  93. package/src/hooks/usePlayback.ts +54 -0
  94. package/src/hooks/useScatter.ts +162 -0
  95. package/src/hooks/useSystemTheme.ts +19 -0
  96. package/src/index.ts +55 -0
  97. package/src/layout/gallery-positions.ts +105 -0
  98. package/src/layout/selector-size.ts +135 -0
  99. package/src/lib/color-utils.ts +22 -0
  100. package/src/lib/gram-schmidt.ts +41 -0
  101. package/src/lib/utils.ts +4 -0
  102. package/src/portal-container.tsx +14 -0
  103. package/src/radial-chart/RadialChart.tsx +184 -0
  104. package/src/radial-chart/arc-path.ts +80 -0
  105. package/src/radial-chart/index.ts +4 -0
  106. package/src/radial-chart/parse-metrics.ts +99 -0
  107. package/src/radial-chart/types.ts +23 -0
  108. package/src/spec.ts +48 -0
  109. package/src/state/atoms.ts +169 -0
  110. package/src/state/spec-sync.ts +190 -0
  111. package/src/styles.css +44 -0
  112. package/src/views.ts +76 -0
  113. package/tsconfig.json +12 -0
  114. package/vite.config.ts +21 -0
@@ -0,0 +1,541 @@
1
+ import {
2
+ GLASBEY_DARK,
3
+ GLASBEY_LIGHT,
4
+ OKABE_ITO,
5
+ computeArcLengths,
6
+ createScatter,
7
+ interpolateAtPosition,
8
+ } from '@dtour/scatter';
9
+ import type { ScatterInstance, ScatterStatus } from '@dtour/scatter';
10
+ import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { AxisOverlay } from './components/AxisOverlay.tsx';
13
+ import { CircularSlider } from './components/CircularSlider.tsx';
14
+ import { Gallery } from './components/Gallery.tsx';
15
+ import { LassoOverlay } from './components/LassoOverlay.tsx';
16
+ import { useAnimatePosition } from './hooks/useAnimatePosition.ts';
17
+ import { useGrandTour } from './hooks/useGrandTour.ts';
18
+ import { useScatter } from './hooks/useScatter.ts';
19
+ import { computeSelectorSize } from './layout/selector-size.ts';
20
+ import { RadialChart } from './radial-chart/RadialChart.tsx';
21
+ import { parseMetrics } from './radial-chart/parse-metrics.ts';
22
+ import type { RadialTrackConfig } from './radial-chart/types.ts';
23
+ import {
24
+ activeColumnsAtom,
25
+ activeIndicesAtom,
26
+ animationGenAtom,
27
+ cameraZoomAtom,
28
+ canvasSizeAtom,
29
+ currentBasisAtom,
30
+ guidedSuspendedAtom,
31
+ legendSelectionAtom,
32
+ metadataAtom,
33
+ pointColorAtom,
34
+ previewCountAtom,
35
+ previewScaleAtom,
36
+ resolvedThemeAtom,
37
+ tourByAtom,
38
+ tourPlayingAtom,
39
+ tourPositionAtom,
40
+ viewModeAtom,
41
+ } from './state/atoms.ts';
42
+ import { createDefaultViews, createPCAViews } from './views.ts';
43
+
44
+ export type DtourViewerProps = {
45
+ /** Arrow IPC or Parquet ArrayBuffer. Ownership is transferred on load. */
46
+ data?: ArrayBuffer | undefined;
47
+ /** Tour keyframe views (p×2 column-major). Auto-generated if omitted. */
48
+ views?: Float32Array[] | undefined;
49
+ /** Arrow IPC ArrayBuffer with per-view quality metrics. */
50
+ metrics?: ArrayBuffer | undefined;
51
+ /** Track configuration for radial bar charts. */
52
+ metricTracks?: RadialTrackConfig[] | undefined;
53
+ /** Global bar width override for radial charts ('full' or px). */
54
+ metricBarWidth?: 'full' | number | undefined;
55
+ /** Called on every status event from the renderer. */
56
+ onStatus?: ((status: ScatterStatus) => void) | undefined;
57
+ /** Height in px of an overlay toolbar above the canvas. The shader shifts
58
+ * and scales content to center it in the visible area below the toolbar.
59
+ * Animates smoothly to 0 in zen mode. Default 0. */
60
+ toolbarHeight?: number | undefined;
61
+ /** Called when the scatter instance is created (or null on destroy). */
62
+ onScatterReady?: ((scatter: ScatterInstance | null) => void) | undefined;
63
+ };
64
+
65
+ const PREVIEW_PHYSICAL_SIZE = 300; // Physical pixels per preview canvas
66
+
67
+ const INSET_ANIMATION_MS = 300;
68
+ const SELECTOR_PADDING = 16;
69
+
70
+ export const DtourViewer = ({
71
+ data,
72
+ views,
73
+ metrics,
74
+ metricTracks,
75
+ metricBarWidth,
76
+ onStatus,
77
+ toolbarHeight = 0,
78
+ onScatterReady,
79
+ }: DtourViewerProps) => {
80
+ const containerRef = useRef<HTMLDivElement>(null);
81
+ const onScatterReadyRef = useRef(onScatterReady);
82
+ onScatterReadyRef.current = onScatterReady;
83
+ const scatterRef = useRef<ScatterInstance | null>(null);
84
+ const previewCanvasesRef = useRef<HTMLCanvasElement[]>([]);
85
+ const [position, setPosition] = useAtom(tourPositionAtom);
86
+ const metadata = useAtomValue(metadataAtom);
87
+ const previewCount = useAtomValue(previewCountAtom);
88
+ const previewScale = useAtomValue(previewScaleAtom);
89
+ const viewMode = useAtomValue(viewModeAtom);
90
+ const [guidedSuspended, setGuidedSuspended] = useAtom(guidedSuspendedAtom);
91
+ const setPlaying = useSetAtom(tourPlayingAtom);
92
+ const setCanvasSize = useSetAtom(canvasSizeAtom);
93
+ const store = useStore();
94
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
95
+ const activeIndices = useAtomValue(activeIndicesAtom);
96
+ const setActiveColumns = useSetAtom(activeColumnsAtom);
97
+ const lastDataRef = useRef<ArrayBuffer | undefined>(undefined);
98
+ const prevDimCountRef = useRef<number | null>(null);
99
+ const dataRef = useRef(data);
100
+ dataRef.current = data;
101
+ const onStatusRef = useRef(onStatus);
102
+ onStatusRef.current = onStatus;
103
+
104
+ const setCurrentBasis = useSetAtom(currentBasisAtom);
105
+ const tourBy = useAtomValue(tourByAtom);
106
+ const [pcaResult, setPcaResult] = useState<{
107
+ eigenvectors: Float32Array[];
108
+ numDims: number;
109
+ } | null>(null);
110
+
111
+ const isGuidedMode = viewMode === 'guided';
112
+
113
+ // Resolve views (from props or auto-generated) and precompute arc lengths
114
+ // so we can track the current tour basis on the main thread.
115
+ const { resolvedViews, arcLengths } = useMemo(() => {
116
+ if (!metadata || metadata.dimCount < 2) return { resolvedViews: null, arcLengths: null };
117
+ if (activeIndices.length < 2) return { resolvedViews: null, arcLengths: null };
118
+ const dims = metadata.dimCount;
119
+ let rb: Float32Array[];
120
+ if (tourBy === 'pca' && pcaResult && pcaResult.eigenvectors.length >= 2) {
121
+ rb = createPCAViews(pcaResult.eigenvectors, dims, pcaResult.numDims, previewCount);
122
+ } else if (views && views.length > 0) {
123
+ rb = views.map((b) => new Float32Array(b));
124
+ } else {
125
+ rb = createDefaultViews(dims, previewCount, activeIndices);
126
+ }
127
+ return { resolvedViews: rb, arcLengths: computeArcLengths(rb, dims) };
128
+ }, [views, metadata, previewCount, activeIndices, tourBy, pcaResult]);
129
+
130
+ // Keep currentBasisAtom in sync with the tour interpolation so other
131
+ // modes (manual, grand) can initialize from the current projection.
132
+ // Only update in guided mode — in manual/grand the atom is owned by
133
+ // AxisOverlay / useGrandTour respectively.
134
+ // Skip when guidedSuspended: the GPU is still showing a directBasis
135
+ // from the previous mode (grand/manual), not the tour interpolation,
136
+ // so overwriting currentBasisAtom here would cause a jump on re-entry.
137
+ useEffect(() => {
138
+ if (viewMode !== 'guided') return;
139
+ if (guidedSuspended) return;
140
+ if (!resolvedViews || !arcLengths || !metadata) return;
141
+ const dims = metadata.dimCount;
142
+ const out = new Float32Array(dims * 2);
143
+ interpolateAtPosition(out, resolvedViews, arcLengths, dims, position);
144
+ setCurrentBasis(out);
145
+ }, [viewMode, guidedSuspended, resolvedViews, arcLengths, metadata, position, setCurrentBasis]);
146
+
147
+ // Parse metrics Arrow IPC into renderable tracks
148
+ const parsedTracks = useMemo(() => {
149
+ if (!metrics) return [];
150
+ return parseMetrics(metrics, metricTracks, metricBarWidth);
151
+ }, [metrics, metricTracks, metricBarWidth]);
152
+
153
+ // Override confusion track color: highlight by default, label palette color on single selection
154
+ const legendSelection = useAtomValue(legendSelectionAtom);
155
+ const pointColor = useAtomValue(pointColorAtom);
156
+ const resolvedTheme = useAtomValue(resolvedThemeAtom);
157
+
158
+ const coloredTracks = useMemo(() => {
159
+ if (parsedTracks.length === 0) return parsedTracks;
160
+ const confusionIdx = parsedTracks.findIndex((t) => t.label === 'confusion');
161
+ if (confusionIdx === -1) return parsedTracks;
162
+
163
+ let confusionColor = resolvedTheme === 'light' ? '#000000' : '#ffffff';
164
+
165
+ if (
166
+ legendSelection &&
167
+ legendSelection.size === 1 &&
168
+ typeof pointColor === 'string' &&
169
+ metadata?.categoricalColumnNames.includes(pointColor)
170
+ ) {
171
+ const selectedIndex = legendSelection.values().next().value as number;
172
+ const labels = metadata.categoricalLabels[pointColor] ?? [];
173
+ const glasbey = resolvedTheme === 'light' ? GLASBEY_LIGHT : GLASBEY_DARK;
174
+ const colors =
175
+ labels.length <= OKABE_ITO.length
176
+ ? OKABE_ITO
177
+ : ([...OKABE_ITO, ...glasbey] as [number, number, number][]);
178
+ const [r, g, b] = colors[selectedIndex % colors.length]!;
179
+ confusionColor = `rgb(${r},${g},${b})`;
180
+ }
181
+
182
+ const currentTrack = parsedTracks[confusionIdx]!;
183
+ if (currentTrack.color === confusionColor) return parsedTracks;
184
+
185
+ const result = [...parsedTracks];
186
+ result[confusionIdx] = { ...currentTrack, color: confusionColor };
187
+ return result;
188
+ }, [parsedTracks, legendSelection, pointColor, metadata, resolvedTheme]);
189
+
190
+ // Bridge Jotai atoms (style, camera) → scatter instance
191
+ useScatter(scatterRef.current);
192
+
193
+ const isToolbarVisible = toolbarHeight > 0 && viewMode !== 'grand';
194
+
195
+ // Animate camera inset when the toolbar appears/disappears (grand toggle).
196
+ // The shader shifts + scales content to center it below the toolbar.
197
+ // We also track the current pixel offset for positioning overlays.
198
+ const [overlayOffsetY, setOverlayOffsetY] = useState(isToolbarVisible ? toolbarHeight / 2 : 0);
199
+ const overlayOffsetRef = useRef(overlayOffsetY);
200
+ overlayOffsetRef.current = overlayOffsetY;
201
+ const insetAnimRef = useRef<number | null>(null);
202
+
203
+ useEffect(() => {
204
+ const scatter = scatterRef.current;
205
+ if (!scatter || containerSize.height === 0) return;
206
+
207
+ const targetT = viewMode === 'grand' || toolbarHeight === 0 ? 0 : 1;
208
+ const h = containerSize.height;
209
+ const t = toolbarHeight;
210
+
211
+ // Current inset factor: derive from current overlayOffsetY via ref
212
+ const startT = t > 0 ? overlayOffsetRef.current / (t / 2) : 0;
213
+ if (Math.abs(startT - targetT) < 0.001) {
214
+ // Already at target — just ensure shader is in sync
215
+ const insetOffsetY = (-targetT * t) / h;
216
+ const insetZoom = 1 - (targetT * t) / h;
217
+ scatter.setCamera({ insetOffsetY, insetZoom } as Parameters<typeof scatter.setCamera>[0]);
218
+ return;
219
+ }
220
+
221
+ const startTime = performance.now();
222
+
223
+ const tick = (now: number) => {
224
+ const elapsed = now - startTime;
225
+ const progress = Math.min(1, elapsed / INSET_ANIMATION_MS);
226
+ // ease-in-out cubic
227
+ const eased =
228
+ progress < 0.5 ? 4 * progress * progress * progress : 1 - (-2 * progress + 2) ** 3 / 2;
229
+
230
+ const currentT = startT + (targetT - startT) * eased;
231
+
232
+ // Shader inset: shift down and scale to fit visible area
233
+ const insetOffsetY = (-currentT * t) / h;
234
+ const insetZoom = 1 - (currentT * t) / h;
235
+ scatter.setCamera({ insetOffsetY, insetZoom } as Parameters<typeof scatter.setCamera>[0]);
236
+
237
+ // Overlay pixel offset
238
+ setOverlayOffsetY((currentT * t) / 2);
239
+
240
+ if (progress < 1) {
241
+ insetAnimRef.current = requestAnimationFrame(tick);
242
+ } else {
243
+ insetAnimRef.current = null;
244
+ }
245
+ };
246
+
247
+ if (insetAnimRef.current !== null) cancelAnimationFrame(insetAnimRef.current);
248
+ insetAnimRef.current = requestAnimationFrame(tick);
249
+
250
+ return () => {
251
+ if (insetAnimRef.current !== null) {
252
+ cancelAnimationFrame(insetAnimRef.current);
253
+ insetAnimRef.current = null;
254
+ }
255
+ };
256
+ }, [viewMode, toolbarHeight, containerSize.height]);
257
+
258
+ // Grand mode: Givens-rotation grand tour
259
+ useGrandTour(scatterRef.current, viewMode, metadata);
260
+
261
+ // Largest selector diameter that doesn't overlap any gallery preview
262
+ const selectorSize = useMemo(
263
+ () =>
264
+ computeSelectorSize(
265
+ containerSize.width,
266
+ containerSize.height,
267
+ previewCount,
268
+ isToolbarVisible,
269
+ SELECTOR_PADDING,
270
+ previewScale,
271
+ coloredTracks.length,
272
+ ),
273
+ [
274
+ containerSize.width,
275
+ containerSize.height,
276
+ previewCount,
277
+ isToolbarVisible,
278
+ previewScale,
279
+ coloredTracks.length,
280
+ ],
281
+ );
282
+
283
+ // Initialize scatter — create main + preview canvases imperatively
284
+ // (transferControlToOffscreen can only be called once per canvas).
285
+ useEffect(() => {
286
+ const container = containerRef.current;
287
+ if (!container) return;
288
+
289
+ const dpr = window.devicePixelRatio || 1;
290
+ const rect = container.getBoundingClientRect();
291
+
292
+ // Main canvas — fills container
293
+ const mainCanvas = document.createElement('canvas');
294
+ mainCanvas.width = Math.round(rect.width * dpr);
295
+ mainCanvas.height = Math.round(rect.height * dpr);
296
+ mainCanvas.style.width = '100%';
297
+ mainCanvas.style.height = '100%';
298
+ mainCanvas.style.display = 'block';
299
+ container.prepend(mainCanvas);
300
+
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],
317
+ zoom: store.get(cameraZoomAtom),
318
+ });
319
+ scatterRef.current = scatter;
320
+ onScatterReadyRef.current?.(scatter);
321
+
322
+ scatter.subscribe((s: ScatterStatus) => {
323
+ onStatusRef.current?.(s);
324
+ if (s.type === 'pcaResult') {
325
+ setPcaResult({ eigenvectors: s.eigenvectors, numDims: s.numDims });
326
+ }
327
+ });
328
+
329
+ const ro = new ResizeObserver(([entry]) => {
330
+ if (!entry) return;
331
+ const { width, height } = entry.contentRect;
332
+ const curDpr = window.devicePixelRatio || 1;
333
+ scatter.resize(0, Math.round(width * curDpr), Math.round(height * curDpr), curDpr);
334
+ setContainerSize({ width, height });
335
+ setCanvasSize({ width, height });
336
+ });
337
+ ro.observe(container);
338
+
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
+ return () => {
349
+ ro.disconnect();
350
+ scatter.destroy();
351
+ scatterRef.current = null;
352
+ onScatterReadyRef.current?.(null);
353
+ mainCanvas.remove();
354
+ for (const c of previews) c.remove();
355
+ previewCanvasesRef.current = [];
356
+ };
357
+ }, [previewCount, setCanvasSize, store]);
358
+
359
+ // Reset active columns and PCA results when a new dataset loads (different dim count)
360
+ useEffect(() => {
361
+ if (!metadata) return;
362
+ if (prevDimCountRef.current !== null && prevDimCountRef.current !== metadata.dimCount) {
363
+ setActiveColumns(null);
364
+ setPcaResult(null);
365
+ }
366
+ prevDimCountRef.current = metadata.dimCount;
367
+ }, [metadata, setActiveColumns]);
368
+
369
+ // Send data when it changes
370
+ useEffect(() => {
371
+ if (!data || !scatterRef.current || data === lastDataRef.current) return;
372
+ lastDataRef.current = data;
373
+ scatterRef.current.loadData(data.slice(0));
374
+ }, [data]);
375
+
376
+ // Trigger PCA computation when tourBy is 'pca' and data is loaded
377
+ useEffect(() => {
378
+ if (tourBy !== 'pca' || !metadata || metadata.dimCount < 2 || !scatterRef.current) return;
379
+ scatterRef.current.computePCA();
380
+ }, [tourBy, metadata]);
381
+
382
+ // Set views when available (from props, PCA, or auto-generated from metadata)
383
+ useEffect(() => {
384
+ const scatter = scatterRef.current;
385
+ if (!scatter) return;
386
+ if (tourBy === 'pca' && pcaResult && pcaResult.eigenvectors.length >= 2 && metadata) {
387
+ const pcaBases = createPCAViews(
388
+ pcaResult.eigenvectors,
389
+ metadata.dimCount,
390
+ pcaResult.numDims,
391
+ previewCount,
392
+ );
393
+ scatter.setBases(pcaBases);
394
+ } else if (views && views.length > 0) {
395
+ scatter.setBases(views.map((b) => new Float32Array(b)));
396
+ } else if (metadata && metadata.dimCount >= 2 && activeIndices.length >= 2) {
397
+ const defaultViews = createDefaultViews(metadata.dimCount, previewCount, activeIndices);
398
+ scatter.setBases(defaultViews);
399
+ }
400
+ // Safety: explicitly request a full re-render after views are set,
401
+ // ensuring all preview canvases get painted even if messages race.
402
+ scatter.render();
403
+ }, [views, metadata, previewCount, activeIndices, tourBy, pcaResult]);
404
+
405
+ const { animateTo, cancelAnimation } = useAnimatePosition();
406
+
407
+ // Slider click → animated seek to the clicked position
408
+ const handlePositionSeek = useCallback(
409
+ (pos: number) => {
410
+ setGuidedSuspended(false);
411
+ setPlaying(false);
412
+ animateTo(pos);
413
+ },
414
+ [setGuidedSuspended, setPlaying, animateTo],
415
+ );
416
+
417
+ // Slider drag start → cancel animation, switch to immediate updates
418
+ const handleDragStart = useCallback(() => {
419
+ cancelAnimation();
420
+ setGuidedSuspended(false);
421
+ }, [cancelAnimation, setGuidedSuspended]);
422
+
423
+ // Slider drag move → immediate position update
424
+ const handlePositionChange = useCallback(
425
+ (pos: number) => {
426
+ setGuidedSuspended(false);
427
+ setPosition(pos);
428
+ },
429
+ [setPosition, setGuidedSuspended],
430
+ );
431
+
432
+ // Wheel → scrub tour position (guided mode) or zoom (Shift+wheel, all modes).
433
+ // Imperative listener with { passive: false } so preventDefault() works.
434
+ useEffect(() => {
435
+ const container = containerRef.current;
436
+ if (!container) return;
437
+ const handler = (e: WheelEvent) => {
438
+ if (e.shiftKey) {
439
+ // Shift+wheel → zoom (camera distance)
440
+ e.preventDefault();
441
+ store.set(cameraZoomAtom, (prev) => {
442
+ // deltaY > 0 = scroll down = zoom out (smaller zoom value)
443
+ const factor = 1 - (e.deltaX || e.deltaY) * 0.002;
444
+ // Clamp to distance range [1x, 4x] → zoom range [0.25, 1]
445
+ return Math.min(1, Math.max(0.25, prev * factor));
446
+ });
447
+ return;
448
+ }
449
+ if (store.get(viewModeAtom) !== 'guided') return;
450
+ e.preventDefault();
451
+ // Cancel any running position animation before wheel scrub
452
+ store.set(animationGenAtom, (g) => g + 1);
453
+ store.set(tourPlayingAtom, false);
454
+ store.set(guidedSuspendedAtom, false);
455
+ store.set(tourPositionAtom, (prev) => {
456
+ let next = prev + e.deltaY * 0.002;
457
+ next = next - Math.floor(next);
458
+ return next;
459
+ });
460
+ };
461
+ container.addEventListener('wheel', handler, { passive: false });
462
+ return () => container.removeEventListener('wheel', handler);
463
+ }, [store]);
464
+
465
+ const tickCount = views?.length ?? previewCount;
466
+ const hasData = !!data && !!metadata;
467
+
468
+ if (import.meta.env.DEV && views && views.length !== previewCount) {
469
+ console.warn(
470
+ `[dtour] views.length (${views.length}) differs from previewCount (${previewCount}). Selector ticks and radial bars reflect views count; preview gallery reflects previewCount. Set previewCount to match views.length for full alignment.`,
471
+ );
472
+ }
473
+
474
+ return (
475
+ <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)` }}>
479
+ {/* 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}
498
+ />
499
+ )}
500
+
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
+ />
508
+ )}
509
+
510
+ {/* Circular selector + radial chart overlay — only in guided mode, above lasso */}
511
+ {isGuidedMode && hasData && (
512
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
513
+ {/* Radial chart — behind selector */}
514
+ {coloredTracks.length > 0 && (
515
+ <div className="absolute">
516
+ <RadialChart
517
+ tracks={coloredTracks}
518
+ keyframeCount={tickCount}
519
+ position={position}
520
+ size={selectorSize}
521
+ innerRadius={selectorSize * 0.4}
522
+ />
523
+ </div>
524
+ )}
525
+ {/* Selector — on top for drag interaction */}
526
+ <div className="pointer-events-none relative z-10">
527
+ <CircularSlider
528
+ value={position}
529
+ onChange={handlePositionChange}
530
+ onSeek={handlePositionSeek}
531
+ onDragStart={handleDragStart}
532
+ tickCount={tickCount}
533
+ size={selectorSize}
534
+ />
535
+ </div>
536
+ </div>
537
+ )}
538
+ </div>
539
+ </div>
540
+ );
541
+ };