@dtour/viewer 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Dtour.d.ts +5 -1
- package/dist/Dtour.d.ts.map +1 -1
- package/dist/DtourViewer.d.ts +4 -1
- package/dist/DtourViewer.d.ts.map +1 -1
- package/dist/components/AxisOverlay.d.ts +11 -1
- package/dist/components/AxisOverlay.d.ts.map +1 -1
- package/dist/components/CircularSlider.d.ts +21 -2
- package/dist/components/CircularSlider.d.ts.map +1 -1
- package/dist/components/DtourToolbar.d.ts +2 -1
- package/dist/components/DtourToolbar.d.ts.map +1 -1
- package/dist/components/Gallery.d.ts +3 -3
- package/dist/components/Gallery.d.ts.map +1 -1
- package/dist/components/RevertCameraButton.d.ts +6 -0
- package/dist/components/RevertCameraButton.d.ts.map +1 -0
- package/dist/components/ui/checkbox.d.ts +6 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/hooks/usePlayback.d.ts +7 -5
- package/dist/hooks/usePlayback.d.ts.map +1 -1
- package/dist/hooks/useScatter.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/layout/gallery-positions.d.ts +3 -1
- package/dist/layout/gallery-positions.d.ts.map +1 -1
- package/dist/layout/selector-size.d.ts +4 -2
- package/dist/layout/selector-size.d.ts.map +1 -1
- package/dist/lib/arcball.d.ts +21 -0
- package/dist/lib/arcball.d.ts.map +1 -0
- package/dist/lib/position-remap.d.ts +16 -0
- package/dist/lib/position-remap.d.ts.map +1 -0
- package/dist/lib/throttle-debounce.d.ts +28 -0
- package/dist/lib/throttle-debounce.d.ts.map +1 -0
- package/dist/radial-chart/RadialChart.d.ts +5 -1
- package/dist/radial-chart/RadialChart.d.ts.map +1 -1
- package/dist/spec.d.ts +32 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/state/atoms.d.ts +67 -0
- package/dist/state/atoms.d.ts.map +1 -1
- package/dist/state/spec-sync.d.ts +2 -0
- package/dist/state/spec-sync.d.ts.map +1 -1
- package/dist/viewer.css +1 -1
- package/dist/viewer.js +11620 -10118
- package/package.json +6 -1
- package/src/Dtour.tsx +82 -9
- package/src/DtourViewer.tsx +480 -100
- package/src/components/AxisOverlay.tsx +332 -182
- package/src/components/CircularSlider.tsx +363 -174
- package/src/components/DtourToolbar.tsx +121 -10
- package/src/components/Gallery.tsx +197 -39
- package/src/components/RevertCameraButton.tsx +39 -0
- package/src/components/ui/checkbox.tsx +32 -0
- package/src/hooks/usePlayback.ts +18 -44
- package/src/hooks/useScatter.ts +21 -5
- package/src/index.ts +16 -3
- package/src/layout/gallery-positions.ts +15 -4
- package/src/layout/selector-size.ts +24 -10
- package/src/lib/arcball.ts +119 -0
- package/src/lib/position-remap.ts +51 -0
- package/src/lib/throttle-debounce.ts +79 -0
- package/src/radial-chart/RadialChart.tsx +45 -6
- package/src/spec.ts +143 -0
- package/src/state/atoms.ts +65 -0
- package/src/state/spec-sync.ts +15 -0
- package/src/styles.css +16 -16
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
2
10
|
import { cn } from '../lib/utils';
|
|
3
11
|
|
|
12
|
+
export type CircularSliderHandle = {
|
|
13
|
+
/** Update slider position imperatively without triggering a React re-render. */
|
|
14
|
+
setPosition: (value: number) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PreviewCenter = { x: number; y: number; size: number };
|
|
18
|
+
|
|
4
19
|
export type CircularSliderProps = {
|
|
5
|
-
/** Current position [0, 1]. */
|
|
20
|
+
/** Current position [0, 1]. In equal mode this is visual position. */
|
|
6
21
|
value: number;
|
|
7
22
|
/** Called on each drag move (immediate position update). */
|
|
8
23
|
onChange: (value: number) => void;
|
|
@@ -14,189 +29,363 @@ export type CircularSliderProps = {
|
|
|
14
29
|
tickCount?: number;
|
|
15
30
|
/** SVG diameter in px. Default 200. */
|
|
16
31
|
size?: number;
|
|
32
|
+
/** Cumulative arc-lengths for variable-width ring and geodesic tick positions. */
|
|
33
|
+
arcLengths?: Float32Array | null;
|
|
34
|
+
/** Slider spacing mode. Default 'equal'. */
|
|
35
|
+
spacingMode?: 'equal' | 'geodesic';
|
|
36
|
+
/** Index of the keyframe nearest to the current tour position, or null. */
|
|
37
|
+
currentKeyframe?: number | null;
|
|
38
|
+
/** Index of the gallery preview being hovered, or null. */
|
|
39
|
+
hoveredKeyframe?: number | null;
|
|
40
|
+
/** Preview center positions relative to the container center, with sizes. */
|
|
41
|
+
previewCenters?: PreviewCenter[];
|
|
17
42
|
};
|
|
18
43
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const dy = clientY - (rect.top + center);
|
|
45
|
-
// atan2 gives angle from +x axis; rotate so 0 is at 10:30 position, clockwise
|
|
46
|
-
// 10:30 = -135° from +x axis, so offset by +135°
|
|
47
|
-
let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 135;
|
|
48
|
-
if (deg < 0) deg += 360;
|
|
49
|
-
if (deg >= 360) deg -= 360;
|
|
50
|
-
return deg / 360; // normalize to [0, 1]
|
|
51
|
-
},
|
|
52
|
-
[center],
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const handlePointerDown = useCallback(
|
|
56
|
-
(e: React.MouseEvent | React.TouchEvent) => {
|
|
57
|
-
setIsDragging(true);
|
|
58
|
-
hasDraggedRef.current = false;
|
|
59
|
-
const pt = 'touches' in e ? e.touches[0] : e;
|
|
60
|
-
if (pt) {
|
|
61
|
-
const pos = angleFromPointer(pt.clientX, pt.clientY);
|
|
62
|
-
// Animated seek on click; falls back to immediate onChange
|
|
63
|
-
(onSeek ?? onChange)(pos);
|
|
64
|
-
}
|
|
44
|
+
const START_DEG = -135;
|
|
45
|
+
const BASE_STROKE = 3;
|
|
46
|
+
const MIN_STROKE = 1;
|
|
47
|
+
const MAX_STROKE = 7;
|
|
48
|
+
|
|
49
|
+
const TICK_LEN = 5; // normal tick length (outward from ring)
|
|
50
|
+
const TICK_GAP = 3; // gap between ring and tick start
|
|
51
|
+
const ACTIVE_EXTRA = 4; // extra length for active tick
|
|
52
|
+
const ACTIVE_WIDTH = 4; // stroke width for active tick
|
|
53
|
+
const CP1_OFFSET = 24; // bezier control point 1 offset along radial direction
|
|
54
|
+
|
|
55
|
+
export const CircularSlider = forwardRef<CircularSliderHandle, CircularSliderProps>(
|
|
56
|
+
(
|
|
57
|
+
{
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
onSeek,
|
|
61
|
+
onDragStart,
|
|
62
|
+
tickCount = 8,
|
|
63
|
+
size = 200,
|
|
64
|
+
arcLengths,
|
|
65
|
+
spacingMode = 'equal',
|
|
66
|
+
currentKeyframe,
|
|
67
|
+
hoveredKeyframe,
|
|
68
|
+
previewCenters,
|
|
65
69
|
},
|
|
66
|
-
|
|
67
|
-
)
|
|
70
|
+
ref,
|
|
71
|
+
) => {
|
|
72
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
73
|
+
const hasDraggedRef = useRef(false);
|
|
74
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
75
|
+
const handleCircleRef = useRef<SVGCircleElement>(null);
|
|
76
|
+
const hitAreaRef = useRef<SVGCircleElement>(null);
|
|
77
|
+
const arcRef = useRef<SVGPathElement>(null);
|
|
78
|
+
const prevValueRef = useRef(value);
|
|
79
|
+
|
|
80
|
+
const center = size / 2;
|
|
81
|
+
const radius = size * 0.4;
|
|
82
|
+
const startRad = (START_DEG * Math.PI) / 180;
|
|
83
|
+
const startX = center + radius * Math.cos(startRad);
|
|
84
|
+
const startY = center + radius * Math.sin(startRad);
|
|
85
|
+
|
|
86
|
+
/** Compute the angle (radians) for a given keyframe index. */
|
|
87
|
+
const tickRad = useCallback(
|
|
88
|
+
(i: number): number => {
|
|
89
|
+
let tickFraction: number;
|
|
90
|
+
if (spacingMode === 'geodesic' && arcLengths && i < arcLengths.length) {
|
|
91
|
+
tickFraction = arcLengths[i]!;
|
|
92
|
+
} else {
|
|
93
|
+
tickFraction = i / tickCount;
|
|
94
|
+
}
|
|
95
|
+
return ((tickFraction * 360 + START_DEG) * Math.PI) / 180;
|
|
96
|
+
},
|
|
97
|
+
[spacingMode, arcLengths, tickCount],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/** Update SVG DOM elements directly — no React re-render needed. */
|
|
101
|
+
const updateDom = useCallback(
|
|
102
|
+
(val: number) => {
|
|
103
|
+
const isWrapping = Math.abs(val - prevValueRef.current) > 0.5;
|
|
104
|
+
prevValueRef.current = val;
|
|
105
|
+
|
|
106
|
+
const handleRad = ((val * 360 + START_DEG) * Math.PI) / 180;
|
|
107
|
+
const hx = center + radius * Math.cos(handleRad);
|
|
108
|
+
const hy = center + radius * Math.sin(handleRad);
|
|
109
|
+
|
|
110
|
+
handleCircleRef.current?.setAttribute('cx', String(hx));
|
|
111
|
+
handleCircleRef.current?.setAttribute('cy', String(hy));
|
|
112
|
+
hitAreaRef.current?.setAttribute('cx', String(hx));
|
|
113
|
+
hitAreaRef.current?.setAttribute('cy', String(hy));
|
|
114
|
+
|
|
115
|
+
if (val > 0.001 && !isWrapping) {
|
|
116
|
+
const largeArc = val > 0.5 ? 1 : 0;
|
|
117
|
+
arcRef.current?.setAttribute(
|
|
118
|
+
'd',
|
|
119
|
+
`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArc} 1 ${hx} ${hy}`,
|
|
120
|
+
);
|
|
121
|
+
arcRef.current?.removeAttribute('display');
|
|
122
|
+
} else {
|
|
123
|
+
arcRef.current?.setAttribute('display', 'none');
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[center, radius, startX, startY],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Expose imperative handle for parent to update position without re-renders
|
|
130
|
+
useImperativeHandle(ref, () => ({ setPosition: updateDom }), [updateDom]);
|
|
131
|
+
|
|
132
|
+
// Sync DOM when value prop changes (debounced atom updates, seek animations)
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
updateDom(value);
|
|
135
|
+
}, [value, updateDom]);
|
|
136
|
+
|
|
137
|
+
const angleFromPointer = useCallback(
|
|
138
|
+
(clientX: number, clientY: number): number => {
|
|
139
|
+
if (!svgRef.current) return 0;
|
|
140
|
+
const rect = svgRef.current.getBoundingClientRect();
|
|
141
|
+
const dx = clientX - (rect.left + center);
|
|
142
|
+
const dy = clientY - (rect.top + center);
|
|
143
|
+
// atan2 gives angle from +x axis; rotate so 0 is at 10:30 position, clockwise
|
|
144
|
+
// 10:30 = -135° from +x axis, so offset by +135°
|
|
145
|
+
let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 135;
|
|
146
|
+
if (deg < 0) deg += 360;
|
|
147
|
+
if (deg >= 360) deg -= 360;
|
|
148
|
+
return deg / 360; // normalize to [0, 1]
|
|
149
|
+
},
|
|
150
|
+
[center],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const handlePointerDown = useCallback(
|
|
154
|
+
(e: React.MouseEvent | React.TouchEvent) => {
|
|
155
|
+
setIsDragging(true);
|
|
156
|
+
hasDraggedRef.current = false;
|
|
157
|
+
const pt = 'touches' in e ? e.touches[0] : e;
|
|
158
|
+
if (pt) {
|
|
159
|
+
const pos = angleFromPointer(pt.clientX, pt.clientY);
|
|
160
|
+
// Animated seek on click; falls back to immediate onChange
|
|
161
|
+
(onSeek ?? onChange)(pos);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
[angleFromPointer, onChange, onSeek],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!isDragging) return;
|
|
169
|
+
|
|
170
|
+
const onMove = (e: MouseEvent | TouchEvent) => {
|
|
171
|
+
if (!hasDraggedRef.current) {
|
|
172
|
+
hasDraggedRef.current = true;
|
|
173
|
+
onDragStart?.(); // let parent cancel any running animation
|
|
174
|
+
}
|
|
175
|
+
const pt = 'touches' in e ? e.touches[0] : e;
|
|
176
|
+
if (pt) onChange(angleFromPointer(pt.clientX, pt.clientY));
|
|
177
|
+
if ('touches' in e) e.preventDefault();
|
|
178
|
+
};
|
|
179
|
+
const onUp = () => setIsDragging(false);
|
|
180
|
+
|
|
181
|
+
document.addEventListener('mousemove', onMove);
|
|
182
|
+
document.addEventListener('mouseup', onUp);
|
|
183
|
+
document.addEventListener('touchmove', onMove, { passive: false });
|
|
184
|
+
document.addEventListener('touchend', onUp);
|
|
185
|
+
|
|
186
|
+
return () => {
|
|
187
|
+
document.removeEventListener('mousemove', onMove);
|
|
188
|
+
document.removeEventListener('mouseup', onUp);
|
|
189
|
+
document.removeEventListener('touchmove', onMove);
|
|
190
|
+
document.removeEventListener('touchend', onUp);
|
|
191
|
+
};
|
|
192
|
+
}, [isDragging, angleFromPointer, onChange, onDragStart]);
|
|
193
|
+
|
|
194
|
+
// Initial positions from value prop (first paint; updateDom takes over after mount)
|
|
195
|
+
const handleRad = ((value * 360 + START_DEG) * Math.PI) / 180;
|
|
196
|
+
const handleX = center + radius * Math.cos(handleRad);
|
|
197
|
+
const handleY = center + radius * Math.sin(handleRad);
|
|
198
|
+
|
|
199
|
+
// Tick marks — outward facing, positioned at arc-length fractions (geodesic)
|
|
200
|
+
// or evenly spaced (equal). Active tick is longer and wider.
|
|
201
|
+
const ticks = useMemo(() => {
|
|
202
|
+
return Array.from({ length: tickCount }, (_, i) => {
|
|
203
|
+
const angle = tickRad(i);
|
|
204
|
+
const isActive = i === currentKeyframe;
|
|
205
|
+
const extra = isActive ? ACTIVE_EXTRA : 0;
|
|
206
|
+
const r1 = radius + TICK_GAP;
|
|
207
|
+
const r2 = radius + TICK_GAP + TICK_LEN + extra;
|
|
208
|
+
return (
|
|
209
|
+
<line
|
|
210
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: tick key
|
|
211
|
+
key={i}
|
|
212
|
+
x1={center + r1 * Math.cos(angle)}
|
|
213
|
+
y1={center + r1 * Math.sin(angle)}
|
|
214
|
+
x2={center + r2 * Math.cos(angle)}
|
|
215
|
+
y2={center + r2 * Math.sin(angle)}
|
|
216
|
+
stroke={isActive ? 'white' : 'var(--color-dtour-text-muted)'}
|
|
217
|
+
strokeWidth={isActive ? ACTIVE_WIDTH : 2}
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
}, [tickCount, tickRad, radius, center, currentKeyframe]);
|
|
222
|
+
|
|
223
|
+
// Bezier connector — cubic bezier from tick outward end to preview center.
|
|
224
|
+
// Shown on hover (hoveredKeyframe) or during playback (currentKeyframe).
|
|
225
|
+
const connector = useMemo(() => {
|
|
226
|
+
const kf = hoveredKeyframe ?? currentKeyframe;
|
|
227
|
+
if (kf == null || kf >= tickCount) return null;
|
|
228
|
+
const pc = previewCenters?.[kf];
|
|
229
|
+
if (!pc || (pc.x === 0 && pc.y === 0 && pc.size === 0)) return null;
|
|
68
230
|
|
|
69
|
-
|
|
70
|
-
|
|
231
|
+
const angle = tickRad(kf);
|
|
232
|
+
const cos = Math.cos(angle);
|
|
233
|
+
const sin = Math.sin(angle);
|
|
71
234
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
235
|
+
// Start: tick outward end
|
|
236
|
+
const tickEndR = radius + TICK_GAP + TICK_LEN + ACTIVE_EXTRA;
|
|
237
|
+
const sx = center + tickEndR * cos;
|
|
238
|
+
const sy = center + tickEndR * sin;
|
|
239
|
+
|
|
240
|
+
// Control point 1: 24px further along the radial from center through tick
|
|
241
|
+
const cp1x = center + (tickEndR + CP1_OFFSET) * cos;
|
|
242
|
+
const cp1y = center + (tickEndR + CP1_OFFSET) * sin;
|
|
243
|
+
|
|
244
|
+
// End: preview center in SVG coordinates
|
|
245
|
+
const ex = center + pc.x;
|
|
246
|
+
const ey = center + pc.y;
|
|
247
|
+
|
|
248
|
+
// Control point 2: from preview center, move toward SVG center by half preview size
|
|
249
|
+
const dx = center - ex;
|
|
250
|
+
const dy = center - ey;
|
|
251
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
252
|
+
const halfSize = pc.size / 2;
|
|
253
|
+
const cp2x = dist > 1e-6 ? ex + (dx / dist) * halfSize : ex;
|
|
254
|
+
const cp2y = dist > 1e-6 ? ey + (dy / dist) * halfSize : ey;
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<path
|
|
258
|
+
d={`M ${sx} ${sy} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${ex} ${ey}`}
|
|
259
|
+
fill="none"
|
|
260
|
+
stroke="var(--color-dtour-highlight)"
|
|
261
|
+
strokeWidth="1"
|
|
262
|
+
strokeDasharray="3 3"
|
|
263
|
+
opacity="0.4"
|
|
264
|
+
className="pointer-events-none"
|
|
265
|
+
/>
|
|
266
|
+
);
|
|
267
|
+
}, [hoveredKeyframe, currentKeyframe, tickCount, tickRad, radius, center, previewCenters]);
|
|
268
|
+
|
|
269
|
+
// Variable-width ring segments for equal mode — stroke width proportional
|
|
270
|
+
// to the segment's geodesic length. Thin = small geodesic distance (stretched),
|
|
271
|
+
// thick = large geodesic distance (compressed).
|
|
272
|
+
const ringSegments = useMemo(() => {
|
|
273
|
+
if (spacingMode !== 'equal' || !arcLengths || arcLengths.length < 2) return null;
|
|
274
|
+
const n = arcLengths.length - 1;
|
|
275
|
+
const expected = 1 / n;
|
|
276
|
+
const segments: React.ReactElement[] = [];
|
|
277
|
+
for (let i = 0; i < n; i++) {
|
|
278
|
+
const segLen = arcLengths[i + 1]! - arcLengths[i]!;
|
|
279
|
+
const ratio = expected > 1e-10 ? segLen / expected : 1;
|
|
280
|
+
const strokeW = Math.max(MIN_STROKE, Math.min(MAX_STROKE, BASE_STROKE * ratio));
|
|
281
|
+
|
|
282
|
+
const startFrac = i / n;
|
|
283
|
+
const endFrac = (i + 1) / n;
|
|
284
|
+
const a1 = ((startFrac * 360 + START_DEG) * Math.PI) / 180;
|
|
285
|
+
const a2 = ((endFrac * 360 + START_DEG) * Math.PI) / 180;
|
|
286
|
+
|
|
287
|
+
const x1 = center + radius * Math.cos(a1);
|
|
288
|
+
const y1 = center + radius * Math.sin(a1);
|
|
289
|
+
const x2 = center + radius * Math.cos(a2);
|
|
290
|
+
const y2 = center + radius * Math.sin(a2);
|
|
291
|
+
|
|
292
|
+
const sweep = endFrac - startFrac;
|
|
293
|
+
const largeArc = sweep > 0.5 ? 1 : 0;
|
|
294
|
+
|
|
295
|
+
segments.push(
|
|
296
|
+
<path
|
|
297
|
+
key={i}
|
|
298
|
+
d={`M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`}
|
|
299
|
+
fill="none"
|
|
300
|
+
stroke="var(--color-dtour-border)"
|
|
301
|
+
strokeWidth={strokeW}
|
|
302
|
+
className="pointer-events-none"
|
|
303
|
+
/>,
|
|
304
|
+
);
|
|
76
305
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
};
|
|
81
|
-
const onUp = () => setIsDragging(false);
|
|
82
|
-
|
|
83
|
-
document.addEventListener('mousemove', onMove);
|
|
84
|
-
document.addEventListener('mouseup', onUp);
|
|
85
|
-
document.addEventListener('touchmove', onMove, { passive: false });
|
|
86
|
-
document.addEventListener('touchend', onUp);
|
|
87
|
-
|
|
88
|
-
return () => {
|
|
89
|
-
document.removeEventListener('mousemove', onMove);
|
|
90
|
-
document.removeEventListener('mouseup', onUp);
|
|
91
|
-
document.removeEventListener('touchmove', onMove);
|
|
92
|
-
document.removeEventListener('touchend', onUp);
|
|
93
|
-
};
|
|
94
|
-
}, [isDragging, angleFromPointer, onChange, onDragStart]);
|
|
95
|
-
|
|
96
|
-
// Convert value [0,1] back to handle position
|
|
97
|
-
// 10:30 start = -135° from +x axis
|
|
98
|
-
const startDeg = -135;
|
|
99
|
-
const handleRad = ((value * 360 + startDeg) * Math.PI) / 180;
|
|
100
|
-
const handleX = center + radius * Math.cos(handleRad);
|
|
101
|
-
const handleY = center + radius * Math.sin(handleRad);
|
|
102
|
-
|
|
103
|
-
// Arc from 10:30 to handle
|
|
104
|
-
const startRad = (startDeg * Math.PI) / 180;
|
|
105
|
-
const startX = center + radius * Math.cos(startRad);
|
|
106
|
-
const startY = center + radius * Math.sin(startRad);
|
|
107
|
-
const largeArc = value > 0.5 ? 1 : 0;
|
|
108
|
-
|
|
109
|
-
// Tick marks
|
|
110
|
-
const ticks = Array.from({ length: tickCount }, (_, i) => {
|
|
111
|
-
const tickRad = (((i / tickCount) * 360 + startDeg) * Math.PI) / 180;
|
|
112
|
-
const r1 = radius - 8;
|
|
113
|
-
const r2 = radius - 3;
|
|
306
|
+
return segments;
|
|
307
|
+
}, [spacingMode, arcLengths, radius, center]);
|
|
308
|
+
|
|
114
309
|
return (
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
r={radius}
|
|
153
|
-
fill="none"
|
|
154
|
-
stroke="var(--color-dtour-border)"
|
|
155
|
-
strokeWidth="3"
|
|
156
|
-
className="pointer-events-none"
|
|
157
|
-
/>
|
|
158
|
-
{/* Ticks */}
|
|
159
|
-
{ticks}
|
|
160
|
-
{/* Arc showing position */}
|
|
161
|
-
{value > 0.001 && !isWrapping && (
|
|
310
|
+
<svg
|
|
311
|
+
ref={svgRef}
|
|
312
|
+
width={size}
|
|
313
|
+
height={size}
|
|
314
|
+
overflow="visible"
|
|
315
|
+
onMouseDown={handlePointerDown}
|
|
316
|
+
onTouchStart={handlePointerDown}
|
|
317
|
+
className="pointer-events-none select-none touch-none"
|
|
318
|
+
>
|
|
319
|
+
<title>Circular slider for Dtour</title>
|
|
320
|
+
{/* Transparent hit area for track ring — wider for easier clicking */}
|
|
321
|
+
<circle
|
|
322
|
+
cx={center}
|
|
323
|
+
cy={center}
|
|
324
|
+
r={radius}
|
|
325
|
+
fill="none"
|
|
326
|
+
stroke="transparent"
|
|
327
|
+
strokeWidth="20"
|
|
328
|
+
className="cursor-pointer pointer-events-auto"
|
|
329
|
+
/>
|
|
330
|
+
{/* Track ring — variable width in equal mode, constant in geodesic */}
|
|
331
|
+
{ringSegments ?? (
|
|
332
|
+
<circle
|
|
333
|
+
cx={center}
|
|
334
|
+
cy={center}
|
|
335
|
+
r={radius}
|
|
336
|
+
fill="none"
|
|
337
|
+
stroke="var(--color-dtour-border)"
|
|
338
|
+
strokeWidth="3"
|
|
339
|
+
className="pointer-events-none"
|
|
340
|
+
/>
|
|
341
|
+
)}
|
|
342
|
+
{/* Ticks (outward facing) */}
|
|
343
|
+
{ticks}
|
|
344
|
+
{/* Bezier connector — from tick to preview center */}
|
|
345
|
+
{connector}
|
|
346
|
+
{/* Arc showing position — always rendered, visibility controlled imperatively */}
|
|
162
347
|
<path
|
|
163
|
-
|
|
348
|
+
ref={arcRef}
|
|
349
|
+
d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${value > 0.5 ? 1 : 0} 1 ${handleX} ${handleY}`}
|
|
350
|
+
display={value > 0.001 ? undefined : 'none'}
|
|
164
351
|
fill="none"
|
|
165
352
|
stroke="var(--color-dtour-highlight)"
|
|
166
353
|
strokeWidth="4"
|
|
167
354
|
strokeLinecap="round"
|
|
168
355
|
className="pointer-events-none"
|
|
169
356
|
/>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
357
|
+
{/* Center dot */}
|
|
358
|
+
<circle
|
|
359
|
+
cx={center}
|
|
360
|
+
cy={center}
|
|
361
|
+
r="3"
|
|
362
|
+
fill="var(--color-dtour-text-muted)"
|
|
363
|
+
className="pointer-events-none"
|
|
364
|
+
/>
|
|
365
|
+
{/* Transparent hit area for handle — larger for easier grabbing */}
|
|
366
|
+
<circle
|
|
367
|
+
ref={hitAreaRef}
|
|
368
|
+
cx={handleX}
|
|
369
|
+
cy={handleY}
|
|
370
|
+
r="16"
|
|
371
|
+
fill="transparent"
|
|
372
|
+
className={cn(
|
|
373
|
+
'cursor-grab pointer-events-auto',
|
|
374
|
+
isDragging ? 'cursor-grabbing' : 'cursor-grab',
|
|
375
|
+
)}
|
|
376
|
+
/>
|
|
377
|
+
{/* Handle */}
|
|
378
|
+
<circle
|
|
379
|
+
ref={handleCircleRef}
|
|
380
|
+
cx={handleX}
|
|
381
|
+
cy={handleY}
|
|
382
|
+
r="8"
|
|
383
|
+
fill="var(--color-dtour-highlight)"
|
|
384
|
+
stroke="var(--color-dtour-bg)"
|
|
385
|
+
strokeWidth="2"
|
|
386
|
+
className="pointer-events-none"
|
|
387
|
+
/>
|
|
388
|
+
</svg>
|
|
389
|
+
);
|
|
390
|
+
},
|
|
391
|
+
);
|