@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,54 +1,28 @@
1
- import { useAtomValue, useSetAtom } from 'jotai';
2
- import { useEffect, useRef } from 'react';
3
- import {
4
- tourDirectionAtom,
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
- * rAF loop that advances tour position when playing.
7
+ * Delegates playback to the GPU worker's rAF loop.
12
8
  *
13
- * Reads playing/speed/direction from atoms, writes position.
14
- * Automatically pauses when the tab is hidden (rAF stops firing).
15
- * Wraps at 0/1 for cyclic tour.
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 (!playing) return;
31
-
32
- let prevTime: number | null = null;
33
- let rafId: number;
34
-
35
- const tick = (time: number) => {
36
- if (prevTime !== null) {
37
- const dt = (time - prevTime) / 1000; // seconds
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
  };
@@ -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 when suspended after returning from manual/grand)
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 shortSide = Math.min(containerWidth, containerHeight);
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 template = ratios.map((r) => `${Math.round(baseSize * r)}px`).join(' ');
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: template,
99
- gridTemplateRows: template,
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
- isToolbarVisible: boolean,
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
- // When toolbar is visible overlayOffsetY = toolbarHeight/2 = 20 shifts
42
- // the wrapper down, so we bump vertical insets to keep 16px visual gap.
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 = isToolbarVisible ? 20 : 0; // toolbarHeight / 2
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 shortSide = Math.min(gridW, gridH);
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 - trackTotal - (numTracks - 1) * GAP;
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]! + trackSizes[j - 1]! + effGapY);
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 = trackSizes[row]!;
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, py - cy, cy - (py + size));
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
+ };