@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.
Files changed (63) hide show
  1. package/dist/Dtour.d.ts +5 -1
  2. package/dist/Dtour.d.ts.map +1 -1
  3. package/dist/DtourViewer.d.ts +4 -1
  4. package/dist/DtourViewer.d.ts.map +1 -1
  5. package/dist/components/AxisOverlay.d.ts +11 -1
  6. package/dist/components/AxisOverlay.d.ts.map +1 -1
  7. package/dist/components/CircularSlider.d.ts +21 -2
  8. package/dist/components/CircularSlider.d.ts.map +1 -1
  9. package/dist/components/DtourToolbar.d.ts +2 -1
  10. package/dist/components/DtourToolbar.d.ts.map +1 -1
  11. package/dist/components/Gallery.d.ts +3 -3
  12. package/dist/components/Gallery.d.ts.map +1 -1
  13. package/dist/components/RevertCameraButton.d.ts +6 -0
  14. package/dist/components/RevertCameraButton.d.ts.map +1 -0
  15. package/dist/components/ui/checkbox.d.ts +6 -0
  16. package/dist/components/ui/checkbox.d.ts.map +1 -0
  17. package/dist/hooks/usePlayback.d.ts +7 -5
  18. package/dist/hooks/usePlayback.d.ts.map +1 -1
  19. package/dist/hooks/useScatter.d.ts.map +1 -1
  20. package/dist/index.d.ts +4 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/layout/gallery-positions.d.ts +3 -1
  23. package/dist/layout/gallery-positions.d.ts.map +1 -1
  24. package/dist/layout/selector-size.d.ts +4 -2
  25. package/dist/layout/selector-size.d.ts.map +1 -1
  26. package/dist/lib/arcball.d.ts +21 -0
  27. package/dist/lib/arcball.d.ts.map +1 -0
  28. package/dist/lib/position-remap.d.ts +16 -0
  29. package/dist/lib/position-remap.d.ts.map +1 -0
  30. package/dist/lib/throttle-debounce.d.ts +28 -0
  31. package/dist/lib/throttle-debounce.d.ts.map +1 -0
  32. package/dist/radial-chart/RadialChart.d.ts +5 -1
  33. package/dist/radial-chart/RadialChart.d.ts.map +1 -1
  34. package/dist/spec.d.ts +32 -0
  35. package/dist/spec.d.ts.map +1 -1
  36. package/dist/state/atoms.d.ts +67 -0
  37. package/dist/state/atoms.d.ts.map +1 -1
  38. package/dist/state/spec-sync.d.ts +2 -0
  39. package/dist/state/spec-sync.d.ts.map +1 -1
  40. package/dist/viewer.css +1 -1
  41. package/dist/viewer.js +11620 -10118
  42. package/package.json +6 -1
  43. package/src/Dtour.tsx +82 -9
  44. package/src/DtourViewer.tsx +480 -100
  45. package/src/components/AxisOverlay.tsx +332 -182
  46. package/src/components/CircularSlider.tsx +363 -174
  47. package/src/components/DtourToolbar.tsx +121 -10
  48. package/src/components/Gallery.tsx +197 -39
  49. package/src/components/RevertCameraButton.tsx +39 -0
  50. package/src/components/ui/checkbox.tsx +32 -0
  51. package/src/hooks/usePlayback.ts +18 -44
  52. package/src/hooks/useScatter.ts +21 -5
  53. package/src/index.ts +16 -3
  54. package/src/layout/gallery-positions.ts +15 -4
  55. package/src/layout/selector-size.ts +24 -10
  56. package/src/lib/arcball.ts +119 -0
  57. package/src/lib/position-remap.ts +51 -0
  58. package/src/lib/throttle-debounce.ts +79 -0
  59. package/src/radial-chart/RadialChart.tsx +45 -6
  60. package/src/spec.ts +143 -0
  61. package/src/state/atoms.ts +65 -0
  62. package/src/state/spec-sync.ts +15 -0
  63. package/src/styles.css +16 -16
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ArrowCounterClockwiseIcon,
3
3
  CaretDownIcon,
