@dtour/viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/Dtour.d.ts +46 -0
  2. package/dist/Dtour.d.ts.map +1 -0
  3. package/dist/DtourViewer.d.ts +24 -0
  4. package/dist/DtourViewer.d.ts.map +1 -0
  5. package/dist/components/AxisOverlay.d.ts +9 -0
  6. package/dist/components/AxisOverlay.d.ts.map +1 -0
  7. package/dist/components/CircularSlider.d.ts +16 -0
  8. package/dist/components/CircularSlider.d.ts.map +1 -0
  9. package/dist/components/ColorLegend.d.ts +2 -0
  10. package/dist/components/ColorLegend.d.ts.map +1 -0
  11. package/dist/components/DtourToolbar.d.ts +5 -0
  12. package/dist/components/DtourToolbar.d.ts.map +1 -0
  13. package/dist/components/Gallery.d.ts +12 -0
  14. package/dist/components/Gallery.d.ts.map +1 -0
  15. package/dist/components/LassoOverlay.d.ts +9 -0
  16. package/dist/components/LassoOverlay.d.ts.map +1 -0
  17. package/dist/components/Logo.d.ts +2 -0
  18. package/dist/components/Logo.d.ts.map +1 -0
  19. package/dist/components/ui/button.d.ts +12 -0
  20. package/dist/components/ui/button.d.ts.map +1 -0
  21. package/dist/components/ui/dropdown-menu.d.ts +10 -0
  22. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  23. package/dist/components/ui/slider.d.ts +6 -0
  24. package/dist/components/ui/slider.d.ts.map +1 -0
  25. package/dist/components/ui/tooltip.d.ts +8 -0
  26. package/dist/components/ui/tooltip.d.ts.map +1 -0
  27. package/dist/hooks/useAnimatePosition.d.ts +13 -0
  28. package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
  29. package/dist/hooks/useGrandTour.d.ts +14 -0
  30. package/dist/hooks/useGrandTour.d.ts.map +1 -0
  31. package/dist/hooks/useLongPressIndicator.d.ts +5 -0
  32. package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
  33. package/dist/hooks/useModeCycling.d.ts +12 -0
  34. package/dist/hooks/useModeCycling.d.ts.map +1 -0
  35. package/dist/hooks/usePlayback.d.ts +9 -0
  36. package/dist/hooks/usePlayback.d.ts.map +1 -0
  37. package/dist/hooks/useScatter.d.ts +10 -0
  38. package/dist/hooks/useScatter.d.ts.map +1 -0
  39. package/dist/hooks/useSystemTheme.d.ts +6 -0
  40. package/dist/hooks/useSystemTheme.d.ts.map +1 -0
  41. package/dist/index.d.ts +16 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/layout/gallery-positions.d.ts +38 -0
  44. package/dist/layout/gallery-positions.d.ts.map +1 -0
  45. package/dist/layout/selector-size.d.ts +15 -0
  46. package/dist/layout/selector-size.d.ts.map +1 -0
  47. package/dist/lib/color-utils.d.ts +7 -0
  48. package/dist/lib/color-utils.d.ts.map +1 -0
  49. package/dist/lib/gram-schmidt.d.ts +9 -0
  50. package/dist/lib/gram-schmidt.d.ts.map +1 -0
  51. package/dist/lib/utils.d.ts +3 -0
  52. package/dist/lib/utils.d.ts.map +1 -0
  53. package/dist/portal-container.d.ts +10 -0
  54. package/dist/portal-container.d.ts.map +1 -0
  55. package/dist/radial-chart/RadialChart.d.ts +13 -0
  56. package/dist/radial-chart/RadialChart.d.ts.map +1 -0
  57. package/dist/radial-chart/arc-path.d.ts +23 -0
  58. package/dist/radial-chart/arc-path.d.ts.map +1 -0
  59. package/dist/radial-chart/index.d.ts +5 -0
  60. package/dist/radial-chart/index.d.ts.map +1 -0
  61. package/dist/radial-chart/parse-metrics.d.ts +10 -0
  62. package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
  63. package/dist/radial-chart/types.d.ts +23 -0
  64. package/dist/radial-chart/types.d.ts.map +1 -0
  65. package/dist/spec.d.ts +42 -0
  66. package/dist/spec.d.ts.map +1 -0
  67. package/dist/state/atoms.d.ts +150 -0
  68. package/dist/state/atoms.d.ts.map +1 -0
  69. package/dist/state/spec-sync.d.ts +5 -0
  70. package/dist/state/spec-sync.d.ts.map +1 -0
  71. package/dist/viewer.css +3 -0
  72. package/dist/viewer.js +14501 -0
  73. package/dist/views.d.ts +30 -0
  74. package/dist/views.d.ts.map +1 -0
  75. package/package.json +48 -0
  76. package/src/Dtour.tsx +300 -0
  77. package/src/DtourViewer.tsx +541 -0
  78. package/src/components/AxisOverlay.tsx +224 -0
  79. package/src/components/CircularSlider.tsx +202 -0
  80. package/src/components/ColorLegend.tsx +178 -0
  81. package/src/components/DtourToolbar.tsx +642 -0
  82. package/src/components/Gallery.tsx +166 -0
  83. package/src/components/LassoOverlay.tsx +240 -0
  84. package/src/components/Logo.tsx +37 -0
  85. package/src/components/ui/button.tsx +36 -0
  86. package/src/components/ui/dropdown-menu.tsx +92 -0
  87. package/src/components/ui/slider.tsx +89 -0
  88. package/src/components/ui/tooltip.tsx +45 -0
  89. package/src/hooks/useAnimatePosition.ts +102 -0
  90. package/src/hooks/useGrandTour.ts +176 -0
  91. package/src/hooks/useLongPressIndicator.ts +342 -0
  92. package/src/hooks/useModeCycling.ts +64 -0
  93. package/src/hooks/usePlayback.ts +54 -0
  94. package/src/hooks/useScatter.ts +162 -0
  95. package/src/hooks/useSystemTheme.ts +19 -0
  96. package/src/index.ts +55 -0
  97. package/src/layout/gallery-positions.ts +105 -0
  98. package/src/layout/selector-size.ts +135 -0
  99. package/src/lib/color-utils.ts +22 -0
  100. package/src/lib/gram-schmidt.ts +41 -0
  101. package/src/lib/utils.ts +4 -0
  102. package/src/portal-container.tsx +14 -0
  103. package/src/radial-chart/RadialChart.tsx +184 -0
  104. package/src/radial-chart/arc-path.ts +80 -0
  105. package/src/radial-chart/index.ts +4 -0
  106. package/src/radial-chart/parse-metrics.ts +99 -0
  107. package/src/radial-chart/types.ts +23 -0
  108. package/src/spec.ts +48 -0
  109. package/src/state/atoms.ts +169 -0
  110. package/src/state/spec-sync.ts +190 -0
  111. package/src/styles.css +44 -0
  112. package/src/views.ts +76 -0
  113. package/tsconfig.json +12 -0
  114. package/vite.config.ts +21 -0
@@ -0,0 +1,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 &times;{' '}
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;