@dtour/viewer 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Dtour.d.ts +5 -1
- package/dist/Dtour.d.ts.map +1 -1
- package/dist/DtourViewer.d.ts +4 -1
- package/dist/DtourViewer.d.ts.map +1 -1
- package/dist/components/AxisOverlay.d.ts +11 -1
- package/dist/components/AxisOverlay.d.ts.map +1 -1
- package/dist/components/CircularSlider.d.ts +21 -2
- package/dist/components/CircularSlider.d.ts.map +1 -1
- package/dist/components/DtourToolbar.d.ts +2 -1
- package/dist/components/DtourToolbar.d.ts.map +1 -1
- package/dist/components/Gallery.d.ts +3 -3
- package/dist/components/Gallery.d.ts.map +1 -1
- package/dist/components/RevertCameraButton.d.ts +6 -0
- package/dist/components/RevertCameraButton.d.ts.map +1 -0
- package/dist/components/ui/checkbox.d.ts +6 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/hooks/usePlayback.d.ts +7 -5
- package/dist/hooks/usePlayback.d.ts.map +1 -1
- package/dist/hooks/useScatter.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/layout/gallery-positions.d.ts +3 -1
- package/dist/layout/gallery-positions.d.ts.map +1 -1
- package/dist/layout/selector-size.d.ts +4 -2
- package/dist/layout/selector-size.d.ts.map +1 -1
- package/dist/lib/arcball.d.ts +21 -0
- package/dist/lib/arcball.d.ts.map +1 -0
- package/dist/lib/position-remap.d.ts +16 -0
- package/dist/lib/position-remap.d.ts.map +1 -0
- package/dist/lib/throttle-debounce.d.ts +28 -0
- package/dist/lib/throttle-debounce.d.ts.map +1 -0
- package/dist/radial-chart/RadialChart.d.ts +5 -1
- package/dist/radial-chart/RadialChart.d.ts.map +1 -1
- package/dist/spec.d.ts +32 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/state/atoms.d.ts +67 -0
- package/dist/state/atoms.d.ts.map +1 -1
- package/dist/state/spec-sync.d.ts +2 -0
- package/dist/state/spec-sync.d.ts.map +1 -1
- package/dist/viewer.css +1 -1
- package/dist/viewer.js +11620 -10118
- package/package.json +6 -1
- package/src/Dtour.tsx +82 -9
- package/src/DtourViewer.tsx +480 -100
- package/src/components/AxisOverlay.tsx +332 -182
- package/src/components/CircularSlider.tsx +363 -174
- package/src/components/DtourToolbar.tsx +121 -10
- package/src/components/Gallery.tsx +197 -39
- package/src/components/RevertCameraButton.tsx +39 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/hooks/usePlayback.ts +18 -44
- package/src/hooks/useScatter.ts +21 -5
- package/src/index.ts +16 -3
- package/src/layout/gallery-positions.ts +15 -4
- package/src/layout/selector-size.ts +24 -10
- package/src/lib/arcball.ts +119 -0
- package/src/lib/position-remap.ts +51 -0
- package/src/lib/throttle-debounce.ts +79 -0
- package/src/radial-chart/RadialChart.tsx +45 -6
- package/src/spec.ts +143 -0
- package/src/state/atoms.ts +65 -0
- package/src/state/spec-sync.ts +15 -0
- package/src/styles.css +16 -16
|
@@ -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
|
-
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
-
|
|
50
|
+
toolbarHeight,
|
|
31
51
|
}: GalleryProps) => {
|
|
32
52
|
const previewCount = useAtomValue(previewCountAtom);
|
|
33
53
|
const previewScale = useAtomValue(previewScaleAtom);
|
|
34
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
196
|
+
'flex pointer-events-auto group/preview',
|
|
197
|
+
isBottomEdge ? 'flex-col-reverse' : 'flex-col',
|
|
198
|
+
visible ? '' : 'hidden',
|
|
152
199
|
)}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
);
|