4
+ ChartScatterIcon,
4
5
  CompassIcon,
5
6
  CursorIcon,
6
7
  GaugeIcon,
@@ -13,17 +14,18 @@ import {
13
14
  PauseIcon,
14
15
  PlayIcon,
15
16
  SidebarSimpleIcon,
17
+ SlidersHorizontalIcon,
16
18
  SunIcon,
17
19
  } from '@phosphor-icons/react';
18
20
  import * as Popover from '@radix-ui/react-popover';
19
21
  import { useAtom, useAtomValue, useSetAtom } from 'jotai';
20
22
  import { useCallback, useEffect, useRef, useState } from 'react';
21
23
  import { useAnimatePosition } from '../hooks/useAnimatePosition.ts';
22
- import { usePlayback } from '../hooks/usePlayback.ts';
23
24
  import { usePortalContainer } from '../portal-container.tsx';
24
25
  import {
25
26
  activeColumnsAtom,
26
27
  cameraZoomAtom,
28
+ frameLoadingsAtom,
27
29
  grandExitTargetAtom,
28
30
  guidedSuspendedAtom,
29
31
  legendVisibleAtom,
@@ -32,15 +34,22 @@ import {
32
34
  previewCountAtom,
33
35
  previewScaleAtom,
34
36
  selectedKeyframeAtom,
37
+ showAxesAtom,
38
+ showFrameLoadingsAtom,
39
+ showFrameNumbersAtom,
35
40
  showLegendAtom,
41
+ showTourDescriptionAtom,
42
+ sliderSpacingAtom,
36
43
  themeModeAtom,
37
44
  tourByAtom,
45
+ tourDescriptionAtom,
38
46
  tourPlayingAtom,
39
47
  tourSpeedAtom,
40
48
  viewModeAtom,
41
49
  } from '../state/atoms.ts';
42
50
  import { Logo } from './Logo.tsx';
43
51
  import { Button } from './ui/button.tsx';
52
+ import { Checkbox } from './ui/checkbox.tsx';
44
53
  import {
45
54
  DropdownMenu,
46
55
  DropdownMenuCheckboxItem,
@@ -64,9 +73,10 @@ const DEFAULT_COLOR: [number, number, number] = [0.25, 0.5, 0.9];
64
73
 
65
74
  export type DtourToolbarProps = {
66
75
  onLoadData?: ((data: ArrayBuffer, fileName: string) => void) | undefined;
76
+ onLogoClick?: (() => void) | undefined;
67
77
  };
68
78
 
69
- export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
79
+ export const DtourToolbar = ({ onLoadData, onLogoClick }: DtourToolbarProps) => {
70
80
  const [playing, setPlaying] = useAtom(tourPlayingAtom);
71
81
  const [speed, setSpeed] = useAtom(tourSpeedAtom);
72
82
  const [zoom, setZoom] = useAtom(cameraZoomAtom);
@@ -82,14 +92,18 @@ export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
82
92
  const [showLegend, setShowLegend] = useAtom(showLegendAtom);
83
93
  const legendVisible = useAtomValue(legendVisibleAtom);
84
94
  const [themeMode, setThemeMode] = useAtom(themeModeAtom);
95
+ const [showAxes, setShowAxes] = useAtom(showAxesAtom);
96
+ const [showFrameNumbers, setShowFrameNumbers] = useAtom(showFrameNumbersAtom);
97
+ const [showFrameLoadings, setShowFrameLoadings] = useAtom(showFrameLoadingsAtom);
98
+ const hasFrameLoadings = useAtomValue(frameLoadingsAtom) !== null;
99
+ const hasTourDescription = useAtomValue(tourDescriptionAtom) !== null;
85
100
  const [tourBy, setTourBy] = useAtom(tourByAtom);
101
+ const [sliderSpacing, setSliderSpacing] = useAtom(sliderSpacingAtom);
102
+ const [showTourDescription, setShowTourDescription] = useAtom(showTourDescriptionAtom);
86
103
 
87
104
  const portalContainer = usePortalContainer();
88
105
  const fileInputRef = useRef<HTMLInputElement>(null);
89
106
 
90
- // Activate the rAF playback loop
91
- usePlayback();
92
-
93
107
  const { animateTo, cancelAnimation } = useAnimatePosition();
94
108
 
95
109
  const handlePlayPause = useCallback(() => {
@@ -167,12 +181,29 @@ export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
167
181
 
168
182
  {/* Left: branding + mode switcher */}
169
183
  <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 />
184
+ {onLogoClick ? (
185
+ <Button
186
+ variant="ghost"
187
+ size="sm"
188
+ onClick={onLogoClick}
189
+ className="-ml-1 -mr-1 relative font-semibold tracking-wide text-dtour-highlight"
190
+ >
191
+ <div className="opacity-0 px-2 pointer-events-none">dtour</div>
192
+ <div
193
+ className="absolute top-0 left-2 bottom-0 right-2 flex items-center justify-center"
194
+ data-logo-target
195
+ >
196
+ <Logo />
197
+ </div>
198
+ </Button>
199
+ ) : (
200
+ <div className="relative text-sm font-semibold tracking-wide text-dtour-highlight">
201
+ <div className="opacity-0 pointer-events-none">dtour</div>
202
+ <div className="absolute inset-0" data-logo-target>
203
+ <Logo />
204
+ </div>
174
205
  </div>
175
- </div>
206
+ )}
176
207
  <div className="ml-2 flex items-center overflow-hidden rounded-md border border-dtour-surface">
177
208
  {/* Guided button — expands to include Dims/PCA sub-toggle when active */}
178
209
  <div
@@ -253,6 +284,62 @@ export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
253
284
 
254
285
  {/* Center: playback controls (guided mode) / speed (grand mode) */}
255
286
  <div className="flex items-center gap-1">
287
+ {viewMode === 'guided' && (
288
+ <DropdownMenu>
289
+ <DropdownMenuTrigger asChild>
290
+ <Button variant="ghost" size="icon" title="Tour settings">
291
+ <SlidersHorizontalIcon size={16} />
292
+ </Button>
293
+ </DropdownMenuTrigger>
294
+ <DropdownMenuContent align="center">
295
+ <DropdownMenuItem
296
+ className="gap-4"
297
+ onSelect={(e) => {
298
+ e.preventDefault();
299
+ setSliderSpacing(sliderSpacing === 'equal' ? 'geodesic' : 'equal');
300
+ }}
301
+ >
302
+ <span className="flex-1 text-xs">Geodesic spacing</span>
303
+ <Checkbox
304
+ checked={sliderSpacing === 'geodesic'}
305
+ onCheckedChange={() =>
306
+ setSliderSpacing(sliderSpacing === 'equal' ? 'geodesic' : 'equal')
307
+ }
308
+ />
309
+ </DropdownMenuItem>
310
+ {hasFrameLoadings && (
311
+ <DropdownMenuItem
312
+ className="gap-4"
313
+ onSelect={(e) => {
314
+ e.preventDefault();
315
+ setShowFrameLoadings((v) => !v);
316
+ }}
317
+ >
318
+ <span className="flex-1 text-xs">Feature correlations</span>
319
+ <Checkbox
320
+ checked={showFrameLoadings}
321
+ onCheckedChange={() => setShowFrameLoadings((v) => !v)}
322
+ />
323
+ </DropdownMenuItem>
324
+ )}
325
+ {hasTourDescription && (
326
+ <DropdownMenuItem
327
+ className="gap-4"
328
+ onSelect={(e) => {
329
+ e.preventDefault();
330
+ setShowTourDescription((v) => !v);
331
+ }}
332
+ >
333
+ <span className="flex-1 text-xs">Tour description</span>
334
+ <Checkbox
335
+ checked={showTourDescription}
336
+ onCheckedChange={() => setShowTourDescription((v) => !v)}
337
+ />
338
+ </DropdownMenuItem>
339
+ )}
340
+ </DropdownMenuContent>
341
+ </DropdownMenu>
342
+ )}
256
343
  <Popover.Root>
257
344
  <Popover.Trigger asChild>
258
345
  <Button variant="ghost" size="icon" title={`Zoom: ${zoomToDistance(zoom)}x`}>
@@ -376,11 +463,35 @@ export const DtourToolbar = ({ onLoadData }: DtourToolbarProps) => {
376
463
  />
377
464
  </div>
378
465
  </div>
466
+ <div className="my-1 h-px bg-dtour-border" />
467
+ <div
468
+ className="flex items-center gap-4 cursor-pointer select-none"
469
+ onClick={() => setShowFrameNumbers((v) => !v)}
470
+ onKeyDown={undefined}
471
+ >
472
+ <span className="flex-1 text-xs text-dtour-text-muted">Numbers</span>
473
+ <Checkbox
474
+ checked={showFrameNumbers}
475
+ onCheckedChange={() => setShowFrameNumbers((v) => !v)}
476
+ />
477
+ </div>
379
478
  </div>
380
479
  </Popover.Content>
381
480
  </Popover.Portal>
382
481
  </Popover.Root>
383
482
  )}
483
+
484
+ {viewMode === 'guided' && (
485
+ <Button
486
+ variant="ghost"
487
+ size="icon"
488
+ onClick={() => setShowAxes((v) => !v)}
489
+ title={showAxes ? 'Hide axes' : 'Show axes'}
490
+ className={showAxes ? '' : 'opacity-40'}
491
+ >
492
+ <ChartScatterIcon size={16} weight={showAxes ? 'fill' : 'regular'} />
493
+ </Button>
494
+ )}
384
495
  </div>
385
496
 
386
497
  {/* Right: data info + settings */}
@@ -1,16 +1,26 @@
1
+ import { ArrowsLeftRightIcon, EqualsIcon } from '@phosphor-icons/react';
1
2
  import { useAtom, useAtomValue, useSetAtom } from 'jotai';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
3
4
  import { useAnimatePosition } from '../hooks/useAnimatePosition.ts';
4
- import { computeGallerySizes } from '../layout/gallery-positions.ts';
5
+ import { LOADING_BAR_HEIGHT, computeGallerySizes } from '../layout/gallery-positions.ts';
5
6
  import { cn } from '../lib/utils.ts';
7
+ import type { FrameLoading } from '../spec.ts';
6
8
  import {
9
+ arcLengthsAtom,
10
+ currentKeyframeAtom,
11
+ frameLoadingsAtom,
7
12
  guidedSuspendedAtom,
13
+ hoveredKeyframeAtom,
14
+ previewCentersAtom,
8
15
  previewCountAtom,
9
16
  previewScaleAtom,
10
17
  selectedKeyframeAtom,
18
+ showFrameLoadingsAtom,
19
+ showFrameNumbersAtom,
20
+ tourFrameDescriptionAtom,
11
21
  tourPlayingAtom,
12
- tourPositionAtom,
13
22
  } from '../state/atoms.ts';
23
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx';
14
24
 
15
25
  export type GalleryProps = {
16
26
  /** Fixed pool of preview canvas elements (created at scatter init). */
@@ -19,37 +29,54 @@ export type GalleryProps = {
19
29
  containerWidth: number;
20
30
  /** Container height (px). */
21
31
  containerHeight: number;
22
- /** Is toolbar visible? */
23
- isToolbarVisible: boolean;
32
+ /** Effective toolbar height in px (0 when hidden). */
33
+ toolbarHeight: number;
24
34
  };
25
35
 
36
+ // ---------------------------------------------------------------------------
37
+ // Loading pill helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Whether the two loadings in a pair have the same sign (co-vary vs contrast). */
41
+ function sameSign(pairs: FrameLoading[]): boolean {
42
+ if (pairs.length < 2) return true;
43
+ return pairs[0]![1] * pairs[1]![1] >= 0;
44
+ }
45
+
26
46
  export const Gallery = ({
27
47
  previewCanvases,
28
48
  containerWidth,
29
49
  containerHeight,
30
- isToolbarVisible,
50
+ toolbarHeight,
31
51
  }: GalleryProps) => {
32
52
  const previewCount = useAtomValue(previewCountAtom);
33
53
  const previewScale = useAtomValue(previewScaleAtom);
34
- const position = useAtomValue(tourPositionAtom);
54
+ const currentKeyframe = useAtomValue(currentKeyframeAtom);
35
55
  const [selectedKeyframe, setSelectedKeyframe] = useAtom(selectedKeyframeAtom);
36
56
  const setPlaying = useSetAtom(tourPlayingAtom);
37
57
  const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
58
+ const arcLengths = useAtomValue(arcLengthsAtom);
59
+ const [hoveredIndex, setHoveredIndex] = useAtom(hoveredKeyframeAtom);
60
+ const showFrameNumbers = useAtomValue(showFrameNumbersAtom);
61
+ const showFrameLoadings = useAtomValue(showFrameLoadingsAtom);
62
+ const frameLoadings = useAtomValue(frameLoadingsAtom);
63
+ const tourFrameDescription = useAtomValue(tourFrameDescriptionAtom);
64
+ const setPreviewCenters = useSetAtom(previewCentersAtom);
38
65
  const { animateTo } = useAnimatePosition();
66
+ const galleryRef = useRef<HTMLDivElement>(null);
39
67
  const wrapperRefs = useRef<(HTMLDivElement | null)[]>([]);
40
- const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
68
+
69
+ // Whether loading pills are actually visible (data available + user toggle on)
70
+ const loadingsVisible = showFrameLoadings && frameLoadings !== null && frameLoadings.length > 0;
41
71
 
42
72
  // Grid area = container minus its CSS insets.
43
- // When the toolbar is visible overlayOffsetY shifts the wrapper down by
44
- // toolbarHeight/2 = 20px. Bump the top & bottom CSS insets by the same
45
- // amount so the *visual* padding from the visible edges stays at 32px.
46
- const verticalInset = isToolbarVisible ? 36 : 16; // 16 + toolbarHeight/2
47
- const gridWidth = containerWidth - 32; // left-4 + right-4 = 32px
73
+ const verticalInset = 16 + toolbarHeight / 2;
74
+ const gridWidth = containerWidth - 32;
48
75
  const gridHeight = containerHeight - 2 * verticalInset;
49
76
 
50
77
  const { gridTemplateColumns, gridTemplateRows, sizes } = useMemo(
51
- () => computeGallerySizes(gridWidth, gridHeight, previewCount, previewScale),
52
- [gridWidth, gridHeight, previewCount, previewScale],
78
+ () => computeGallerySizes(gridWidth, gridHeight, previewCount, previewScale, loadingsVisible),
79
+ [gridWidth, gridHeight, previewCount, previewScale, loadingsVisible],
53
80
  );
54
81
 
55
82
  // Adopt each canvas into its wrapper div (once, on mount)
@@ -63,7 +90,29 @@ export const Gallery = ({
63
90
  }
64
91
  }, [previewCanvases]);
65
92
 
66
- const currentKeyframe = Math.round(position * previewCount) % previewCount;
93
+ // Measure preview center positions relative to the container center.
94
+ useEffect(() => {
95
+ const galleryEl = galleryRef.current;
96
+ if (!galleryEl) return;
97
+ const galleryRect = galleryEl.getBoundingClientRect();
98
+ const centers: { x: number; y: number; size: number }[] = [];
99
+ for (let i = 0; i < previewCount; i++) {
100
+ const wrapper = wrapperRefs.current[i];
101
+ if (!wrapper) {
102
+ centers.push({ x: 0, y: 0, size: sizes[i] ?? 0 });
103
+ continue;
104
+ }
105
+ const r = wrapper.getBoundingClientRect();
106
+ const cx = r.left - galleryRect.left + r.width / 2;
107
+ const cy = r.top - galleryRect.top + r.height / 2;
108
+ centers.push({
109
+ x: cx + 16 - containerWidth / 2,
110
+ y: cy + verticalInset - containerHeight / 2,
111
+ size: sizes[i] ?? r.width,
112
+ });
113
+ }
114
+ setPreviewCenters(centers);
115
+ }, [containerWidth, containerHeight, previewCount, sizes, verticalInset, setPreviewCenters]);
67
116
 
68
117
  const getBorderColor = (i: number): string | undefined => {
69
118
  const isActive = i === selectedKeyframe || i === currentKeyframe;
@@ -91,15 +140,17 @@ export const Gallery = ({
91
140
  setGuidedSuspended(false);
92
141
  setSelectedKeyframe(i);
93
142
  setPlaying(false);
94
- animateTo(i / previewCount);
143
+ const target = arcLengths && i < arcLengths.length ? arcLengths[i]! : i / previewCount;
144
+ animateTo(target);
95
145
  },
96
- [previewCount, setSelectedKeyframe, setPlaying, setGuidedSuspended, animateTo],
146
+ [previewCount, arcLengths, setSelectedKeyframe, setPlaying, setGuidedSuspended, animateTo],
97
147
  );
98
148
 
99
149
  const k = previewCount / 4;
100
150
 
101
151
  return (
102
152
  <div
153
+ ref={galleryRef}
103
154
  className="absolute left-4 right-4 grid gap-8 justify-between content-between pointer-events-none"
104
155
  style={{ top: verticalInset, bottom: verticalInset, gridTemplateColumns, gridTemplateRows }}
105
156
  >
@@ -109,19 +160,15 @@ export const Gallery = ({
109
160
  let col: number;
110
161
  let row: number;
111
162
  if (i < k) {
112
- // top edge: left → right
113
163
  row = 0;
114
164
  col = i;
115
165
  } else if (i < 2 * k) {
116
- // right edge: top → bottom
117
166
  row = i - k;
118
167
  col = k;
119
168
  } else if (i < 3 * k) {
120
- // bottom edge: right → left
121
169
  row = k;
122
170
  col = 3 * k - i;
123
171
  } else {
124
- // left edge: bottom → top
125
172
  row = 4 * k - i;
126
173
  col = 0;
127
174
  }
@@ -131,6 +178,12 @@ export const Gallery = ({
131
178
  const horizontalAlignment =
132
179
  col === 0 ? 'justify-start' : col < k ? 'justify-center' : 'justify-end';
133
180
 
181
+ // For bottom-edge previews, put loading bar above (flex-col-reverse)
182
+ const isBottomEdge = row === k;
183
+ const loadingPairs: FrameLoading[] | null =
184
+ loadingsVisible && frameLoadings && i < frameLoadings.length ? frameLoadings[i]! : null;
185
+ const hasLoadingPills = loadingPairs !== null && loadingPairs.length >= 2;
186
+
134
187
  return (
135
188
  <div
136
189
  // biome-ignore lint/suspicious/noArrayIndexKey: fixed pool keyed by slot index
@@ -139,25 +192,130 @@ export const Gallery = ({
139
192
  style={{ gridColumn: col + 1, gridRow: row + 1 }}
140
193
  >
141
194
  <div
142
- ref={(el) => {
143
- wrapperRefs.current[i] = el;
144
- }}
145
- onClick={visible ? () => handleClick(i) : undefined}
146
- onKeyDown={undefined}
147
- onMouseEnter={visible ? () => setHoveredIndex(i) : undefined}
148
- onMouseLeave={visible ? () => setHoveredIndex(null) : undefined}
149
195
  className={cn(
150
- 'pointer-events-auto overflow-hidden border border-dtour-border rounded transition-[border-color,border-width,box-shadow] duration-200 ease-in-out z-20',
151
- visible ? 'block cursor-pointer' : 'hidden',
196
+ 'flex pointer-events-auto group/preview',
197
+ isBottomEdge ? 'flex-col-reverse' : 'flex-col',
198
+ visible ? '' : 'hidden',
152
199
  )}
153
- style={{
154
- width: visible ? sizes[i] : 0,
155
- height: visible ? sizes[i] : 0,
156
- borderColor: getBorderColor(i),
157
- borderWidth: getBorderWidth(i),
158
- boxShadow: getBoxShadow(i),
159
- }}
160
- />
200
+ onMouseEnter={visible ? () => setHoveredIndex(i) : undefined}
201
+ onMouseLeave={visible ? () => setHoveredIndex(null) : undefined}
202
+ >
203
+ <div
204
+ ref={(el) => {
205
+ wrapperRefs.current[i] = el;
206
+ }}
207
+ onClick={visible ? () => handleClick(i) : undefined}
208
+ onKeyDown={undefined}
209
+ className={cn(
210
+ 'overflow-hidden border border-dtour-border transition-[border-color,border-width,box-shadow] duration-200 ease-in-out z-20 relative group',
211
+ hasLoadingPills ? (isBottomEdge ? 'rounded-b' : 'rounded-t') : 'rounded',
212
+ visible ? 'block cursor-pointer' : 'hidden',
213
+ )}
214
+ style={{
215
+ width: visible ? sizes[i] : 0,
216
+ height: visible ? sizes[i] : 0,
217
+ borderColor: getBorderColor(i),
218
+ borderWidth: getBorderWidth(i),
219
+ boxShadow: getBoxShadow(i),
220
+ }}
221
+ >
222
+ {visible && showFrameNumbers && (
223
+ <span
224
+ className={cn(
225
+ 'absolute text-xs leading-none text-white pointer-events-none transition-opacity duration-200',
226
+ row === 0 ? 'top-0.5' : row === k ? 'bottom-0.5' : 'top-1/2 -translate-y-1/2',
227
+ col === 0 ? 'left-1' : col === k ? 'right-1' : 'left-1/2 -translate-x-1/2',
228
+ i === selectedKeyframe || i === currentKeyframe
229
+ ? 'opacity-100'
230
+ : 'opacity-40 group-hover:opacity-100',
231
+ )}
232
+ >
233
+ {i + 1}
234
+ </span>
235
+ )}
236
+ </div>
237
+ {/* Loading pills: [name1] [≠ or =] [name2] */}
238
+ {visible &&
239
+ loadingPairs &&
240
+ loadingPairs.length >= 2 &&
241
+ (() => {
242
+ const [n0] = loadingPairs[0]!;
243
+ const [n1] = loadingPairs[1]!;
244
+ const same = sameSign(loadingPairs);
245
+ const isActive = i === selectedKeyframe || i === currentKeyframe;
246
+ return (
247
+ <TooltipProvider>
248
+ <Tooltip>
249
+ <TooltipTrigger asChild>
250
+ <div
251
+ className={cn(
252
+ 'flex items-center cursor-default select-none transition-[color,background-color,border-color] duration-200 relative z-20',
253
+ 'border border-dtour-border',
254
+ isBottomEdge ? 'border-b-0 rounded-t-sm' : 'border-t-0 rounded-b-sm',
255
+ )}
256
+ style={{
257
+ width: sizes[i],
258
+ height: LOADING_BAR_HEIGHT,
259
+ borderColor: getBorderColor(i),
260
+ backgroundColor:
261
+ isActive || i === hoveredIndex
262
+ ? 'var(--color-dtour-highlight)'
263
+ : 'var(--color-dtour-border)',
264
+ }}
265
+ >
266
+ <div className="flex-1 flex items-center justify-center rounded-l-sm overflow-hidden h-full">
267
+ <span
268
+ className={cn(
269
+ 'text-[10px] transition-colors duration-200 truncate px-1',
270
+ isActive || i === hoveredIndex
271
+ ? 'text-dtour-bg'
272
+ : 'text-dtour-highlight/70',
273
+ )}
274
+ >
275
+ {n0}
276
+ </span>
277
+ </div>
278
+ <span
279
+ className={cn(
280
+ 'text-[10px] leading-none transition-colors duration-200 px-0.5 shrink-0',
281
+ isActive || i === hoveredIndex
282
+ ? 'text-dtour-bg'
283
+ : 'text-dtour-highlight/70',
284
+ )}
285
+ >
286
+ {same ? (
287
+ <EqualsIcon size={10} weight="bold" />
288
+ ) : (
289
+ <ArrowsLeftRightIcon size={10} weight="bold" />
290
+ )}
291
+ </span>
292
+ <div className="flex-1 flex items-center justify-center rounded-r-sm overflow-hidden h-full">
293
+ <span
294
+ className={cn(
295
+ 'text-[10px] transition-colors duration-200 truncate px-1',
296
+ isActive || i === hoveredIndex
297
+ ? 'text-dtour-bg'
298
+ : 'text-dtour-highlight/70',
299
+ )}
300
+ >
301
+ {n1}
302
+ </span>
303
+ </div>
304
+ </div>
305
+ </TooltipTrigger>
306
+ <TooltipContent side={isBottomEdge ? 'top' : 'bottom'} sideOffset={0}>
307
+ {tourFrameDescription
308
+ ? tourFrameDescription
309
+ .replace('{dim1}', n0)
310
+ .replace('{dim2}', n1)
311
+ .replace('{relation}', same ? 'co-varying' : 'contrasting')
312
+ : `${same ? 'Co-varying' : 'Contrasting'} ${n0} and ${n1}`}
313
+ </TooltipContent>
314
+ </Tooltip>
315
+ </TooltipProvider>
316
+ );
317
+ })()}
318
+ </div>
161
319
  </div>
162
320
  );
163
321
  })}
@@ -0,0 +1,39 @@
1
+ import { ArrowCounterClockwiseIcon } from '@phosphor-icons/react';
2
+ import { useAtomValue } from 'jotai';
3
+ import { useEffect, useState } from 'react';
4
+ import { cn } from '../lib/utils.ts';
5
+ import { is3dRotatedAtom } from '../state/atoms.ts';
6
+ import { Button } from './ui/button.tsx';
7
+
8
+ type RevertCameraButtonProps = {
9
+ onRevert: () => void;
10
+ };
11
+
12
+ export const RevertCameraButton = ({ onRevert }: RevertCameraButtonProps) => {
13
+ const is3dRotated = useAtomValue(is3dRotatedAtom);
14
+ // Delay visibility by a frame so the CSS transition triggers
15
+ const [visible, setVisible] = useState(false);
16
+ useEffect(() => {
17
+ if (is3dRotated) {
18
+ const id = requestAnimationFrame(() => setVisible(true));
19
+ return () => cancelAnimationFrame(id);
20
+ }
21
+ setVisible(false);
22
+ }, [is3dRotated]);
23
+
24
+ if (!is3dRotated) return null;
25
+
26
+ return (
27
+ <Button
28
+ variant="ghost"
29
+ className={cn(
30
+ 'flex items-center gap-2 absolute bottom-8 left-1/2 -translate-x-1/2 text-xs cursor-pointer px-3 py-2 bg-dtour-surface/60 hover:bg-dtour-surface backdrop-blur-sm transition-opacity ease-out duration-250',
31
+ visible ? 'opacity-100' : 'opacity-0',
32
+ )}
33
+ onClick={onRevert}
34
+ >
35
+ <ArrowCounterClockwiseIcon size={12} />
36
+ Revert camera to adjust projection
37
+ </Button>
38
+ );
39
+ };
@@ -0,0 +1,32 @@
1
+ import { CheckIcon } from '@phosphor-icons/react';
2
+ import type { ComponentPropsWithoutRef } from 'react';
3
+ import { cn } from '../../lib/utils.ts';
4
+
5
+ export const Checkbox = ({
6
+ checked,
7
+ onCheckedChange,
8
+ className,
9
+ ...props
10
+ }: {
11
+ checked: boolean;
12
+ onCheckedChange: (checked: boolean) => void;
13
+ } & Omit<ComponentPropsWithoutRef<'button'>, 'onClick' | 'role'>) => (
14
+ <button
15
+ type="button"
16
+ // biome-ignore lint/a11y/useSemanticElements: <explanation>
17
+ role="checkbox"
18
+ aria-checked={checked}
19
+ onClick={(e) => {
20
+ e.stopPropagation();
21
+ onCheckedChange(!checked);
22
+ }}
23
+ className={cn(
24
+ 'inline-flex h-4 w-4 shrink-0 items-center justify-center transition-colors',
25
+ checked ? 'text-dtour-text' : 'text-transparent',
26
+ className,
27
+ )}
28
+ {...props}
29
+ >
30
+ <CheckIcon size={14} weight="bold" />
31
+ </button>
32
+ );