@dtour/viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/Dtour.d.ts +46 -0
  2. package/dist/Dtour.d.ts.map +1 -0
  3. package/dist/DtourViewer.d.ts +24 -0
  4. package/dist/DtourViewer.d.ts.map +1 -0
  5. package/dist/components/AxisOverlay.d.ts +9 -0
  6. package/dist/components/AxisOverlay.d.ts.map +1 -0
  7. package/dist/components/CircularSlider.d.ts +16 -0
  8. package/dist/components/CircularSlider.d.ts.map +1 -0
  9. package/dist/components/ColorLegend.d.ts +2 -0
  10. package/dist/components/ColorLegend.d.ts.map +1 -0
  11. package/dist/components/DtourToolbar.d.ts +5 -0
  12. package/dist/components/DtourToolbar.d.ts.map +1 -0
  13. package/dist/components/Gallery.d.ts +12 -0
  14. package/dist/components/Gallery.d.ts.map +1 -0
  15. package/dist/components/LassoOverlay.d.ts +9 -0
  16. package/dist/components/LassoOverlay.d.ts.map +1 -0
  17. package/dist/components/Logo.d.ts +2 -0
  18. package/dist/components/Logo.d.ts.map +1 -0
  19. package/dist/components/ui/button.d.ts +12 -0
  20. package/dist/components/ui/button.d.ts.map +1 -0
  21. package/dist/components/ui/dropdown-menu.d.ts +10 -0
  22. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  23. package/dist/components/ui/slider.d.ts +6 -0
  24. package/dist/components/ui/slider.d.ts.map +1 -0
  25. package/dist/components/ui/tooltip.d.ts +8 -0
  26. package/dist/components/ui/tooltip.d.ts.map +1 -0
  27. package/dist/hooks/useAnimatePosition.d.ts +13 -0
  28. package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
  29. package/dist/hooks/useGrandTour.d.ts +14 -0
  30. package/dist/hooks/useGrandTour.d.ts.map +1 -0
  31. package/dist/hooks/useLongPressIndicator.d.ts +5 -0
  32. package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
  33. package/dist/hooks/useModeCycling.d.ts +12 -0
  34. package/dist/hooks/useModeCycling.d.ts.map +1 -0
  35. package/dist/hooks/usePlayback.d.ts +9 -0
  36. package/dist/hooks/usePlayback.d.ts.map +1 -0
  37. package/dist/hooks/useScatter.d.ts +10 -0
  38. package/dist/hooks/useScatter.d.ts.map +1 -0
  39. package/dist/hooks/useSystemTheme.d.ts +6 -0
  40. package/dist/hooks/useSystemTheme.d.ts.map +1 -0
  41. package/dist/index.d.ts +16 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/layout/gallery-positions.d.ts +38 -0
  44. package/dist/layout/gallery-positions.d.ts.map +1 -0
  45. package/dist/layout/selector-size.d.ts +15 -0
  46. package/dist/layout/selector-size.d.ts.map +1 -0
  47. package/dist/lib/color-utils.d.ts +7 -0
  48. package/dist/lib/color-utils.d.ts.map +1 -0
  49. package/dist/lib/gram-schmidt.d.ts +9 -0
  50. package/dist/lib/gram-schmidt.d.ts.map +1 -0
  51. package/dist/lib/utils.d.ts +3 -0
  52. package/dist/lib/utils.d.ts.map +1 -0
  53. package/dist/portal-container.d.ts +10 -0
  54. package/dist/portal-container.d.ts.map +1 -0
  55. package/dist/radial-chart/RadialChart.d.ts +13 -0
  56. package/dist/radial-chart/RadialChart.d.ts.map +1 -0
  57. package/dist/radial-chart/arc-path.d.ts +23 -0
  58. package/dist/radial-chart/arc-path.d.ts.map +1 -0
  59. package/dist/radial-chart/index.d.ts +5 -0
  60. package/dist/radial-chart/index.d.ts.map +1 -0
  61. package/dist/radial-chart/parse-metrics.d.ts +10 -0
  62. package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
  63. package/dist/radial-chart/types.d.ts +23 -0
  64. package/dist/radial-chart/types.d.ts.map +1 -0
  65. package/dist/spec.d.ts +42 -0
  66. package/dist/spec.d.ts.map +1 -0
  67. package/dist/state/atoms.d.ts +150 -0
  68. package/dist/state/atoms.d.ts.map +1 -0
  69. package/dist/state/spec-sync.d.ts +5 -0
  70. package/dist/state/spec-sync.d.ts.map +1 -0
  71. package/dist/viewer.css +3 -0
  72. package/dist/viewer.js +14501 -0
  73. package/dist/views.d.ts +30 -0
  74. package/dist/views.d.ts.map +1 -0
  75. package/package.json +48 -0
  76. package/src/Dtour.tsx +300 -0
  77. package/src/DtourViewer.tsx +541 -0
  78. package/src/components/AxisOverlay.tsx +224 -0
  79. package/src/components/CircularSlider.tsx +202 -0
  80. package/src/components/ColorLegend.tsx +178 -0
  81. package/src/components/DtourToolbar.tsx +642 -0
  82. package/src/components/Gallery.tsx +166 -0
  83. package/src/components/LassoOverlay.tsx +240 -0
  84. package/src/components/Logo.tsx +37 -0
  85. package/src/components/ui/button.tsx +36 -0
  86. package/src/components/ui/dropdown-menu.tsx +92 -0
  87. package/src/components/ui/slider.tsx +89 -0
  88. package/src/components/ui/tooltip.tsx +45 -0
  89. package/src/hooks/useAnimatePosition.ts +102 -0
  90. package/src/hooks/useGrandTour.ts +176 -0
  91. package/src/hooks/useLongPressIndicator.ts +342 -0
  92. package/src/hooks/useModeCycling.ts +64 -0
  93. package/src/hooks/usePlayback.ts +54 -0
  94. package/src/hooks/useScatter.ts +162 -0
  95. package/src/hooks/useSystemTheme.ts +19 -0
  96. package/src/index.ts +55 -0
  97. package/src/layout/gallery-positions.ts +105 -0
  98. package/src/layout/selector-size.ts +135 -0
  99. package/src/lib/color-utils.ts +22 -0
  100. package/src/lib/gram-schmidt.ts +41 -0
  101. package/src/lib/utils.ts +4 -0
  102. package/src/portal-container.tsx +14 -0
  103. package/src/radial-chart/RadialChart.tsx +184 -0
  104. package/src/radial-chart/arc-path.ts +80 -0
  105. package/src/radial-chart/index.ts +4 -0
  106. package/src/radial-chart/parse-metrics.ts +99 -0
  107. package/src/radial-chart/types.ts +23 -0
  108. package/src/spec.ts +48 -0
  109. package/src/state/atoms.ts +169 -0
  110. package/src/state/spec-sync.ts +190 -0
  111. package/src/styles.css +44 -0
  112. package/src/views.ts +76 -0
  113. package/tsconfig.json +12 -0
  114. package/vite.config.ts +21 -0
