@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.
- package/dist/Dtour.d.ts +46 -0
- package/dist/Dtour.d.ts.map +1 -0
- package/dist/DtourViewer.d.ts +24 -0
- package/dist/DtourViewer.d.ts.map +1 -0
- package/dist/components/AxisOverlay.d.ts +9 -0
- package/dist/components/AxisOverlay.d.ts.map +1 -0
- package/dist/components/CircularSlider.d.ts +16 -0
- package/dist/components/CircularSlider.d.ts.map +1 -0
- package/dist/components/ColorLegend.d.ts +2 -0
- package/dist/components/ColorLegend.d.ts.map +1 -0
- package/dist/components/DtourToolbar.d.ts +5 -0
- package/dist/components/DtourToolbar.d.ts.map +1 -0
- package/dist/components/Gallery.d.ts +12 -0
- package/dist/components/Gallery.d.ts.map +1 -0
- package/dist/components/LassoOverlay.d.ts +9 -0
- package/dist/components/LassoOverlay.d.ts.map +1 -0
- package/dist/components/Logo.d.ts +2 -0
- package/dist/components/Logo.d.ts.map +1 -0
- package/dist/components/ui/button.d.ts +12 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +10 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/slider.d.ts +6 -0
- package/dist/components/ui/slider.d.ts.map +1 -0
- package/dist/components/ui/tooltip.d.ts +8 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/hooks/useAnimatePosition.d.ts +13 -0
- package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
- package/dist/hooks/useGrandTour.d.ts +14 -0
- package/dist/hooks/useGrandTour.d.ts.map +1 -0
- package/dist/hooks/useLongPressIndicator.d.ts +5 -0
- package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
- package/dist/hooks/useModeCycling.d.ts +12 -0
- package/dist/hooks/useModeCycling.d.ts.map +1 -0
- package/dist/hooks/usePlayback.d.ts +9 -0
- package/dist/hooks/usePlayback.d.ts.map +1 -0
- package/dist/hooks/useScatter.d.ts +10 -0
- package/dist/hooks/useScatter.d.ts.map +1 -0
- package/dist/hooks/useSystemTheme.d.ts +6 -0
- package/dist/hooks/useSystemTheme.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/layout/gallery-positions.d.ts +38 -0
- package/dist/layout/gallery-positions.d.ts.map +1 -0
- package/dist/layout/selector-size.d.ts +15 -0
- package/dist/layout/selector-size.d.ts.map +1 -0
- package/dist/lib/color-utils.d.ts +7 -0
- package/dist/lib/color-utils.d.ts.map +1 -0
- package/dist/lib/gram-schmidt.d.ts +9 -0
- package/dist/lib/gram-schmidt.d.ts.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/portal-container.d.ts +10 -0
- package/dist/portal-container.d.ts.map +1 -0
- package/dist/radial-chart/RadialChart.d.ts +13 -0
- package/dist/radial-chart/RadialChart.d.ts.map +1 -0
- package/dist/radial-chart/arc-path.d.ts +23 -0
- package/dist/radial-chart/arc-path.d.ts.map +1 -0
- package/dist/radial-chart/index.d.ts +5 -0
- package/dist/radial-chart/index.d.ts.map +1 -0
- package/dist/radial-chart/parse-metrics.d.ts +10 -0
- package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
- package/dist/radial-chart/types.d.ts +23 -0
- package/dist/radial-chart/types.d.ts.map +1 -0
- package/dist/spec.d.ts +42 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/state/atoms.d.ts +150 -0
- package/dist/state/atoms.d.ts.map +1 -0
- package/dist/state/spec-sync.d.ts +5 -0
- package/dist/state/spec-sync.d.ts.map +1 -0
- package/dist/viewer.css +3 -0
- package/dist/viewer.js +14501 -0
- package/dist/views.d.ts +30 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/Dtour.tsx +300 -0
- package/src/DtourViewer.tsx +541 -0
- package/src/components/AxisOverlay.tsx +224 -0
- package/src/components/CircularSlider.tsx +202 -0
- package/src/components/ColorLegend.tsx +178 -0
- package/src/components/DtourToolbar.tsx +642 -0
- package/src/components/Gallery.tsx +166 -0
- package/src/components/LassoOverlay.tsx +240 -0
- package/src/components/Logo.tsx +37 -0
- package/src/components/ui/button.tsx +36 -0
- package/src/components/ui/dropdown-menu.tsx +92 -0
- package/src/components/ui/slider.tsx +89 -0
- package/src/components/ui/tooltip.tsx +45 -0
- package/src/hooks/useAnimatePosition.ts +102 -0
- package/src/hooks/useGrandTour.ts +176 -0
- package/src/hooks/useLongPressIndicator.ts +342 -0
- package/src/hooks/useModeCycling.ts +64 -0
- package/src/hooks/usePlayback.ts +54 -0
- package/src/hooks/useScatter.ts +162 -0
- package/src/hooks/useSystemTheme.ts +19 -0
- package/src/index.ts +55 -0
- package/src/layout/gallery-positions.ts +105 -0
- package/src/layout/selector-size.ts +135 -0
- package/src/lib/color-utils.ts +22 -0
- package/src/lib/gram-schmidt.ts +41 -0
- package/src/lib/utils.ts +4 -0
- package/src/portal-container.tsx +14 -0
- package/src/radial-chart/RadialChart.tsx +184 -0
- package/src/radial-chart/arc-path.ts +80 -0
- package/src/radial-chart/index.ts +4 -0
- package/src/radial-chart/parse-metrics.ts +99 -0
- package/src/radial-chart/types.ts +23 -0
- package/src/spec.ts +48 -0
- package/src/state/atoms.ts +169 -0
- package/src/state/spec-sync.ts +190 -0
- package/src/styles.css +44 -0
- package/src/views.ts +76 -0
- package/tsconfig.json +12 -0
- 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
|
+
};
|