@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.
- package/dist/Dtour.d.ts +46 -0
- package/dist/Dtour.d.ts.map +1 -0
- package/dist/DtourViewer.d.ts +24 -0
- package/dist/DtourViewer.d.ts.map +1 -0
- package/dist/components/AxisOverlay.d.ts +9 -0
- package/dist/components/AxisOverlay.d.ts.map +1 -0
- package/dist/components/CircularSlider.d.ts +16 -0
- package/dist/components/CircularSlider.d.ts.map +1 -0
- package/dist/components/ColorLegend.d.ts +2 -0
- package/dist/components/ColorLegend.d.ts.map +1 -0
- package/dist/components/DtourToolbar.d.ts +5 -0
- package/dist/components/DtourToolbar.d.ts.map +1 -0
- package/dist/components/Gallery.d.ts +12 -0
- package/dist/components/Gallery.d.ts.map +1 -0
- package/dist/components/LassoOverlay.d.ts +9 -0
- package/dist/components/LassoOverlay.d.ts.map +1 -0
- package/dist/components/Logo.d.ts +2 -0
- package/dist/components/Logo.d.ts.map +1 -0
- package/dist/components/ui/button.d.ts +12 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +10 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/slider.d.ts +6 -0
- package/dist/components/ui/slider.d.ts.map +1 -0
- package/dist/components/ui/tooltip.d.ts +8 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/hooks/useAnimatePosition.d.ts +13 -0
- package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
- package/dist/hooks/useGrandTour.d.ts +14 -0
- package/dist/hooks/useGrandTour.d.ts.map +1 -0
- package/dist/hooks/useLongPressIndicator.d.ts +5 -0
- package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
- package/dist/hooks/useModeCycling.d.ts +12 -0
- package/dist/hooks/useModeCycling.d.ts.map +1 -0
- package/dist/hooks/usePlayback.d.ts +9 -0
- package/dist/hooks/usePlayback.d.ts.map +1 -0
- package/dist/hooks/useScatter.d.ts +10 -0
- package/dist/hooks/useScatter.d.ts.map +1 -0
- package/dist/hooks/useSystemTheme.d.ts +6 -0
- package/dist/hooks/useSystemTheme.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/layout/gallery-positions.d.ts +38 -0
- package/dist/layout/gallery-positions.d.ts.map +1 -0
- package/dist/layout/selector-size.d.ts +15 -0
- package/dist/layout/selector-size.d.ts.map +1 -0
- package/dist/lib/color-utils.d.ts +7 -0
- package/dist/lib/color-utils.d.ts.map +1 -0
- package/dist/lib/gram-schmidt.d.ts +9 -0
- package/dist/lib/gram-schmidt.d.ts.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/portal-container.d.ts +10 -0
- package/dist/portal-container.d.ts.map +1 -0
- package/dist/radial-chart/RadialChart.d.ts +13 -0
- package/dist/radial-chart/RadialChart.d.ts.map +1 -0
- package/dist/radial-chart/arc-path.d.ts +23 -0
- package/dist/radial-chart/arc-path.d.ts.map +1 -0
- package/dist/radial-chart/index.d.ts +5 -0
- package/dist/radial-chart/index.d.ts.map +1 -0
- package/dist/radial-chart/parse-metrics.d.ts +10 -0
- package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
- package/dist/radial-chart/types.d.ts +23 -0
- package/dist/radial-chart/types.d.ts.map +1 -0
- package/dist/spec.d.ts +42 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/state/atoms.d.ts +150 -0
- package/dist/state/atoms.d.ts.map +1 -0
- package/dist/state/spec-sync.d.ts +5 -0
- package/dist/state/spec-sync.d.ts.map +1 -0
- package/dist/viewer.css +3 -0
- package/dist/viewer.js +14501 -0
- package/dist/views.d.ts +30 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/Dtour.tsx +300 -0
- package/src/DtourViewer.tsx +541 -0
- package/src/components/AxisOverlay.tsx +224 -0
- package/src/components/CircularSlider.tsx +202 -0
- package/src/components/ColorLegend.tsx +178 -0
- package/src/components/DtourToolbar.tsx +642 -0
- package/src/components/Gallery.tsx +166 -0
- package/src/components/LassoOverlay.tsx +240 -0
- package/src/components/Logo.tsx +37 -0
- package/src/components/ui/button.tsx +36 -0
- package/src/components/ui/dropdown-menu.tsx +92 -0
- package/src/components/ui/slider.tsx +89 -0
- package/src/components/ui/tooltip.tsx +45 -0
- package/src/hooks/useAnimatePosition.ts +102 -0
- package/src/hooks/useGrandTour.ts +176 -0
- package/src/hooks/useLongPressIndicator.ts +342 -0
- package/src/hooks/useModeCycling.ts +64 -0
- package/src/hooks/usePlayback.ts +54 -0
- package/src/hooks/useScatter.ts +162 -0
- package/src/hooks/useSystemTheme.ts +19 -0
- package/src/index.ts +55 -0
- package/src/layout/gallery-positions.ts +105 -0
- package/src/layout/selector-size.ts +135 -0
- package/src/lib/color-utils.ts +22 -0
- package/src/lib/gram-schmidt.ts +41 -0
- package/src/lib/utils.ts +4 -0
- package/src/portal-container.tsx +14 -0
- package/src/radial-chart/RadialChart.tsx +184 -0
- package/src/radial-chart/arc-path.ts +80 -0
- package/src/radial-chart/index.ts +4 -0
- package/src/radial-chart/parse-metrics.ts +99 -0
- package/src/radial-chart/types.ts +23 -0
- package/src/spec.ts +48 -0
- package/src/state/atoms.ts +169 -0
- package/src/state/spec-sync.ts +190 -0
- package/src/styles.css +44 -0
- package/src/views.ts +76 -0
- package/tsconfig.json +12 -0
- 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
|
+
};
|