@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.
- package/dist/Dtour.d.ts +5 -1
- package/dist/Dtour.d.ts.map +1 -1
- package/dist/DtourViewer.d.ts +4 -1
- package/dist/DtourViewer.d.ts.map +1 -1
- package/dist/components/AxisOverlay.d.ts +11 -1
- package/dist/components/AxisOverlay.d.ts.map +1 -1
- package/dist/components/CircularSlider.d.ts +21 -2
- package/dist/components/CircularSlider.d.ts.map +1 -1
- package/dist/components/DtourToolbar.d.ts +2 -1
- package/dist/components/DtourToolbar.d.ts.map +1 -1
- package/dist/components/Gallery.d.ts +3 -3
- package/dist/components/Gallery.d.ts.map +1 -1
- package/dist/components/RevertCameraButton.d.ts +6 -0
- package/dist/components/RevertCameraButton.d.ts.map +1 -0
- package/dist/components/ui/checkbox.d.ts +6 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/hooks/usePlayback.d.ts +7 -5
- package/dist/hooks/usePlayback.d.ts.map +1 -1
- package/dist/hooks/useScatter.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/layout/gallery-positions.d.ts +3 -1
- package/dist/layout/gallery-positions.d.ts.map +1 -1
- package/dist/layout/selector-size.d.ts +4 -2
- package/dist/layout/selector-size.d.ts.map +1 -1
- package/dist/lib/arcball.d.ts +21 -0
- package/dist/lib/arcball.d.ts.map +1 -0
- package/dist/lib/position-remap.d.ts +16 -0
- package/dist/lib/position-remap.d.ts.map +1 -0
- package/dist/lib/throttle-debounce.d.ts +28 -0
- package/dist/lib/throttle-debounce.d.ts.map +1 -0
- package/dist/radial-chart/RadialChart.d.ts +5 -1
- package/dist/radial-chart/RadialChart.d.ts.map +1 -1
- package/dist/spec.d.ts +32 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/state/atoms.d.ts +67 -0
- package/dist/state/atoms.d.ts.map +1 -1
- package/dist/state/spec-sync.d.ts +2 -0
- package/dist/state/spec-sync.d.ts.map +1 -1
- package/dist/viewer.css +1 -1
- package/dist/viewer.js +11620 -10118
- package/package.json +6 -1
- package/src/Dtour.tsx +82 -9
- package/src/DtourViewer.tsx +480 -100
- package/src/components/AxisOverlay.tsx +332 -182
- package/src/components/CircularSlider.tsx +363 -174
- package/src/components/DtourToolbar.tsx +121 -10
- package/src/components/Gallery.tsx +197 -39
- package/src/components/RevertCameraButton.tsx +39 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/hooks/usePlayback.ts +18 -44
- package/src/hooks/useScatter.ts +21 -5
- package/src/index.ts +16 -3
- package/src/layout/gallery-positions.ts +15 -4
- package/src/layout/selector-size.ts +24 -10
- package/src/lib/arcball.ts +119 -0
- package/src/lib/position-remap.ts +51 -0
- package/src/lib/throttle-debounce.ts +79 -0
- package/src/radial-chart/RadialChart.tsx +45 -6
- package/src/spec.ts +143 -0
- package/src/state/atoms.ts +65 -0
- package/src/state/spec-sync.ts +15 -0
- package/src/styles.css +16 -16
package/src/DtourViewer.tsx
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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 /
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
399
|
+
effectiveToolbarHeight,
|
|
278
400
|
previewScale,
|
|
279
401
|
coloredTracks.length,
|
|
402
|
+
loadingsVisible,
|
|
280
403
|
],
|
|
281
404
|
);
|
|
282
405
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
406
|
+
// Effect A — Scatter 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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
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 =
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 || !
|
|
538
|
+
if (!data || !scatter || data === lastDataRef.current) return;
|
|
539
|
+
if (data.byteLength === 0) return; // already transferred (detached)
|
|
372
540
|
lastDataRef.current = data;
|
|
373
|
-
|
|
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 || !
|
|
379
|
-
|
|
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
|
-
(
|
|
579
|
+
(visualPos: number) => {
|
|
410
580
|
setGuidedSuspended(false);
|
|
411
581
|
setPlaying(false);
|
|
412
|
-
|
|
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 →
|
|
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
|
-
(
|
|
599
|
+
(visualPos: number) => {
|
|
426
600
|
setGuidedSuspended(false);
|
|
427
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
477
|
-
|
|
478
|
-
<div className="absolute
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
{/*
|
|
502
|
-
{
|
|
503
|
-
<
|
|
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
|
-
|
|
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>
|