@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
package/src/hooks/usePlayback.ts
CHANGED
|
@@ -1,54 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
tourPlayingAtom,
|
|
6
|
-
tourPositionAtom,
|
|
7
|
-
tourSpeedAtom,
|
|
8
|
-
} from '../state/atoms.ts';
|
|
1
|
+
import type { ScatterInstance } from '@dtour/scatter';
|
|
2
|
+
import { useAtomValue } from 'jotai';
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { tourDirectionAtom, tourPlayingAtom, tourSpeedAtom } from '../state/atoms.ts';
|
|
9
5
|
|
|
10
6
|
/**
|
|
11
|
-
*
|
|
7
|
+
* Delegates playback to the GPU worker's rAF loop.
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* When playing, sends startPlayback to the scatter instance which runs
|
|
10
|
+
* a requestAnimationFrame loop in the GPU worker — rendering directly
|
|
11
|
+
* without main-thread involvement. Position updates are broadcast back
|
|
12
|
+
* at ~30fps for UI sync (slider, atom).
|
|
16
13
|
*/
|
|
17
|
-
export const usePlayback = () => {
|
|
14
|
+
export const usePlayback = (scatter: ScatterInstance | null) => {
|
|
18
15
|
const playing = useAtomValue(tourPlayingAtom);
|
|
19
16
|
const speed = useAtomValue(tourSpeedAtom);
|
|
20
17
|
const direction = useAtomValue(tourDirectionAtom);
|
|
21
|
-
const setPosition = useSetAtom(tourPositionAtom);
|
|
22
|
-
|
|
23
|
-
// Use refs for values read inside rAF to avoid re-creating the effect
|
|
24
|
-
const speedRef = useRef(speed);
|
|
25
|
-
speedRef.current = speed;
|
|
26
|
-
const directionRef = useRef(direction);
|
|
27
|
-
directionRef.current = direction;
|
|
28
18
|
|
|
29
19
|
useEffect(() => {
|
|
30
|
-
if (!
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Full tour cycle = 20s at speed=1
|
|
39
|
-
const delta = (dt * speedRef.current * directionRef.current) / 20;
|
|
40
|
-
setPosition((prev) => {
|
|
41
|
-
let next = prev + delta;
|
|
42
|
-
// Wrap cyclically
|
|
43
|
-
next = next - Math.floor(next);
|
|
44
|
-
return next;
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
prevTime = time;
|
|
48
|
-
rafId = requestAnimationFrame(tick);
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
rafId = requestAnimationFrame(tick);
|
|
52
|
-
return () => cancelAnimationFrame(rafId);
|
|
53
|
-
}, [playing, setPosition]);
|
|
20
|
+
if (!scatter) return;
|
|
21
|
+
if (playing) {
|
|
22
|
+
scatter.startPlayback(speed, direction);
|
|
23
|
+
} else {
|
|
24
|
+
scatter.stopPlayback();
|
|
25
|
+
}
|
|
26
|
+
return () => scatter.stopPlayback();
|
|
27
|
+
}, [scatter, playing, speed, direction]);
|
|
54
28
|
};
|
package/src/hooks/useScatter.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { ScatterInstance, ScatterStatus } from '@dtour/scatter';
|
|
|
2
2
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
3
3
|
import { useEffect, useRef } from 'react';
|
|
4
4
|
import { hexToRgb, hexToRgb255, isHexColor } from '../lib/color-utils.ts';
|
|
5
|
+
import { parseEmbeddedConfig } from '../spec.ts';
|
|
5
6
|
import {
|
|
6
7
|
backgroundColorAtom,
|
|
7
8
|
cameraPanXAtom,
|
|
8
9
|
cameraPanYAtom,
|
|
9
10
|
cameraZoomAtom,
|
|
10
11
|
colorMapAtom,
|
|
12
|
+
embeddedConfigAtom,
|
|
11
13
|
guidedSuspendedAtom,
|
|
12
14
|
legendClearGenAtom,
|
|
13
15
|
legendSelectionAtom,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
pointOpacityAtom,
|
|
18
20
|
pointSizeAtom,
|
|
19
21
|
resolvedThemeAtom,
|
|
22
|
+
tourPlayingAtom,
|
|
20
23
|
tourPositionAtom,
|
|
21
24
|
} from '../state/atoms.ts';
|
|
22
25
|
|
|
@@ -33,6 +36,7 @@ export const useScatter = (scatter: ScatterInstance | null) => {
|
|
|
33
36
|
const opacity = useAtomValue(pointOpacityAtom);
|
|
34
37
|
const color = useAtomValue(pointColorAtom);
|
|
35
38
|
const guidedSuspended = useAtomValue(guidedSuspendedAtom);
|
|
39
|
+
const playing = useAtomValue(tourPlayingAtom);
|
|
36
40
|
const panX = useAtomValue(cameraPanXAtom);
|
|
37
41
|
const panY = useAtomValue(cameraPanYAtom);
|
|
38
42
|
const zoom = useAtomValue(cameraZoomAtom);
|
|
@@ -58,11 +62,12 @@ export const useScatter = (scatter: ScatterInstance | null) => {
|
|
|
58
62
|
scatter?.setCamera({ pan: [panX, panY], zoom });
|
|
59
63
|
}, [scatter, panX, panY, zoom]);
|
|
60
64
|
|
|
61
|
-
// Forward tour position (skipped
|
|
65
|
+
// Forward tour position (skipped during worker-driven playback and when
|
|
66
|
+
// suspended after returning from manual/grand)
|
|
62
67
|
useEffect(() => {
|
|
63
|
-
if (guidedSuspended) return;
|
|
68
|
+
if (guidedSuspended || playing) return;
|
|
64
69
|
scatter?.setTourPosition(position);
|
|
65
|
-
}, [scatter, position, guidedSuspended]);
|
|
70
|
+
}, [scatter, position, guidedSuspended, playing]);
|
|
66
71
|
|
|
67
72
|
// Forward point style (size + opacity + uniform color)
|
|
68
73
|
useEffect(() => {
|
|
@@ -77,8 +82,13 @@ export const useScatter = (scatter: ScatterInstance | null) => {
|
|
|
77
82
|
scatter.clearColor();
|
|
78
83
|
scatter.setStyle({ pointSize, opacity, color: hexToRgb(color) });
|
|
79
84
|
} else {
|
|
80
|
-
// Column name — encode per-point colors via data worker
|
|
85
|
+
// Column name — encode per-point colors via data worker.
|
|
86
|
+
// Skip until metadata is available: the data worker only knows column
|
|
87
|
+
// names after it has parsed the dataset, so encodeColor sent before
|
|
88
|
+
// that point would silently no-op. Including metadata in the deps
|
|
89
|
+
// ensures we re-send once data is ready.
|
|
81
90
|
scatter.setStyle({ pointSize, opacity });
|
|
91
|
+
if (!metadata) return;
|
|
82
92
|
// Resolve theme-aware colorMap to Record<string, [r,g,b]> for the scatter worker
|
|
83
93
|
let resolvedColorMap: Record<string, [number, number, number]> | undefined;
|
|
84
94
|
if (rawColorMap) {
|
|
@@ -90,7 +100,7 @@ export const useScatter = (scatter: ScatterInstance | null) => {
|
|
|
90
100
|
}
|
|
91
101
|
scatter.encodeColor(color, palette, resolvedTheme, resolvedColorMap);
|
|
92
102
|
}
|
|
93
|
-
}, [scatter, pointSize, opacity, color, palette, resolvedTheme, rawColorMap]);
|
|
103
|
+
}, [scatter, pointSize, opacity, color, palette, resolvedTheme, rawColorMap, metadata]);
|
|
94
104
|
|
|
95
105
|
// Forward legend selection → scatter.selectByColumn
|
|
96
106
|
useEffect(() => {
|
|
@@ -150,12 +160,18 @@ export const useScatter = (scatter: ScatterInstance | null) => {
|
|
|
150
160
|
const setLegendSelectionRef = useRef(setLegendSelection);
|
|
151
161
|
setLegendSelectionRef.current = setLegendSelection;
|
|
152
162
|
|
|
163
|
+
const setEmbeddedConfig = useSetAtom(embeddedConfigAtom);
|
|
164
|
+
const setEmbeddedConfigRef = useRef(setEmbeddedConfig);
|
|
165
|
+
setEmbeddedConfigRef.current = setEmbeddedConfig;
|
|
166
|
+
|
|
153
167
|
useEffect(() => {
|
|
154
168
|
if (!scatter) return;
|
|
155
169
|
return scatter.subscribe((s: ScatterStatus) => {
|
|
156
170
|
if (s.type === 'metadata') {
|
|
157
171
|
setMetadataRef.current(s.metadata);
|
|
158
172
|
setLegendSelectionRef.current(null);
|
|
173
|
+
// Parse and store embedded config from Parquet metadata
|
|
174
|
+
setEmbeddedConfigRef.current(parseEmbeddedConfig(s.metadata.embeddedConfig));
|
|
159
175
|
}
|
|
160
176
|
});
|
|
161
177
|
}, [scatter]);
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,8 @@ import './styles.css';
|
|
|
4
4
|
// Primary API — self-contained component with spec-driven state
|
|
5
5
|
export { Dtour } from './Dtour.tsx';
|
|
6
6
|
export type { DtourProps, DtourHandle } from './Dtour.tsx';
|
|
7
|
-
export type { DtourSpec } from './spec.ts';
|
|
8
|
-
export { dtourSpecSchema, DTOUR_DEFAULTS } from './spec.ts';
|
|
7
|
+
export type { DtourSpec, EmbeddedConfig } from './spec.ts';
|
|
8
|
+
export { dtourSpecSchema, DTOUR_DEFAULTS, parseEmbeddedConfig } from './spec.ts';
|
|
9
9
|
|
|
10
10
|
// Portal container — for Shadow DOM isolation (e.g. anywidget/Marimo)
|
|
11
11
|
export { PortalContainerContext } from './portal-container.tsx';
|
|
@@ -15,7 +15,7 @@ export { DtourViewer } from './DtourViewer.tsx';
|
|
|
15
15
|
export type { DtourViewerProps } from './DtourViewer.tsx';
|
|
16
16
|
export { DtourToolbar } from './components/DtourToolbar.tsx';
|
|
17
17
|
export { CircularSlider } from './components/CircularSlider.tsx';
|
|
18
|
-
export type { CircularSliderProps } from './components/CircularSlider.tsx';
|
|
18
|
+
export type { CircularSliderProps, CircularSliderHandle } from './components/CircularSlider.tsx';
|
|
19
19
|
export { createDefaultViews } from './views.ts';
|
|
20
20
|
|
|
21
21
|
// Radial chart — quality metrics visualization
|
|
@@ -29,10 +29,14 @@ export {
|
|
|
29
29
|
tourPlayingAtom,
|
|
30
30
|
tourSpeedAtom,
|
|
31
31
|
tourDirectionAtom,
|
|
32
|
+
sliderSpacingAtom,
|
|
33
|
+
arcLengthsAtom,
|
|
32
34
|
// Preview
|
|
33
35
|
previewCountAtom,
|
|
34
36
|
previewPaddingAtom,
|
|
35
37
|
selectedKeyframeAtom,
|
|
38
|
+
currentKeyframeAtom,
|
|
39
|
+
hoveredKeyframeAtom,
|
|
36
40
|
// Point style
|
|
37
41
|
pointSizeAtom,
|
|
38
42
|
pointOpacityAtom,
|
|
@@ -46,10 +50,19 @@ export {
|
|
|
46
50
|
viewModeAtom,
|
|
47
51
|
// Legend
|
|
48
52
|
showLegendAtom,
|
|
53
|
+
// Axes
|
|
54
|
+
showAxesAtom,
|
|
55
|
+
// Frame numbers
|
|
56
|
+
showFrameNumbersAtom,
|
|
57
|
+
// Frame loadings
|
|
58
|
+
showFrameLoadingsAtom,
|
|
59
|
+
frameLoadingsAtom,
|
|
60
|
+
tourModeAtom,
|
|
49
61
|
legendVisibleAtom,
|
|
50
62
|
// Theme
|
|
51
63
|
themeModeAtom,
|
|
52
64
|
resolvedThemeAtom,
|
|
53
65
|
// Read-only
|
|
54
66
|
metadataAtom,
|
|
67
|
+
embeddedConfigAtom,
|
|
55
68
|
} from './state/atoms.ts';
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export const GAP = 32;
|
|
3
3
|
/** Maximum preview size (CSS px). */
|
|
4
4
|
export const MAX_SIZE = 320;
|
|
5
|
+
/** Height of the loading bar below/above each preview (CSS px). */
|
|
6
|
+
export const LOADING_BAR_HEIGHT = 24;
|
|
5
7
|
/**
|
|
6
8
|
* Per-edge-count ratio arrays.
|
|
7
9
|
* k=1 (4 previews) → [1] all same
|
|
@@ -61,6 +63,7 @@ export function computeGallerySizes(
|
|
|
61
63
|
containerHeight: number,
|
|
62
64
|
previewCount: number,
|
|
63
65
|
scale = 1,
|
|
66
|
+
showLoadings = false,
|
|
64
67
|
): GallerySizes {
|
|
65
68
|
const k = Math.max(1, previewCount / 4);
|
|
66
69
|
const numTracks = k + 1;
|
|
@@ -74,14 +77,22 @@ export function computeGallerySizes(
|
|
|
74
77
|
ratioSum += r;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
// When loading bars are shown, each row track needs extra height.
|
|
81
|
+
// Subtract the total loading bar height from the available vertical
|
|
82
|
+
// space before computing the base size so previews stay square.
|
|
83
|
+
const loadingExtra = showLoadings ? LOADING_BAR_HEIGHT : 0;
|
|
84
|
+
const totalLoadingHeight = showLoadings ? numTracks * loadingExtra : 0;
|
|
85
|
+
|
|
77
86
|
// Derive all track sizes from the short side so previews stay square.
|
|
78
87
|
// Available = shortSide - gaps between tracks.
|
|
79
88
|
// baseSize is the unit; corner = 1.0×base, mid-edge = 0.8×base, etc.
|
|
80
|
-
const
|
|
89
|
+
const effectiveHeight = containerHeight - totalLoadingHeight;
|
|
90
|
+
const shortSide = Math.min(containerWidth, effectiveHeight);
|
|
81
91
|
const availableForCells = shortSide - (numTracks - 1) * GAP;
|
|
82
92
|
const baseSize = Math.min(MAX_SIZE, availableForCells / ratioSum) * scale;
|
|
83
93
|
|
|
84
|
-
const
|
|
94
|
+
const colTemplate = ratios.map((r) => `${Math.round(baseSize * r)}px`).join(' ');
|
|
95
|
+
const rowTemplate = ratios.map((r) => `${Math.round(baseSize * r + loadingExtra)}px`).join(' ');
|
|
85
96
|
const previewSize = baseSize;
|
|
86
97
|
|
|
87
98
|
// Per-preview sizes: each preview's size depends on its edge position.
|
|
@@ -95,8 +106,8 @@ export function computeGallerySizes(
|
|
|
95
106
|
const padY = previewSize / 2;
|
|
96
107
|
|
|
97
108
|
return {
|
|
98
|
-
gridTemplateColumns:
|
|
99
|
-
gridTemplateRows:
|
|
109
|
+
gridTemplateColumns: colTemplate,
|
|
110
|
+
gridTemplateRows: rowTemplate,
|
|
100
111
|
sizes,
|
|
101
112
|
previewSize,
|
|
102
113
|
padX,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GAP, MAX_SIZE, sizeRatio } from './gallery-positions.ts';
|
|
1
|
+
import { GAP, LOADING_BAR_HEIGHT, MAX_SIZE, sizeRatio } from './gallery-positions.ts';
|
|
2
2
|
|
|
3
3
|
const MIN_SIZE = 80;
|
|
4
4
|
|
|
@@ -24,11 +24,13 @@ export function computeSelectorSize(
|
|
|
24
24
|
containerWidth: number,
|
|
25
25
|
containerHeight: number,
|
|
26
26
|
previewCount: number,
|
|
27
|
-
|
|
27
|
+
toolbarHeight: number,
|
|
28
28
|
padding: number,
|
|
29
29
|
scale = 1,
|
|
30
30
|
/** Number of radial metric tracks rendered outside the ring. */
|
|
31
31
|
metricTrackCount = 0,
|
|
32
|
+
/** Whether loading bars are shown below/above previews. */
|
|
33
|
+
showLoadings = false,
|
|
32
34
|
): number {
|
|
33
35
|
if (previewCount === 0 || containerWidth <= 0 || containerHeight <= 0) {
|
|
34
36
|
return Math.max(MIN_SIZE, Math.min(containerWidth, containerHeight));
|
|
@@ -36,12 +38,13 @@ export function computeSelectorSize(
|
|
|
36
38
|
|
|
37
39
|
const k = Math.max(1, previewCount / 4);
|
|
38
40
|
const numTracks = k + 1;
|
|
41
|
+
const loadingExtra = showLoadings ? LOADING_BAR_HEIGHT : 0;
|
|
39
42
|
|
|
40
43
|
// Must match Gallery CSS: left-4, right-4, top/bottom = verticalInset.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
44
|
+
// The overlay wrapper shifts down by toolbarHeight/2, so we bump
|
|
45
|
+
// vertical insets to keep a 16px visual gap below the toolbar.
|
|
43
46
|
const gridLeft = 16;
|
|
44
|
-
const toolbarOffset =
|
|
47
|
+
const toolbarOffset = toolbarHeight / 2;
|
|
45
48
|
const verticalInset = 16 + toolbarOffset;
|
|
46
49
|
const gridW = containerWidth - 2 * gridLeft;
|
|
47
50
|
const gridH = containerHeight - 2 * verticalInset;
|
|
@@ -54,16 +57,20 @@ export function computeSelectorSize(
|
|
|
54
57
|
ratios.push(r);
|
|
55
58
|
ratioSum += r;
|
|
56
59
|
}
|
|
57
|
-
const
|
|
60
|
+
const totalLoadingHeight = numTracks * loadingExtra;
|
|
61
|
+
const effectiveHeight = gridH - totalLoadingHeight;
|
|
62
|
+
const shortSide = Math.min(gridW, effectiveHeight);
|
|
58
63
|
const availableForCells = shortSide - (numTracks - 1) * GAP;
|
|
59
64
|
const baseSize = Math.min(MAX_SIZE, availableForCells / ratioSum) * scale;
|
|
60
65
|
const trackSizes = ratios.map((r) => baseSize * r);
|
|
66
|
+
const rowTrackSizes = ratios.map((r) => baseSize * r + loadingExtra);
|
|
61
67
|
const trackTotal = trackSizes.reduce((a, b) => a + b, 0);
|
|
68
|
+
const rowTrackTotal = rowTrackSizes.reduce((a, b) => a + b, 0);
|
|
62
69
|
|
|
63
70
|
// Effective gaps: CSS justify-content/align-content: space-between
|
|
64
71
|
// distributes remaining free space equally between inter-track gutters.
|
|
65
72
|
const freeX = gridW - trackTotal - (numTracks - 1) * GAP;
|
|
66
|
-
const freeY = gridH -
|
|
73
|
+
const freeY = gridH - rowTrackTotal - (numTracks - 1) * GAP;
|
|
67
74
|
const effGapX = GAP + (numTracks > 1 ? Math.max(0, freeX) / (numTracks - 1) : 0);
|
|
68
75
|
const effGapY = GAP + (numTracks > 1 ? Math.max(0, freeY) / (numTracks - 1) : 0);
|
|
69
76
|
|
|
@@ -72,7 +79,7 @@ export function computeSelectorSize(
|
|
|
72
79
|
const rowStarts: number[] = [0];
|
|
73
80
|
for (let j = 1; j <= k; j++) {
|
|
74
81
|
colStarts.push(colStarts[j - 1]! + trackSizes[j - 1]! + effGapX);
|
|
75
|
-
rowStarts.push(rowStarts[j - 1]! +
|
|
82
|
+
rowStarts.push(rowStarts[j - 1]! + rowTrackSizes[j - 1]! + effGapY);
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
// Selector centre in the overlay-wrapper coordinate system
|
|
@@ -104,7 +111,7 @@ export function computeSelectorSize(
|
|
|
104
111
|
const cellX = gridLeft + colStarts[col]!;
|
|
105
112
|
const cellY = verticalInset + rowStarts[row]!;
|
|
106
113
|
const cellW = trackSizes[col]!;
|
|
107
|
-
const cellH =
|
|
114
|
+
const cellH = rowTrackSizes[row]!;
|
|
108
115
|
const size = baseSize * sizeRatio(i % k, k);
|
|
109
116
|
|
|
110
117
|
// Alignment within cell (matches Gallery flex alignment)
|
|
@@ -113,14 +120,21 @@ export function computeSelectorSize(
|
|
|
113
120
|
else if (col === k) px = cellX + cellW - size;
|
|
114
121
|
else px = cellX + (cellW - size) / 2;
|
|
115
122
|
|
|
123
|
+
// Vertical: the cell now includes loading bar height.
|
|
124
|
+
// Bottom-edge previews have the bar above (flex-col-reverse),
|
|
125
|
+
// so the preview canvas sits at the bottom of the cell.
|
|
116
126
|
let py: number;
|
|
117
127
|
if (row === 0) py = cellY;
|
|
118
128
|
else if (row === k) py = cellY + cellH - size;
|
|
119
129
|
else py = cellY + (cellH - size) / 2;
|
|
120
130
|
|
|
131
|
+
// Bounding box includes loading bar
|
|
132
|
+
const boxH = size + loadingExtra;
|
|
133
|
+
const boxY = row === k ? py - loadingExtra : py;
|
|
134
|
+
|
|
121
135
|
// Euclidean distance from centre to nearest point on preview rect
|
|
122
136
|
const dx = Math.max(0, px - cx, cx - (px + size));
|
|
123
|
-
const dy = Math.max(0,
|
|
137
|
+
const dy = Math.max(0, boxY - cy, cy - (boxY + boxH));
|
|
124
138
|
minDist = Math.min(minDist, Math.sqrt(dx * dx + dy * dy));
|
|
125
139
|
}
|
|
126
140
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arcball rotation utilities — Shoemake-style trackball for 3D camera rotation.
|
|
3
|
+
* All quaternions are [x, y, z, w] format. Rotation matrices are 3×3 column-major.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Quat = [number, number, number, number];
|
|
7
|
+
|
|
8
|
+
export const IDENTITY_QUAT: Quat = [0, 0, 0, 1];
|
|
9
|
+
|
|
10
|
+
/** Project a screen point (in [-1,1]² NDC) onto the arcball sphere. */
|
|
11
|
+
export const projectToSphere = (x: number, y: number): [number, number, number] => {
|
|
12
|
+
const r2 = x * x + y * y;
|
|
13
|
+
if (r2 <= 1) {
|
|
14
|
+
// On the sphere
|
|
15
|
+
return [x, y, Math.sqrt(1 - r2)];
|
|
16
|
+
}
|
|
17
|
+
// Outside sphere — project onto edge
|
|
18
|
+
const s = 1 / Math.sqrt(r2);
|
|
19
|
+
return [x * s, y * s, 0];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Compute rotation quaternion that takes vector `from` to vector `to`. */
|
|
23
|
+
export const arcballQuat = (from: [number, number, number], to: [number, number, number]): Quat => {
|
|
24
|
+
// Cross product = rotation axis, dot product = cos(angle)
|
|
25
|
+
const cx = from[1] * to[2] - from[2] * to[1];
|
|
26
|
+
const cy = from[2] * to[0] - from[0] * to[2];
|
|
27
|
+
const cz = from[0] * to[1] - from[1] * to[0];
|
|
28
|
+
const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2];
|
|
29
|
+
// q = (cross, 1 + dot), then normalize — avoids trig
|
|
30
|
+
const w = 1 + dot;
|
|
31
|
+
const len = Math.sqrt(cx * cx + cy * cy + cz * cz + w * w);
|
|
32
|
+
if (len < 1e-12) return IDENTITY_QUAT;
|
|
33
|
+
return [cx / len, cy / len, cz / len, w / len];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Multiply two quaternions: result = a * b. */
|
|
37
|
+
export const multiplyQuat = (a: Quat, b: Quat): Quat => [
|
|
38
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
39
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
40
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
41
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Normalize a quaternion in-place and return it. */
|
|
45
|
+
export const normalizeQuat = (q: Quat): Quat => {
|
|
46
|
+
const len = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]);
|
|
47
|
+
if (len < 1e-12) return IDENTITY_QUAT;
|
|
48
|
+
return [q[0] / len, q[1] / len, q[2] / len, q[3] / len];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** SLERP between two quaternions. t=0 → a, t=1 → b. */
|
|
52
|
+
export const slerp = (a: Quat, b: Quat, t: number): Quat => {
|
|
53
|
+
// Ensure shortest path
|
|
54
|
+
let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
|
|
55
|
+
let bx = b[0];
|
|
56
|
+
let by = b[1];
|
|
57
|
+
let bz = b[2];
|
|
58
|
+
let bw = b[3];
|
|
59
|
+
if (dot < 0) {
|
|
60
|
+
dot = -dot;
|
|
61
|
+
bx = -bx;
|
|
62
|
+
by = -by;
|
|
63
|
+
bz = -bz;
|
|
64
|
+
bw = -bw;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (dot > 0.9995) {
|
|
68
|
+
// Very close — use linear interpolation to avoid division by zero
|
|
69
|
+
return normalizeQuat([
|
|
70
|
+
a[0] + t * (bx - a[0]),
|
|
71
|
+
a[1] + t * (by - a[1]),
|
|
72
|
+
a[2] + t * (bz - a[2]),
|
|
73
|
+
a[3] + t * (bw - a[3]),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const theta = Math.acos(dot);
|
|
78
|
+
const sinTheta = Math.sin(theta);
|
|
79
|
+
const wa = Math.sin((1 - t) * theta) / sinTheta;
|
|
80
|
+
const wb = Math.sin(t * theta) / sinTheta;
|
|
81
|
+
return [wa * a[0] + wb * bx, wa * a[1] + wb * by, wa * a[2] + wb * bz, wa * a[3] + wb * bw];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Convert quaternion to 3×3 column-major rotation matrix (9-element Float32Array). */
|
|
85
|
+
export const quatToMat3 = (q: Quat): Float32Array => {
|
|
86
|
+
const [x, y, z, w] = q;
|
|
87
|
+
const x2 = x + x;
|
|
88
|
+
const y2 = y + y;
|
|
89
|
+
const z2 = z + z;
|
|
90
|
+
const xx = x * x2;
|
|
91
|
+
const xy = x * y2;
|
|
92
|
+
const xz = x * z2;
|
|
93
|
+
const yy = y * y2;
|
|
94
|
+
const yz = y * z2;
|
|
95
|
+
const zz = z * z2;
|
|
96
|
+
const wx = w * x2;
|
|
97
|
+
const wy = w * y2;
|
|
98
|
+
const wz = w * z2;
|
|
99
|
+
|
|
100
|
+
// Column-major: col0, col1, col2
|
|
101
|
+
return new Float32Array([
|
|
102
|
+
1 - (yy + zz),
|
|
103
|
+
xy + wz,
|
|
104
|
+
xz - wy, // col0
|
|
105
|
+
xy - wz,
|
|
106
|
+
1 - (xx + zz),
|
|
107
|
+
yz + wx, // col1
|
|
108
|
+
xz + wy,
|
|
109
|
+
yz - wx,
|
|
110
|
+
1 - (xx + yy), // col2
|
|
111
|
+
]);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Check if a quaternion is approximately identity (no rotation). */
|
|
115
|
+
export const isIdentityQuat = (q: Quat, epsilon = 1e-4): boolean =>
|
|
116
|
+
Math.abs(q[0]) < epsilon &&
|
|
117
|
+
Math.abs(q[1]) < epsilon &&
|
|
118
|
+
Math.abs(q[2]) < epsilon &&
|
|
119
|
+
Math.abs(Math.abs(q[3]) - 1) < epsilon;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert tour position (arc-length parameterized) to visual position (equal spacing).
|
|
3
|
+
*
|
|
4
|
+
* In equal mode, keyframe `i` occupies visual range `[i/n, (i+1)/n]`.
|
|
5
|
+
* We binary-search for the arc-length segment containing `tourPos`, compute
|
|
6
|
+
* fractional progress within it, and map to the corresponding visual segment.
|
|
7
|
+
*/
|
|
8
|
+
export const tourToVisual = (tourPos: number, arcLengths: Float32Array): number => {
|
|
9
|
+
const n = arcLengths.length - 1;
|
|
10
|
+
if (n <= 0) return tourPos;
|
|
11
|
+
const clamped = Math.max(0, Math.min(1, tourPos));
|
|
12
|
+
|
|
13
|
+
// Binary search for the segment containing clamped
|
|
14
|
+
let lo = 0;
|
|
15
|
+
let hi = n - 1;
|
|
16
|
+
while (lo < hi) {
|
|
17
|
+
const mid = (lo + hi) >>> 1;
|
|
18
|
+
if (arcLengths[mid + 1]! <= clamped) {
|
|
19
|
+
lo = mid + 1;
|
|
20
|
+
} else {
|
|
21
|
+
hi = mid;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const segStart = arcLengths[lo]!;
|
|
26
|
+
const segEnd = arcLengths[lo + 1]!;
|
|
27
|
+
const segLen = segEnd - segStart;
|
|
28
|
+
const localT = segLen > 1e-10 ? (clamped - segStart) / segLen : 0;
|
|
29
|
+
|
|
30
|
+
return (lo + localT) / n;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert visual position (equal spacing) to tour position (arc-length).
|
|
35
|
+
*
|
|
36
|
+
* Visual position `v` maps to segment `floor(v * n)`, with local fraction
|
|
37
|
+
* `frac(v * n)` interpolating within that arc-length segment.
|
|
38
|
+
*/
|
|
39
|
+
export const visualToTour = (visualPos: number, arcLengths: Float32Array): number => {
|
|
40
|
+
const n = arcLengths.length - 1;
|
|
41
|
+
if (n <= 0) return visualPos;
|
|
42
|
+
const clamped = Math.max(0, Math.min(1, visualPos));
|
|
43
|
+
|
|
44
|
+
const scaled = clamped * n;
|
|
45
|
+
const segment = Math.min(Math.floor(scaled), n - 1);
|
|
46
|
+
const localT = scaled - segment;
|
|
47
|
+
|
|
48
|
+
const segStart = arcLengths[segment]!;
|
|
49
|
+
const segEnd = arcLengths[segment + 1]!;
|
|
50
|
+
return segStart + localT * (segEnd - segStart);
|
|
51
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throttle and debounce a function call.
|
|
3
|
+
*
|
|
4
|
+
* Throttling ensures the function is called at most every `throttleTime` ms.
|
|
5
|
+
* Debouncing ensures a final call happens after `debounceTime` ms of silence.
|
|
6
|
+
* Combined, this gives periodic updates during rapid firing AND a guaranteed
|
|
7
|
+
* final call with the latest arguments.
|
|
8
|
+
*
|
|
9
|
+
* Example with throttleTime=3 and debounceTime=3:
|
|
10
|
+
* 1. call(args1) => fn(args1) called immediately
|
|
11
|
+
* 2. call(args2) => ignored (throttled)
|
|
12
|
+
* 3. call(args3) => ignored (throttled)
|
|
13
|
+
* 4. call(args4) => fn(args4) called (throttle window expired)
|
|
14
|
+
* 5. call(args5) => ignored (throttled)
|
|
15
|
+
* 6. (silence)
|
|
16
|
+
* 7. fn(args5) called (debounce fires)
|
|
17
|
+
*/
|
|
18
|
+
export type ThrottledAndDebouncedFunction<Input extends unknown[]> = {
|
|
19
|
+
(...args: Input): void;
|
|
20
|
+
/** Cancel the pending debounce timer. */
|
|
21
|
+
cancel: () => void;
|
|
22
|
+
/** Reset throttle state so the next call fires immediately. */
|
|
23
|
+
reset: () => void;
|
|
24
|
+
/** Bypass throttle/debounce and call immediately. */
|
|
25
|
+
now: (...args: Input) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const throttleAndDebounce = <Input extends unknown[]>(
|
|
29
|
+
fn: (...args: Input) => void,
|
|
30
|
+
throttleTime: number,
|
|
31
|
+
debounceTime?: number,
|
|
32
|
+
): ThrottledAndDebouncedFunction<Input> => {
|
|
33
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
let blockedCalls = 0;
|
|
35
|
+
|
|
36
|
+
const finalWait = debounceTime ?? throttleTime;
|
|
37
|
+
|
|
38
|
+
const debounced = (...args: Input) => {
|
|
39
|
+
const later = () => {
|
|
40
|
+
if (blockedCalls > 0) {
|
|
41
|
+
fn(...args);
|
|
42
|
+
blockedCalls = 0;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
timeout = setTimeout(later, finalWait);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let isWaiting = false;
|
|
51
|
+
const throttledAndDebounced = (...args: Input) => {
|
|
52
|
+
if (isWaiting) {
|
|
53
|
+
blockedCalls++;
|
|
54
|
+
debounced(...args);
|
|
55
|
+
} else {
|
|
56
|
+
fn(...args);
|
|
57
|
+
debounced(...args);
|
|
58
|
+
|
|
59
|
+
isWaiting = true;
|
|
60
|
+
blockedCalls = 0;
|
|
61
|
+
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
isWaiting = false;
|
|
64
|
+
}, throttleTime);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
throttledAndDebounced.reset = () => {
|
|
69
|
+
isWaiting = false;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
throttledAndDebounced.cancel = () => {
|
|
73
|
+
clearTimeout(timeout);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
throttledAndDebounced.now = (...args: Input) => fn(...args);
|
|
77
|
+
|
|
78
|
+
return throttledAndDebounced;
|
|
79
|
+
};
|