@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,642 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowCounterClockwiseIcon,
|
|
3
|
+
CaretDownIcon,
|
|
4
|
+
CompassIcon,
|
|
5
|
+
CursorIcon,
|
|
6
|
+
GaugeIcon,
|
|
7
|
+
ImageSquareIcon,
|
|
8
|
+
MagnifyingGlassMinusIcon,
|
|
9
|
+
MonitorIcon,
|
|
10
|
+
MoonIcon,
|
|
11
|
+
PaintBrushIcon,
|
|
12
|
+
PathIcon,
|
|
13
|
+
PauseIcon,
|
|
14
|
+
PlayIcon,
|
|
15
|
+
SidebarSimpleIcon,
|
|
16
|
+
SunIcon,
|
|
17
|
+
} from '@phosphor-icons/react';
|
|
18
|
+
import * as Popover from '@radix-ui/react-popover';
|
|
19
|
+
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
21
|
+
import { useAnimatePosition } from '../hooks/useAnimatePosition.ts';
|
|
22
|
+
import { usePlayback } from '../hooks/usePlayback.ts';
|
|
23
|
+
import { usePortalContainer } from '../portal-container.tsx';
|
|
24
|
+
import {
|
|
25
|
+
activeColumnsAtom,
|
|
26
|
+
cameraZoomAtom,
|
|
27
|
+
grandExitTargetAtom,
|
|
28
|
+
guidedSuspendedAtom,
|
|
29
|
+
legendVisibleAtom,
|
|
30
|
+
metadataAtom,
|
|
31
|
+
pointColorAtom,
|
|
32
|
+
previewCountAtom,
|
|
33
|
+
previewScaleAtom,
|
|
34
|
+
selectedKeyframeAtom,
|
|
35
|
+
showLegendAtom,
|
|
36
|
+
themeModeAtom,
|
|
37
|
+
tourByAtom,
|
|
38
|
+
tourPlayingAtom,
|
|
39
|
+
tourSpeedAtom,
|
|
40
|
+
viewModeAtom,
|
|
41
|
+
} from '../state/atoms.ts';
|
|
42
|
+
import { Logo } from './Logo.tsx';
|
|
43
|
+
import { Button } from './ui/button.tsx';
|
|
44
|
+
import {
|
|
45
|
+
DropdownMenu,
|
|
46
|
+
DropdownMenuCheckboxItem,
|
|
47
|
+
DropdownMenuContent,
|
|
48
|
+
DropdownMenuItem,
|
|
49
|
+
DropdownMenuLabel,
|
|
50
|
+
DropdownMenuSeparator,
|
|
51
|
+
DropdownMenuTrigger,
|
|
52
|
+
} from './ui/dropdown-menu.tsx';
|
|
53
|
+
import { Slider } from './ui/slider.tsx';
|
|
54
|
+
|
|
55
|
+
type ViewMode = 'guided' | 'manual' | 'grand';
|
|
56
|
+
|
|
57
|
+
const MODE_CONFIG: { mode: ViewMode; label: string; icon: typeof PathIcon }[] = [
|
|
58
|
+
{ mode: 'guided', label: 'Guided', icon: PathIcon },
|
|
59
|
+
{ mode: 'manual', label: 'Manual', icon: CursorIcon },
|
|
60
|
+
{ mode: 'grand', label: 'Grand', icon: CompassIcon },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const DEFAULT_COLOR: [number, number, number] = [0.25, 0.5, 0.9];
|
|
64
|
+
|
|
65
|
+
export type DtourToolbarProps = {
|
|
66
|
+
onLoadData?: ((data: ArrayBuffer, fileName: string) => void) | undefined;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
|
|
70
|
+
const [playing, setPlaying] = useAtom(tourPlayingAtom);
|
|
71
|
+
const [speed, setSpeed] = useAtom(tourSpeedAtom);
|
|
72
|
+
const [zoom, setZoom] = useAtom(cameraZoomAtom);
|
|
73
|
+
const metadata = useAtomValue(metadataAtom);
|
|
74
|
+
const [viewMode, setViewMode] = useAtom(viewModeAtom);
|
|
75
|
+
const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
|
|
76
|
+
const setGrandExitTarget = useSetAtom(grandExitTargetAtom);
|
|
77
|
+
const setSelectedKeyframe = useSetAtom(selectedKeyframeAtom);
|
|
78
|
+
const [pointColor, setPointColor] = useAtom(pointColorAtom);
|
|
79
|
+
const [activeColumns, setActiveColumns] = useAtom(activeColumnsAtom);
|
|
80
|
+
const [previewCount, setPreviewCount] = useAtom(previewCountAtom);
|
|
81
|
+
const [previewScale, setPreviewScale] = useAtom(previewScaleAtom);
|
|
82
|
+
const [showLegend, setShowLegend] = useAtom(showLegendAtom);
|
|
83
|
+
const legendVisible = useAtomValue(legendVisibleAtom);
|
|
84
|
+
const [themeMode, setThemeMode] = useAtom(themeModeAtom);
|
|
85
|
+
const [tourBy, setTourBy] = useAtom(tourByAtom);
|
|
86
|
+
|
|
87
|
+
const portalContainer = usePortalContainer();
|
|
88
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
89
|
+
|
|
90
|
+
// Activate the rAF playback loop
|
|
91
|
+
usePlayback();
|
|
92
|
+
|
|
93
|
+
const { animateTo, cancelAnimation } = useAnimatePosition();
|
|
94
|
+
|
|
95
|
+
const handlePlayPause = useCallback(() => {
|
|
96
|
+
cancelAnimation();
|
|
97
|
+
setGuidedSuspended(false);
|
|
98
|
+
if (!playing) setSelectedKeyframe(null);
|
|
99
|
+
setPlaying((p) => !p);
|
|
100
|
+
}, [playing, setPlaying, setGuidedSuspended, setSelectedKeyframe, cancelAnimation]);
|
|
101
|
+
|
|
102
|
+
const handleReset = useCallback(() => {
|
|
103
|
+
setGuidedSuspended(false);
|
|
104
|
+
setPlaying(false);
|
|
105
|
+
animateTo(0);
|
|
106
|
+
}, [setPlaying, setGuidedSuspended, animateTo]);
|
|
107
|
+
|
|
108
|
+
const handleFileSelect = useCallback(
|
|
109
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
110
|
+
const file = e.target.files?.[0];
|
|
111
|
+
if (!file || !onLoadData) return;
|
|
112
|
+
onLoadData(await file.arrayBuffer(), file.name);
|
|
113
|
+
e.target.value = '';
|
|
114
|
+
},
|
|
115
|
+
[onLoadData],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const openFilePicker = useCallback(() => {
|
|
119
|
+
fileInputRef.current?.click();
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
// Determine active color-by column (string = column name, array = uniform)
|
|
123
|
+
const activeColorColumn = typeof pointColor === 'string' ? pointColor : null;
|
|
124
|
+
|
|
125
|
+
const toggleColorBy = useCallback(
|
|
126
|
+
(columnName: string) => {
|
|
127
|
+
setPointColor((prev) => (prev === columnName ? DEFAULT_COLOR : columnName));
|
|
128
|
+
},
|
|
129
|
+
[setPointColor],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const activeCount =
|
|
133
|
+
activeColumns === null ? (metadata?.columnNames.length ?? 0) : activeColumns.size;
|
|
134
|
+
|
|
135
|
+
const handleToggleColumn = useCallback(
|
|
136
|
+
(dimIndex: number) => {
|
|
137
|
+
setActiveColumns((prev) => {
|
|
138
|
+
const current =
|
|
139
|
+
prev ?? new Set(Array.from({ length: metadata?.dimCount ?? 0 }, (_, i) => i));
|
|
140
|
+
const next = new Set(current);
|
|
141
|
+
if (next.has(dimIndex)) {
|
|
142
|
+
if (next.size <= 2) return prev;
|
|
143
|
+
next.delete(dimIndex);
|
|
144
|
+
} else {
|
|
145
|
+
next.add(dimIndex);
|
|
146
|
+
}
|
|
147
|
+
// Optimize: return null when all columns are active
|
|
148
|
+
if (metadata && next.size === metadata.dimCount) return null;
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
[metadata, setActiveColumns],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="grid h-10 grid-cols-[1fr_auto_1fr] items-center border-b border-dtour-surface bg-dtour-bg px-3 text-dtour-text">
|
|
157
|
+
{/* Hidden file input */}
|
|
158
|
+
{onLoadData && (
|
|
159
|
+
<input
|
|
160
|
+
ref={fileInputRef}
|
|
161
|
+
type="file"
|
|
162
|
+
accept=".parquet,.pq,.arrow"
|
|
163
|
+
className="hidden"
|
|
164
|
+
onChange={handleFileSelect}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Left: branding + mode switcher */}
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<div className="relative text-sm font-semibold tracking-wide text-dtour-highlight">
|
|
171
|
+
<div className="opacity-0 pointer-events-none">dtour</div>
|
|
172
|
+
<div className="absolute inset-0" data-logo-target>
|
|
173
|
+
<Logo />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div className="ml-2 flex items-center overflow-hidden rounded-md border border-dtour-surface">
|
|
177
|
+
{/* Guided button — expands to include Dims/PCA sub-toggle when active */}
|
|
178
|
+
<div
|
|
179
|
+
className={`flex gap-0 items-center ${viewMode === 'guided' ? 'bg-dtour-surface text-dtour-highlight' : 'text-dtour-text-muted'}`}
|
|
180
|
+
>
|
|
181
|
+
<Button
|
|
182
|
+
variant="ghost"
|
|
183
|
+
size="sm"
|
|
184
|
+
className={`rounded-none ${viewMode === 'guided' ? 'text-dtour-highlight' : ''}`}
|
|
185
|
+
onClick={() => {
|
|
186
|
+
if (viewMode === 'grand') {
|
|
187
|
+
setGrandExitTarget('guided');
|
|
188
|
+
} else if (viewMode !== 'guided') {
|
|
189
|
+
setGuidedSuspended(true);
|
|
190
|
+
setViewMode('guided');
|
|
191
|
+
}
|
|
192
|
+
}}
|
|
193
|
+
title="Guided"
|
|
194
|
+
>
|
|
195
|
+
<PathIcon size={14} weight={viewMode === 'guided' ? 'fill' : 'regular'} />
|
|
196
|
+
<span className="ml-1 text-xs">Guided{viewMode === 'guided' ? ':' : ''}</span>
|
|
197
|
+
</Button>
|
|
198
|
+
{viewMode === 'guided' && (
|
|
199
|
+
<>
|
|
200
|
+
<Button
|
|
201
|
+
variant="ghost"
|
|
202
|
+
size="sm"
|
|
203
|
+
className={`rounded-none px-0 ${tourBy === 'dimensions' ? 'text-dtour-highlight' : 'text-dtour-text-muted'}`}
|
|
204
|
+
onClick={() => setTourBy('dimensions')}
|
|
205
|
+
title="Tour by dimensions"
|
|
206
|
+
>
|
|
207
|
+
<span className="text-xs">Dims</span>
|
|
208
|
+
</Button>
|
|
209
|
+
<span className="text-[10px] text-dtour-text-muted select-none px-1.5">/</span>
|
|
210
|
+
<Button
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="sm"
|
|
213
|
+
className={`rounded-none px-0 ${tourBy === 'pca' ? 'text-dtour-highlight' : 'text-dtour-text-muted'}`}
|
|
214
|
+
onClick={() => setTourBy('pca')}
|
|
215
|
+
title="Tour by principal components"
|
|
216
|
+
>
|
|
217
|
+
<span className="text-xs">PCA</span>
|
|
218
|
+
</Button>
|
|
219
|
+
<div className="w-1.5 h-full text-[10px] text-dtour-text-muted select-none" />
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
{/* Manual + Grand buttons */}
|
|
224
|
+
{MODE_CONFIG.filter(({ mode }) => mode !== 'guided').map(
|
|
225
|
+
({ mode, label, icon: Icon }) => (
|
|
226
|
+
<Button
|
|
227
|
+
key={mode}
|
|
228
|
+
variant="ghost"
|
|
229
|
+
size="sm"
|
|
230
|
+
className={`rounded-none ${viewMode === mode ? 'bg-dtour-surface text-dtour-highlight' : 'text-dtour-text-muted'}`}
|
|
231
|
+
onClick={() => {
|
|
232
|
+
if (viewMode === 'grand') {
|
|
233
|
+
if (mode === 'grand') {
|
|
234
|
+
setGrandExitTarget(null);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
setGrandExitTarget(mode);
|
|
238
|
+
} else {
|
|
239
|
+
if (mode !== 'guided' && viewMode === 'guided') setPlaying(false);
|
|
240
|
+
if (mode === 'grand') setGrandExitTarget(null);
|
|
241
|
+
setViewMode(mode);
|
|
242
|
+
}
|
|
243
|
+
}}
|
|
244
|
+
title={label}
|
|
245
|
+
>
|
|
246
|
+
<Icon size={14} weight={viewMode === mode ? 'fill' : 'regular'} />
|
|
247
|
+
<span className="ml-1 text-xs">{label}</span>
|
|
248
|
+
</Button>
|
|
249
|
+
),
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* Center: playback controls (guided mode) / speed (grand mode) */}
|
|
255
|
+
<div className="flex items-center gap-1">
|
|
256
|
+
<Popover.Root>
|
|
257
|
+
<Popover.Trigger asChild>
|
|
258
|
+
<Button variant="ghost" size="icon" title={`Zoom: ${zoomToDistance(zoom)}x`}>
|
|
259
|
+
<MagnifyingGlassMinusIcon size={16} />
|
|
260
|
+
</Button>
|
|
261
|
+
</Popover.Trigger>
|
|
262
|
+
<Popover.Portal container={portalContainer}>
|
|
263
|
+
<Popover.Content
|
|
264
|
+
side="bottom"
|
|
265
|
+
align="center"
|
|
266
|
+
sideOffset={4}
|
|
267
|
+
className="z-50 flex flex-col items-center gap-2 rounded border border-dtour-border bg-dtour-surface p-3 shadow-md origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 animate-ease-out"
|
|
268
|
+
>
|
|
269
|
+
<span className="text-xs text-center font-semibold text-dtour-text-muted">Zoom</span>
|
|
270
|
+
<Slider
|
|
271
|
+
orientation="vertical"
|
|
272
|
+
min={0}
|
|
273
|
+
max={DISTANCE_STEPS.length - 1}
|
|
274
|
+
step={1}
|
|
275
|
+
ticks={DISTANCE_STEPS.length}
|
|
276
|
+
value={[distanceToStep(zoomToDistance(zoom))]}
|
|
277
|
+
onValueChange={([step]: number[]) => {
|
|
278
|
+
if (step !== undefined) setZoom(1 / stepToDistance(step));
|
|
279
|
+
}}
|
|
280
|
+
className="h-[120px]"
|
|
281
|
+
/>
|
|
282
|
+
<span className="text-xs font-medium text-dtour-highlight">
|
|
283
|
+
{zoomToDistance(zoom)}x
|
|
284
|
+
</span>
|
|
285
|
+
</Popover.Content>
|
|
286
|
+
</Popover.Portal>
|
|
287
|
+
</Popover.Root>
|
|
288
|
+
|
|
289
|
+
{viewMode === 'guided' && (
|
|
290
|
+
<>
|
|
291
|
+
<Button variant="ghost" size="icon" onClick={handleReset} title="Reset to start">
|
|
292
|
+
<ArrowCounterClockwiseIcon size={16} />
|
|
293
|
+
</Button>
|
|
294
|
+
<Button
|
|
295
|
+
variant="ghost"
|
|
296
|
+
size="icon"
|
|
297
|
+
onClick={handlePlayPause}
|
|
298
|
+
title={playing ? 'Pause' : 'Play'}
|
|
299
|
+
>
|
|
300
|
+
{playing ? (
|
|
301
|
+
<PauseIcon size={16} weight="fill" />
|
|
302
|
+
) : (
|
|
303
|
+
<PlayIcon size={16} weight="fill" />
|
|
304
|
+
)}
|
|
305
|
+
</Button>
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{(viewMode === 'guided' || viewMode === 'grand') && (
|
|
310
|
+
<Popover.Root>
|
|
311
|
+
<Popover.Trigger asChild>
|
|
312
|
+
<Button variant="ghost" size="icon" title={`Speed: ${speed}x`}>
|
|
313
|
+
<GaugeIcon size={16} />
|
|
314
|
+
</Button>
|
|
315
|
+
</Popover.Trigger>
|
|
316
|
+
<Popover.Portal container={portalContainer}>
|
|
317
|
+
<Popover.Content
|
|
318
|
+
side="bottom"
|
|
319
|
+
align="center"
|
|
320
|
+
sideOffset={4}
|
|
321
|
+
className="z-50 flex flex-col items-center gap-2 rounded border border-dtour-border bg-dtour-surface p-3 shadow-md origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 animate-ease-out"
|
|
322
|
+
>
|
|
323
|
+
<div className="text-xs text-center font-semibold text-dtour-text-muted">Speed</div>
|
|
324
|
+
<Slider
|
|
325
|
+
orientation="vertical"
|
|
326
|
+
min={0}
|
|
327
|
+
max={SPEED_STEPS.length - 1}
|
|
328
|
+
step={1}
|
|
329
|
+
ticks={SPEED_STEPS.length}
|
|
330
|
+
value={[speedToStep(speed)]}
|
|
331
|
+
onValueChange={([step]: number[]) => {
|
|
332
|
+
if (step !== undefined) setSpeed(stepToSpeed(step));
|
|
333
|
+
}}
|
|
334
|
+
className="h-[120px]"
|
|
335
|
+
/>
|
|
336
|
+
<span className="text-xs font-medium text-dtour-highlight">{speed}x</span>
|
|
337
|
+
</Popover.Content>
|
|
338
|
+
</Popover.Portal>
|
|
339
|
+
</Popover.Root>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{viewMode === 'guided' && (
|
|
343
|
+
<Popover.Root>
|
|
344
|
+
<Popover.Trigger asChild>
|
|
345
|
+
<Button variant="ghost" size="icon" title={`Previews: ${previewCount}`}>
|
|
346
|
+
<ImageSquareIcon size={16} />
|
|
347
|
+
</Button>
|
|
348
|
+
</Popover.Trigger>
|
|
349
|
+
<Popover.Portal container={portalContainer}>
|
|
350
|
+
<Popover.Content
|
|
351
|
+
side="bottom"
|
|
352
|
+
align="center"
|
|
353
|
+
sideOffset={4}
|
|
354
|
+
className="z-50 flex flex-col items-center gap-2 rounded border border-dtour-border bg-dtour-surface p-3 shadow-md origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 animate-ease-out"
|
|
355
|
+
>
|
|
356
|
+
<div className="flex flex-col gap-2">
|
|
357
|
+
<div className="text-xs text-center font-semibold text-dtour-text-muted">
|
|
358
|
+
Preview
|
|
359
|
+
</div>
|
|
360
|
+
<div className="flex gap-4">
|
|
361
|
+
<div className="flex flex-col items-center gap-2">
|
|
362
|
+
<span className="text-xs text-dtour-text-muted">Count</span>
|
|
363
|
+
<PreviewStepSlider
|
|
364
|
+
steps={PREVIEW_COUNT_STEPS}
|
|
365
|
+
value={previewCount}
|
|
366
|
+
onCommit={setPreviewCount}
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
<div className="flex flex-col items-center gap-2">
|
|
370
|
+
<span className="text-xs text-dtour-text-muted">Size</span>
|
|
371
|
+
<PreviewStepSlider
|
|
372
|
+
steps={PREVIEW_SCALE_STEPS}
|
|
373
|
+
value={previewScale}
|
|
374
|
+
onCommit={setPreviewScale}
|
|
375
|
+
formatLabel={SCALE_LABELS}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</Popover.Content>
|
|
381
|
+
</Popover.Portal>
|
|
382
|
+
</Popover.Root>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{/* Right: data info + settings */}
|
|
387
|
+
<div className="flex items-center justify-end gap-2">
|
|
388
|
+
{metadata ? (
|
|
389
|
+
<DropdownMenu>
|
|
390
|
+
<DropdownMenuTrigger asChild>
|
|
391
|
+
<Button variant="ghost" size="sm">
|
|
392
|
+
{metadata.rowCount.toLocaleString()} pts ×{' '}
|
|
393
|
+
{activeCount === metadata.dimCount
|
|
394
|
+
? `${metadata.dimCount} dims`
|
|
395
|
+
: `${activeCount}/${metadata.dimCount} dims`}
|
|
396
|
+
<CaretDownIcon size={12} />
|
|
397
|
+
</Button>
|
|
398
|
+
</DropdownMenuTrigger>
|
|
399
|
+
<DropdownMenuContent align="end" className="max-h-[60vh] w-64 overflow-y-auto">
|
|
400
|
+
{/* Numeric columns */}
|
|
401
|
+
{metadata.columnNames.length > 0 && (
|
|
402
|
+
<>
|
|
403
|
+
<DropdownMenuLabel className="text-xs font-semibold">Numerical</DropdownMenuLabel>
|
|
404
|
+
{metadata.columnNames.map((col, index) => {
|
|
405
|
+
const isActive = activeColumns === null || activeColumns.has(index);
|
|
406
|
+
return (
|
|
407
|
+
<ColumnRow
|
|
408
|
+
key={col}
|
|
409
|
+
name={col}
|
|
410
|
+
dtype="num"
|
|
411
|
+
checked={isActive}
|
|
412
|
+
onCheckedChange={() => handleToggleColumn(index)}
|
|
413
|
+
disabled={isActive && activeCount <= 2}
|
|
414
|
+
isColorActive={activeColorColumn === col}
|
|
415
|
+
onToggleColor={() => toggleColorBy(col)}
|
|
416
|
+
/>
|
|
417
|
+
);
|
|
418
|
+
})}
|
|
419
|
+
</>
|
|
420
|
+
)}
|
|
421
|
+
|
|
422
|
+
{/* Categorical columns */}
|
|
423
|
+
{metadata.categoricalColumnNames.length > 0 && (
|
|
424
|
+
<>
|
|
425
|
+
<DropdownMenuLabel className="text-xs font-semibold">
|
|
426
|
+
Categorical
|
|
427
|
+
</DropdownMenuLabel>
|
|
428
|
+
{metadata.categoricalColumnNames.map((col) => (
|
|
429
|
+
<ColumnRow
|
|
430
|
+
key={col}
|
|
431
|
+
name={col}
|
|
432
|
+
dtype="cat"
|
|
433
|
+
isColorActive={activeColorColumn === col}
|
|
434
|
+
onToggleColor={() => toggleColorBy(col)}
|
|
435
|
+
/>
|
|
436
|
+
))}
|
|
437
|
+
</>
|
|
438
|
+
)}
|
|
439
|
+
|
|
440
|
+
{onLoadData && (
|
|
441
|
+
<>
|
|
442
|
+
<DropdownMenuSeparator />
|
|
443
|
+
<DropdownMenuItem
|
|
444
|
+
className="text-xs active:scale-[0.97] transition-transform"
|
|
445
|
+
onSelect={openFilePicker}
|
|
446
|
+
>
|
|
447
|
+
Load new data
|
|
448
|
+
</DropdownMenuItem>
|
|
449
|
+
</>
|
|
450
|
+
)}
|
|
451
|
+
</DropdownMenuContent>
|
|
452
|
+
</DropdownMenu>
|
|
453
|
+
) : onLoadData ? (
|
|
454
|
+
<Button variant="ghost" size="sm" onClick={openFilePicker}>
|
|
455
|
+
Load data
|
|
456
|
+
</Button>
|
|
457
|
+
) : (
|
|
458
|
+
<Button variant="ghost" size="sm">
|
|
459
|
+
No data
|
|
460
|
+
</Button>
|
|
461
|
+
)}
|
|
462
|
+
<Button
|
|
463
|
+
variant="ghost"
|
|
464
|
+
size="icon"
|
|
465
|
+
onClick={() =>
|
|
466
|
+
setThemeMode((m) => (m === 'dark' ? 'light' : m === 'light' ? 'system' : 'dark'))
|
|
467
|
+
}
|
|
468
|
+
title={`Theme: ${themeMode === 'dark' ? 'Dark' : themeMode === 'light' ? 'Light' : 'System'}`}
|
|
469
|
+
>
|
|
470
|
+
{themeMode === 'dark' ? (
|
|
471
|
+
<MoonIcon size={16} weight="fill" />
|
|
472
|
+
) : themeMode === 'light' ? (
|
|
473
|
+
<SunIcon size={16} weight="fill" />
|
|
474
|
+
) : (
|
|
475
|
+
<MonitorIcon size={16} weight="fill" />
|
|
476
|
+
)}
|
|
477
|
+
</Button>
|
|
478
|
+
{activeColorColumn && (
|
|
479
|
+
<Button
|
|
480
|
+
variant="ghost"
|
|
481
|
+
size="icon"
|
|
482
|
+
onClick={() => setShowLegend((v) => !v)}
|
|
483
|
+
title={showLegend ? 'Hide legend' : 'Show legend'}
|
|
484
|
+
className={legendVisible || showLegend ? '' : 'opacity-40'}
|
|
485
|
+
>
|
|
486
|
+
<SidebarSimpleIcon size={16} weight={showLegend ? 'fill' : 'regular'} />
|
|
487
|
+
</Button>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Column row — a single column entry in the settings dropdown
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
const ColumnRow = ({
|
|
499
|
+
name,
|
|
500
|
+
dtype,
|
|
501
|
+
isColorActive,
|
|
502
|
+
onToggleColor,
|
|
503
|
+
checked,
|
|
504
|
+
onCheckedChange,
|
|
505
|
+
disabled,
|
|
506
|
+
}: {
|
|
507
|
+
name: string;
|
|
508
|
+
dtype: 'num' | 'cat';
|
|
509
|
+
isColorActive: boolean;
|
|
510
|
+
onToggleColor: () => void;
|
|
511
|
+
checked?: boolean;
|
|
512
|
+
onCheckedChange?: () => void;
|
|
513
|
+
disabled?: boolean;
|
|
514
|
+
}) => (
|
|
515
|
+
<DropdownMenuCheckboxItem
|
|
516
|
+
onSelect={(e) => e.preventDefault()}
|
|
517
|
+
className="flex items-center gap-2 pr-1"
|
|
518
|
+
checked={checked ?? false}
|
|
519
|
+
{...(onCheckedChange ? { onCheckedChange } : {})}
|
|
520
|
+
{...(disabled ? { disabled } : {})}
|
|
521
|
+
>
|
|
522
|
+
<span className="flex-1 truncate text-xs">{name}</span>
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={(e) => {
|
|
526
|
+
e.stopPropagation();
|
|
527
|
+
onToggleColor();
|
|
528
|
+
}}
|
|
529
|
+
className={`shrink-0 cursor-pointer rounded p-1 transition-[color,transform] active:scale-[0.85] ${
|
|
530
|
+
isColorActive
|
|
531
|
+
? 'bg-dtour-highlight text-dtour-bg'
|
|
532
|
+
: 'text-dtour-text-muted hover:text-dtour-highlight'
|
|
533
|
+
}`}
|
|
534
|
+
title={isColorActive ? `Stop coloring by ${name}` : `Color by ${name}`}
|
|
535
|
+
>
|
|
536
|
+
<PaintBrushIcon size={12} weight={isColorActive ? 'fill' : 'regular'} />
|
|
537
|
+
</button>
|
|
538
|
+
</DropdownMenuCheckboxItem>
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Preview step slider — generic discrete slider with local drag state
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
const PREVIEW_COUNT_STEPS: (4 | 8 | 12 | 16)[] = [4, 8, 12, 16];
|
|
546
|
+
const PREVIEW_SCALE_STEPS: (1 | 0.75 | 0.5)[] = [0.5, 0.75, 1];
|
|
547
|
+
const SCALE_LABELS: Record<number, string> = { 1: 'L', 0.75: 'M', 0.5: 'S' };
|
|
548
|
+
|
|
549
|
+
function PreviewStepSlider<T extends number>({
|
|
550
|
+
steps,
|
|
551
|
+
value,
|
|
552
|
+
onCommit,
|
|
553
|
+
formatLabel,
|
|
554
|
+
}: {
|
|
555
|
+
steps: T[];
|
|
556
|
+
value: T;
|
|
557
|
+
onCommit: (v: T) => void;
|
|
558
|
+
formatLabel?: Record<number, string>;
|
|
559
|
+
}) {
|
|
560
|
+
const [localStep, setLocalStep] = useState(() => steps.indexOf(value));
|
|
561
|
+
|
|
562
|
+
// Resync local state when value changes externally (e.g. restored settings, spec updates)
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
const idx = steps.indexOf(value);
|
|
565
|
+
if (idx !== -1) setLocalStep(idx);
|
|
566
|
+
}, [value, steps]);
|
|
567
|
+
|
|
568
|
+
const display = formatLabel?.[steps[localStep]!] ?? String(steps[localStep] ?? value);
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<>
|
|
572
|
+
<Slider
|
|
573
|
+
orientation="vertical"
|
|
574
|
+
min={0}
|
|
575
|
+
max={steps.length - 1}
|
|
576
|
+
step={1}
|
|
577
|
+
ticks={steps.length}
|
|
578
|
+
value={[localStep]}
|
|
579
|
+
onValueChange={([step]: number[]) => {
|
|
580
|
+
if (step !== undefined) setLocalStep(step);
|
|
581
|
+
}}
|
|
582
|
+
onValueCommit={([step]: number[]) => {
|
|
583
|
+
if (step !== undefined) onCommit(steps[step]!);
|
|
584
|
+
}}
|
|
585
|
+
className="h-[120px]"
|
|
586
|
+
/>
|
|
587
|
+
<span className="text-xs font-medium text-dtour-highlight">{display}</span>
|
|
588
|
+
</>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// Speed / distance step helpers
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
const SPEED_STEPS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5] as const;
|
|
597
|
+
|
|
598
|
+
const speedToStep = (speed: number): number => {
|
|
599
|
+
let best = 0;
|
|
600
|
+
let bestDist = Math.abs(speed - SPEED_STEPS[0]!);
|
|
601
|
+
for (let i = 1; i < SPEED_STEPS.length; i++) {
|
|
602
|
+
const dist = Math.abs(speed - SPEED_STEPS[i]!);
|
|
603
|
+
if (dist < bestDist) {
|
|
604
|
+
best = i;
|
|
605
|
+
bestDist = dist;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return best;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const stepToSpeed = (step: number): number => SPEED_STEPS[step] ?? 1;
|
|
612
|
+
|
|
613
|
+
const DISTANCE_STEPS = [1, 1.25, 1.5, 2, 2.5, 3, 4] as const;
|
|
614
|
+
|
|
615
|
+
const zoomToDistance = (zoom: number): number => {
|
|
616
|
+
const d = 1 / zoom;
|
|
617
|
+
let best = 0;
|
|
618
|
+
let bestDist = Math.abs(d - DISTANCE_STEPS[0]!);
|
|
619
|
+
for (let i = 1; i < DISTANCE_STEPS.length; i++) {
|
|
620
|
+
const dist = Math.abs(d - DISTANCE_STEPS[i]!);
|
|
621
|
+
if (dist < bestDist) {
|
|
622
|
+
best = i;
|
|
623
|
+
bestDist = dist;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return DISTANCE_STEPS[best]!;
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const distanceToStep = (distance: number): number => {
|
|
630
|
+
let best = 0;
|
|
631
|
+
let bestDist = Math.abs(distance - DISTANCE_STEPS[0]!);
|
|
632
|
+
for (let i = 1; i < DISTANCE_STEPS.length; i++) {
|
|
633
|
+
const dist = Math.abs(distance - DISTANCE_STEPS[i]!);
|
|
634
|
+
if (dist < bestDist) {
|
|
635
|
+
best = i;
|
|
636
|
+
bestDist = dist;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return best;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const stepToDistance = (step: number): number => DISTANCE_STEPS[step] ?? 1.25;
|