@@ -0,0 +1,45 @@
1
+ import { Tooltip as TooltipPrimitive } from 'radix-ui';
2
+ import type { ComponentProps } from 'react';
3
+ import { cn } from '../../lib/utils.ts';
4
+ import { usePortalContainer } from '../../portal-container.tsx';
5
+
6
+ function TooltipProvider({
7
+ delayDuration = 0,
8
+ ...props
9
+ }: ComponentProps<typeof TooltipPrimitive.Provider>) {
10
+ return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />;
11
+ }
12
+
13
+ function Tooltip(props: ComponentProps<typeof TooltipPrimitive.Root>) {
14
+ return <TooltipPrimitive.Root {...props} />;
15
+ }
16
+
17
+ function TooltipTrigger(props: ComponentProps<typeof TooltipPrimitive.Trigger>) {
18
+ return <TooltipPrimitive.Trigger {...props} />;
19
+ }
20
+
21
+ function TooltipContent({
22
+ className,
23
+ sideOffset = 6,
24
+ children,
25
+ ...props
26
+ }: ComponentProps<typeof TooltipPrimitive.Content>) {
27
+ const container = usePortalContainer();
28
+ return (
29
+ <TooltipPrimitive.Portal container={container}>
30
+ <TooltipPrimitive.Content
31
+ sideOffset={sideOffset}
32
+ className={cn(
33
+ 'z-50 rounded text-dtour-bg bg-dtour-highlight px-3 py-1.5 text-xs shadow-[0_1px_4px_rgba(0,0,0,0.6)] animate-in animate-ease-out fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
34
+ className,
35
+ )}
36
+ {...props}
37
+ >
38
+ {children}
39
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-dtour-highlight fill-dtour-highlight" />
40
+ </TooltipPrimitive.Content>
41
+ </TooltipPrimitive.Portal>
42
+ );
43
+ }
44
+
45
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -0,0 +1,102 @@
1
+ import { useSetAtom, useStore } from 'jotai';
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ import { animationGenAtom, tourPositionAtom } from '../state/atoms.ts';
4
+
5
+ /** 360° of travel = 1000ms base animation duration */
6
+ const MS_PER_FULL_ROTATION = 1000;
7
+ /** Minimum animation duration to keep it perceptible */
8
+ const MIN_ANIMATION_MS = 80;
9
+ /** Stretch factor applied to the base duration */
10
+ const DURATION_STRETCH = 1.5;
11
+
12
+ /** Ease-in-out cubic: slow start + slow end, fast middle. */
13
+ const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
14
+
15
+ /**
16
+ * Shared hook for animating `tourPositionAtom` to a target value.
17
+ *
18
+ * Multiple components can call `useAnimatePosition()` independently.
19
+ * A generation counter (`animationGenAtom`) ensures that when any
20
+ * component starts a new animation or cancels, all other running
21
+ * animations bail out on their next rAF tick.
22
+ */
23
+ export const useAnimatePosition = () => {
24
+ const store = useStore();
25
+ const setPosition = useSetAtom(tourPositionAtom);
26
+ const rafRef = useRef<number | null>(null);
27
+
28
+ const cancelLocal = useCallback(() => {
29
+ if (rafRef.current !== null) {
30
+ cancelAnimationFrame(rafRef.current);
31
+ rafRef.current = null;
32
+ }
33
+ }, []);
34
+
35
+ /** Cancel any running animation (from any component). */
36
+ const cancelAnimation = useCallback(() => {
37
+ store.set(animationGenAtom, (g) => g + 1);
38
+ cancelLocal();
39
+ }, [store, cancelLocal]);
40
+
41
+ /**
42
+ * Animate tour position from its current value to `target` along the
43
+ * shortest arc on the [0, 1) circle, using ease-in-out cubic easing.
44
+ */
45
+ const animateTo = useCallback(
46
+ (target: number) => {
47
+ cancelLocal();
48
+
49
+ // Claim a new generation — invalidates all other animations
50
+ const gen = store.get(animationGenAtom) + 1;
51
+ store.set(animationGenAtom, gen);
52
+
53
+ // Read current position imperatively via the functional updater
54
+ setPosition((current) => {
55
+ // Shortest angular distance on [0,1) circle
56
+ let delta = target - current;
57
+ if (delta > 0.5) delta -= 1;
58
+ if (delta < -0.5) delta += 1;
59
+
60
+ const absDelta = Math.abs(delta);
61
+ if (absDelta < 0.001) return target;
62
+
63
+ const startPos = current;
64
+ const startTime = performance.now();
65
+ const durationMs =
66
+ Math.max(MIN_ANIMATION_MS, absDelta * MS_PER_FULL_ROTATION) * DURATION_STRETCH;
67
+
68
+ const tick = (now: number) => {
69
+ // Bail if a newer animation or cancel has bumped the generation
70
+ if (store.get(animationGenAtom) !== gen) return;
71
+
72
+ const elapsed = now - startTime;
73
+ const t = Math.min(1, elapsed / durationMs);
74
+ const eased = easeInOutCubic(t);
75
+
76
+ let pos = startPos + delta * eased;
77
+ // Wrap to [0, 1)
78
+ pos = pos - Math.floor(pos);
79
+
80
+ setPosition(pos);
81
+
82
+ if (t < 1) {
83
+ rafRef.current = requestAnimationFrame(tick);
84
+ } else {
85
+ rafRef.current = null;
86
+ }
87
+ };
88
+
89
+ rafRef.current = requestAnimationFrame(tick);
90
+
91
+ // Return current unchanged — the rAF loop will drive updates
92
+ return current;
93
+ });
94
+ },
95
+ [store, setPosition, cancelLocal],
96
+ );
97
+
98
+ // Cleanup on unmount
99
+ useEffect(() => cancelLocal, [cancelLocal]);
100
+
101
+ return { animateTo, cancelAnimation };
102
+ };
@@ -0,0 +1,176 @@
1
+ import type { Metadata, ScatterInstance } from '@dtour/scatter';
2
+ import { useAtomValue, useSetAtom, useStore } from 'jotai';
3
+ import { useEffect, useRef } from 'react';
4
+ import { gramSchmidt } from '../lib/gram-schmidt.ts';
5
+ import {
6
+ activeIndicesAtom,
7
+ currentBasisAtom,
8
+ grandExitTargetAtom,
9
+ guidedSuspendedAtom,
10
+ tourSpeedAtom,
11
+ viewModeAtom,
12
+ } from '../state/atoms.ts';
13
+
14
+ const EASE_DURATION = 0.5; // seconds
15
+
16
+ function smoothstep(t: number): number {
17
+ return t * t * (3 - 2 * t);
18
+ }
19
+
20
+ /**
21
+ * Givens-rotation grand tour for grand mode.
22
+ *
23
+ * Generates random angular velocities for active dimension pairs and
24
+ * applies rotations each frame via rAF. Sends basis to GPU via
25
+ * `setDirectBasis` — much cheaper than `setBases`.
26
+ *
27
+ * Eases in over 500ms on entry and eases out over 500ms on exit.
28
+ * During ease-out, `viewMode` stays 'grand' — the actual mode switch
29
+ * happens only after the animation decelerates to zero.
30
+ */
31
+ export const useGrandTour = (
32
+ scatter: ScatterInstance | null,
33
+ viewMode: 'guided' | 'manual' | 'grand',
34
+ metadata: Metadata | null,
35
+ ): void => {
36
+ const speed = useAtomValue(tourSpeedAtom);
37
+ const speedRef = useRef(speed);
38
+ speedRef.current = speed;
39
+
40
+ const scatterRef = useRef(scatter);
41
+ scatterRef.current = scatter;
42
+
43
+ const activeIndices = useAtomValue(activeIndicesAtom);
44
+
45
+ // Read exit target via ref so the rAF closure always sees the latest
46
+ // value without restarting the effect.
47
+ const exitTarget = useAtomValue(grandExitTargetAtom);
48
+ const exitTargetRef = useRef(exitTarget);
49
+ exitTargetRef.current = exitTarget;
50
+
51
+ const store = useStore();
52
+ const setViewMode = useSetAtom(viewModeAtom);
53
+ const setGrandExitTarget = useSetAtom(grandExitTargetAtom);
54
+ const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
55
+
56
+ useEffect(() => {
57
+ if (viewMode !== 'grand' || !metadata || metadata.dimCount < 2 || !scatter) return;
58
+ if (activeIndices.length < 2) return;
59
+
60
+ const dims = metadata.dimCount;
61
+
62
+ // Build pairs only from active dimensions
63
+ const pairs: [number, number][] = [];
64
+ for (let a = 0; a < activeIndices.length; a++) {
65
+ for (let b = a + 1; b < activeIndices.length; b++) {
66
+ pairs.push([activeIndices[a]!, activeIndices[b]!]);
67
+ }
68
+ }
69
+ const numPairs = pairs.length;
70
+
71
+ // Generate random angular velocities for each active pair
72
+ const omegas = new Float32Array(numPairs);
73
+ for (let i = 0; i < numPairs; i++) {
74
+ omegas[i] = (0.5 + Math.random()) * Math.PI * (Math.random() > 0.5 ? 1 : -1);
75
+ }
76
+
77
+ // Initialize basis from the current projection so the view doesn't jump
78
+ const current = store.get(currentBasisAtom);
79
+ const basis = new Float32Array(dims * 2);
80
+ if (current && current.length === dims * 2) {
81
+ basis.set(current);
82
+ } else {
83
+ basis[activeIndices[0]!] = 1;
84
+ basis[dims + activeIndices[1]!] = 1;
85
+ }
86
+
87
+ // Zero out inactive dimensions and re-orthonormalize
88
+ const activeSet = new Set(activeIndices);
89
+ for (let d = 0; d < dims; d++) {
90
+ if (!activeSet.has(d)) {
91
+ basis[d] = 0;
92
+ basis[dims + d] = 0;
93
+ }
94
+ }
95
+ gramSchmidt(basis, dims);
96
+
97
+ let prevTime: number | null = null;
98
+ let rafId: number;
99
+ let easeT = 0; // 0 = stopped, 1 = full speed
100
+
101
+ const animate = (time: number) => {
102
+ if (prevTime === null) {
103
+ prevTime = time;
104
+ rafId = requestAnimationFrame(animate);
105
+ return;
106
+ }
107
+
108
+ const dt = Math.min((time - prevTime) * 0.001, 0.1); // seconds, clamped
109
+ prevTime = time;
110
+
111
+ const currentExitTarget = exitTargetRef.current;
112
+
113
+ // Advance easeT toward target
114
+ if (currentExitTarget === null) {
115
+ easeT = Math.min(1, easeT + dt / EASE_DURATION);
116
+ } else {
117
+ easeT = Math.max(0, easeT - dt / EASE_DURATION);
118
+ }
119
+
120
+ const easeFactor = smoothstep(easeT);
121
+ const currentSpeed = speedRef.current;
122
+
123
+ // Apply Givens rotations for each active dimension pair
124
+ for (let p = 0; p < numPairs; p++) {
125
+ const [i, j] = pairs[p]!;
126
+ const angle = omegas[p]! * dt * currentSpeed * easeFactor * 0.0375;
127
+ const cos = Math.cos(angle);
128
+ const sin = Math.sin(angle);
129
+
130
+ // Rotate basis rows i and j for both columns
131
+ for (let col = 0; col < 2; col++) {
132
+ const offset = col * dims;
133
+ const ai = basis[offset + i]!;
134
+ const aj = basis[offset + j]!;
135
+ basis[offset + i] = cos * ai - sin * aj;
136
+ basis[offset + j] = sin * ai + cos * aj;
137
+ }
138
+ }
139
+
140
+ // Givens rotations preserve orthonormality, no Gram-Schmidt needed
141
+ scatterRef.current?.setDirectBasis(basis.slice());
142
+
143
+ // Ease-out complete — perform the deferred mode switch
144
+ if (currentExitTarget !== null && easeT <= 0) {
145
+ cancelAnimationFrame(rafId);
146
+ // Store final basis so the next mode can pick up where we left off
147
+ store.set(currentBasisAtom, new Float32Array(basis));
148
+ if (currentExitTarget === 'guided') {
149
+ setGuidedSuspended(true);
150
+ }
151
+ setGrandExitTarget(null);
152
+ setViewMode(currentExitTarget);
153
+ return;
154
+ }
155
+
156
+ rafId = requestAnimationFrame(animate);
157
+ };
158
+
159
+ rafId = requestAnimationFrame(animate);
160
+
161
+ return () => {
162
+ cancelAnimationFrame(rafId);
163
+ // Store basis on cleanup so mode transitions always have the latest
164
+ store.set(currentBasisAtom, new Float32Array(basis));
165
+ };
166
+ }, [
167
+ viewMode,
168
+ metadata,
169
+ scatter,
170
+ activeIndices,
171
+ store,
172
+ setViewMode,
173
+ setGrandExitTarget,
174
+ setGuidedSuspended,
175
+ ]);
176
+ };
@@ -0,0 +1,342 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ // --- Constants (from regl-scatterplot) ---
4
+ const LONG_PRESS_TIME = 750;
5
+ const LONG_PRESS_AFTER_EFFECT_TIME = 500;
6
+ const LONG_PRESS_EFFECT_DELAY = 100;
7
+ const LONG_PRESS_REVERT_EFFECT_TIME = 250;
8
+
9
+ const INDICATOR_COLOR = '#4f8ff7';
10
+ const INDICATOR_ACTIVE_COLOR = '#4f8ff7';
11
+
12
+ // --- getComputedStyle helpers ---
13
+ const getCurrentTransform = (node: HTMLElement, hasRotated = false) => {
14
+ const cs = getComputedStyle(node);
15
+ const opacity = +cs.opacity;
16
+ const m = cs.transform.match(/([0-9.-]+)+/g);
17
+
18
+ if (!m) return { opacity, scale: 0, rotate: 0 };
19
+
20
+ const a = +m[0]!;
21
+ const b = +m[1]!;
22
+ const scale = Math.sqrt(a * a + b * b);
23
+ let rotate = Math.atan2(b, a) * (180 / Math.PI);
24
+ if (hasRotated && rotate <= 0) rotate = 360 + rotate;
25
+
26
+ return { opacity, scale, rotate };
27
+ };
28
+
29
+ // --- DOM element creation (from create-long-press-elements.js) ---
30
+ const createElements = () => {
31
+ const root = document.createElement('div');
32
+ root.style.position = 'fixed';
33
+ root.style.width = '1.25rem';
34
+ root.style.height = '1.25rem';
35
+ root.style.pointerEvents = 'none';
36
+ root.style.transform = 'translate(-50%,-50%)';
37
+ root.style.zIndex = '100';
38
+
39
+ const circle = document.createElement('div');
40
+ circle.style.position = 'absolute';
41
+ circle.style.top = '0';
42
+ circle.style.left = '0';
43
+ circle.style.width = '1.25rem';
44
+ circle.style.height = '1.25rem';
45
+ circle.style.clipPath = 'inset(0px 0px 0px 50%)';
46
+ circle.style.opacity = '0';
47
+ root.appendChild(circle);
48
+
49
+ const circleLeft = document.createElement('div');
50
+ circleLeft.style.boxSizing = 'content-box';
51
+ circleLeft.style.position = 'absolute';
52
+ circleLeft.style.top = '0';
53
+ circleLeft.style.left = '0';
54
+ circleLeft.style.width = '0.8rem';
55
+ circleLeft.style.height = '0.8rem';
56
+ circleLeft.style.border = '0.2rem solid currentcolor';
57
+ circleLeft.style.borderRadius = '0.8rem';
58
+ circleLeft.style.clipPath = 'inset(0px 50% 0px 0px)';
59
+ circleLeft.style.transform = 'rotate(0deg)';
60
+ circle.appendChild(circleLeft);
61
+
62
+ const circleRight = document.createElement('div');
63
+ circleRight.style.boxSizing = 'content-box';
64
+ circleRight.style.position = 'absolute';
65
+ circleRight.style.top = '0';
66
+ circleRight.style.left = '0';
67
+ circleRight.style.width = '0.8rem';
68
+ circleRight.style.height = '0.8rem';
69
+ circleRight.style.border = '0.2rem solid currentcolor';
70
+ circleRight.style.borderRadius = '0.8rem';
71
+ circleRight.style.clipPath = 'inset(0px 50% 0px 0px)';
72
+ circleRight.style.transform = 'rotate(0deg)';
73
+ circle.appendChild(circleRight);
74
+
75
+ const effect = document.createElement('div');
76
+ effect.style.position = 'absolute';
77
+ effect.style.top = '0';
78
+ effect.style.left = '0';
79
+ effect.style.width = '1.25rem';
80
+ effect.style.height = '1.25rem';
81
+ effect.style.borderRadius = '1.25rem';
82
+ effect.style.background = 'currentcolor';
83
+ effect.style.transform = 'scale(0)';
84
+ effect.style.opacity = '0';
85
+ root.appendChild(effect);
86
+
87
+ return { root, circle, circleLeft, circleRight, effect };
88
+ };
89
+
90
+ // --- The hook ---
91
+ export const useLongPressIndicator = () => {
92
+ const elementsRef = useRef<ReturnType<typeof createElements> | null>(null);
93
+ const animationsRef = useRef<Animation[]>([]);
94
+ const isStarting = useRef(false);
95
+
96
+ // Create / destroy DOM elements
97
+ useEffect(() => {
98
+ const created = createElements();
99
+ created.root.style.color = INDICATOR_COLOR;
100
+ elementsRef.current = created;
101
+
102
+ // Always append to document.body so `position: fixed` is relative to
103
+ // the viewport — a transformed ancestor would break fixed positioning.
104
+ document.body.appendChild(created.root);
105
+
106
+ return () => {
107
+ for (const a of animationsRef.current) a.cancel();
108
+ animationsRef.current = [];
109
+ created.root.remove();
110
+ elementsRef.current = null;
111
+ };
112
+ }, []);
113
+
114
+ const show = useCallback((x: number, y: number) => {
115
+ const el = elementsRef.current;
116
+ if (!el) return;
117
+
118
+ isStarting.current = true;
119
+
120
+ // Capture current animated state before canceling
121
+ const mainColor = getComputedStyle(el.root).color || 'currentcolor';
122
+ const circleCs = getComputedStyle(el.circle);
123
+ const circleClipPath = circleCs.clipPath || 'inset(0 0 0 50%)';
124
+ const circleOpacity = Number(circleCs.opacity) || 0;
125
+ const effectState = getCurrentTransform(el.effect);
126
+ const leftState = getCurrentTransform(el.circleLeft);
127
+ const rightState = getCurrentTransform(el.circleRight);
128
+
129
+ // Cancel running animations, then set inline styles so state persists
130
+ for (const a of animationsRef.current) a.cancel();
131
+
132
+ el.root.style.color = INDICATOR_COLOR;
133
+ el.root.style.top = `${y}px`;
134
+ el.root.style.left = `${x}px`;
135
+ el.circle.style.clipPath = circleClipPath;
136
+ el.circle.style.opacity = String(circleOpacity);
137
+ el.effect.style.opacity = String(effectState.opacity);
138
+ el.effect.style.transform = `scale(${effectState.scale})`;
139
+ el.circleLeft.style.transform = `rotate(${leftState.rotate}deg)`;
140
+ el.circleRight.style.transform = `rotate(${rightState.rotate}deg)`;
141
+
142
+ // Compute timing based on how far the previous animation progressed
143
+ const progress = leftState.rotate / 360;
144
+ const duration = (1 - progress) * LONG_PRESS_TIME + LONG_PRESS_AFTER_EFFECT_TIME;
145
+ const lp = ((1 - progress) * LONG_PRESS_TIME) / duration;
146
+ const half = lp / 2;
147
+ const afterEffect = lp + (1 - lp) / 4;
148
+ const opts = { duration, delay: LONG_PRESS_EFFECT_DELAY, fill: 'forwards' as const };
149
+ // CSS animation-timing-function applies per-segment; in Web Animations API
150
+ // that maps to per-keyframe easing (not the effect-level easing option).
151
+ const eo = 'ease-out';
152
+
153
+ const anims: Animation[] = [];
154
+
155
+ // Root: color + opacity
156
+ anims.push(
157
+ el.root.animate(
158
+ [
159
+ { color: mainColor, opacity: 1, offset: 0, easing: eo },
160
+ { color: mainColor, opacity: 1, offset: lp, easing: eo },
161
+ { color: INDICATOR_ACTIVE_COLOR, opacity: 0.8, offset: 1 },
162
+ ],
163
+ opts,
164
+ ),
165
+ );
166
+
167
+ // Effect circle: scale + fade
168
+ anims.push(
169
+ el.effect.animate(
170
+ [
171
+ {
172
+ opacity: effectState.opacity,
173
+ transform: `scale(${effectState.scale})`,
174
+ offset: 0,
175
+ easing: eo,
176
+ },
177
+ {
178
+ opacity: effectState.opacity,
179
+ transform: `scale(${effectState.scale})`,
180
+ offset: lp,
181
+ easing: eo,
182
+ },
183
+ { opacity: 0.66, transform: 'scale(1.5)', offset: afterEffect, easing: eo },
184
+ { opacity: 0, transform: 'scale(2)', offset: 0.99, easing: eo },
185
+ { opacity: 0, transform: 'scale(0)', offset: 1 },
186
+ ],
187
+ opts,
188
+ ),
189
+ );
190
+
191
+ // Circle left half: rotation
192
+ anims.push(
193
+ el.circleLeft.animate(
194
+ [
195
+ { transform: `rotate(${leftState.rotate}deg)`, offset: 0 },
196
+ { transform: 'rotate(360deg)', offset: lp },
197
+ { transform: 'rotate(360deg)', offset: 1 },
198
+ ],
199
+ opts,
200
+ ),
201
+ );
202
+
203
+ // Circle right half: rotation
204
+ anims.push(
205
+ el.circleRight.animate(
206
+ [
207
+ { transform: `rotate(${rightState.rotate}deg)`, offset: 0 },
208
+ { transform: 'rotate(180deg)', offset: half },
209
+ { transform: 'rotate(180deg)', offset: 1 },
210
+ ],
211
+ opts,
212
+ ),
213
+ );
214
+
215
+ // Circle container: clip-path reveal
216
+ anims.push(
217
+ el.circle.animate(
218
+ [
219
+ { clipPath: circleClipPath, opacity: circleOpacity, offset: 0 },
220
+ { clipPath: circleClipPath, opacity: 1, offset: half },
221
+ { clipPath: 'inset(0)', opacity: 1, offset: half + 0.0001 },
222
+ { clipPath: 'inset(0)', opacity: 1, offset: 1 },
223
+ ],
224
+ opts,
225
+ ),
226
+ );
227
+
228
+ animationsRef.current = anims;
229
+ }, []);
230
+
231
+ const hide = useCallback(() => {
232
+ const el = elementsRef.current;
233
+ if (!el || !isStarting.current) return;
234
+
235
+ isStarting.current = false;
236
+
237
+ // Capture current animated state before canceling
238
+ const mainColor = getComputedStyle(el.root).color || 'currentcolor';
239
+ const circleCs = getComputedStyle(el.circle);
240
+ const circleClipPath = circleCs.clipPath || 'inset(0px)';
241
+ const circleOpacity = Number(circleCs.opacity) || 1;
242
+ const effectState = getCurrentTransform(el.effect);
243
+
244
+ // Detect if past the 50% mark of the circle animation
245
+ const pastHalf = circleCs.clipPath.slice(-2, -1) === 'x';
246
+
247
+ const leftState = getCurrentTransform(el.circleLeft, pastHalf);
248
+ const rightState = getCurrentTransform(el.circleRight);
249
+
250
+ // Cancel running animations, then set inline styles so state persists
251
+ for (const a of animationsRef.current) a.cancel();
252
+
253
+ el.root.style.color = mainColor;
254
+ el.circle.style.clipPath = circleClipPath;
255
+ el.circle.style.opacity = String(circleOpacity);
256
+ el.effect.style.opacity = String(effectState.opacity);
257
+ el.effect.style.transform = `scale(${effectState.scale})`;
258
+ el.circleLeft.style.transform = `rotate(${leftState.rotate}deg)`;
259
+ el.circleRight.style.transform = `rotate(${rightState.rotate}deg)`;
260
+
261
+ // Compute timing
262
+ const progress = leftState.rotate / 360;
263
+ const duration = progress * LONG_PRESS_REVERT_EFFECT_TIME;
264
+
265
+ if (duration < 1) {
266
+ animationsRef.current = [];
267
+ return;
268
+ }
269
+
270
+ const rotated = Math.min(1, progress);
271
+ const half = rotated > 0.5 ? 1 - 0.5 / rotated : 0;
272
+ const opts = { duration, fill: 'forwards' as const };
273
+
274
+ const anims: Animation[] = [];
275
+
276
+ // Root: color revert
277
+ anims.push(el.root.animate([{ color: mainColor }, { color: INDICATOR_COLOR }], opts));
278
+
279
+ // Effect: fade out
280
+ anims.push(
281
+ el.effect.animate(
282
+ [
283
+ { opacity: effectState.opacity, transform: `scale(${effectState.scale})`, offset: 0 },
284
+ { opacity: 0, transform: `scale(${effectState.scale + 0.5})`, offset: 0.99 },
285
+ { opacity: 0, transform: 'scale(0)', offset: 1 },
286
+ ],
287
+ opts,
288
+ ),
289
+ );
290
+
291
+ // Circle left: uses circleRight's rotation (intentionally swapped, matches regl-scatterplot)
292
+ anims.push(
293
+ el.circleLeft.animate(
294
+ half > 0
295
+ ? [
296
+ { transform: `rotate(${rightState.rotate}deg)`, offset: 0 },
297
+ { transform: `rotate(${rightState.rotate}deg)`, offset: half },
298
+ { transform: 'rotate(0deg)', offset: 1 },
299
+ ]
300
+ : [
301
+ { transform: `rotate(${rightState.rotate}deg)`, offset: 0 },
302
+ { transform: 'rotate(0deg)', offset: 1 },
303
+ ],
304
+ opts,
305
+ ),
306
+ );
307
+
308
+ // Circle right: uses circleLeft's rotation (intentionally swapped)
309
+ anims.push(
310
+ el.circleRight.animate(
311
+ [
312
+ { transform: `rotate(${leftState.rotate}deg)`, offset: 0 },
313
+ { transform: 'rotate(0deg)', offset: 1 },
314
+ ],
315
+ opts,
316
+ ),
317
+ );
318
+
319
+ // Circle container: clip-path hide
320
+ anims.push(
321
+ el.circle.animate(
322
+ half > 0
323
+ ? [
324
+ { clipPath: circleClipPath, opacity: circleOpacity, offset: 0 },
325
+ { clipPath: circleClipPath, opacity: circleOpacity, offset: half },
326
+ { clipPath: 'inset(0 0 0 50%)', opacity: circleOpacity, offset: half + 0.0001 },
327
+ { clipPath: 'inset(0 0 0 50%)', opacity: 0, offset: 1 },
328
+ ]
329
+ : [
330
+ { clipPath: circleClipPath, opacity: circleOpacity, offset: 0 },
331
+ { clipPath: 'inset(0 0 0 50%)', opacity: circleOpacity, offset: 0.0001 },
332
+ { clipPath: 'inset(0 0 0 50%)', opacity: 0, offset: 1 },
333
+ ],
334
+ opts,
335
+ ),
336
+ );
337
+
338
+ animationsRef.current = anims;
339
+ }, []);
340
+
341
+ return { show, hide };
342
+ };