@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,8 +1,23 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
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
- export const CircularSlider = ({
20
- value,
21
- onChange,
22
- onSeek,
23
- onDragStart,
24
- tickCount = 8,
25
- size = 200,
26
- }: CircularSliderProps) => {
27
- const [isDragging, setIsDragging] = useState(false);
28
- const hasDraggedRef = useRef(false);
29
- const svgRef = useRef<SVGSVGElement>(null);
30
- const prevValue = useRef(value);
31
-
32
- // Detect wrap-around (e.g. 0.98 → 0.02) to suppress arc flicker
33
- const isWrapping = Math.abs(value - prevValue.current) > 0.5;
34
- prevValue.current = value;
35
-
36
- const center = size / 2;
37
- const radius = size * 0.4;
38
-
39
- const angleFromPointer = useCallback(
40
- (clientX: number, clientY: number): number => {
41
- if (!svgRef.current) return 0;
42
- const rect = svgRef.current.getBoundingClientRect();
43
- const dx = clientX - (rect.left + center);
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
- [angleFromPointer, onChange, onSeek],
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
- useEffect(() => {
70
- if (!isDragging) return;
231
+ const angle = tickRad(kf);
232
+ const cos = Math.cos(angle);
233
+ const sin = Math.sin(angle);
71
234
 
72
- const onMove = (e: MouseEvent | TouchEvent) => {
73
- if (!hasDraggedRef.current) {
74
- hasDraggedRef.current = true;
75
- onDragStart?.(); // let parent cancel any running animation
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
- const pt = 'touches' in e ? e.touches[0] : e;
78
- if (pt) onChange(angleFromPointer(pt.clientX, pt.clientY));
79
- if ('touches' in e) e.preventDefault();
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
- <line
116
- // biome-ignore lint/suspicious/noArrayIndexKey: tick key
117
- key={i}
118
- x1={center + r1 * Math.cos(tickRad)}
119
- y1={center + r1 * Math.sin(tickRad)}
120
- x2={center + r2 * Math.cos(tickRad)}
121
- y2={center + r2 * Math.sin(tickRad)}
122
- stroke="var(--color-dtour-text-muted)"
123
- strokeWidth="2"
124
- />
125
- );
126
- });
127
-
128
- return (
129
- <svg
130
- ref={svgRef}
131
- width={size}
132
- height={size}
133
- onMouseDown={handlePointerDown}
134
- onTouchStart={handlePointerDown}
135
- className="pointer-events-none select-none touch-none"
136
- >
137
- <title>Circular slider for Dtour</title>
138
- {/* Transparent hit area for track ring — wider for easier clicking */}
139
- <circle
140
- cx={center}
141
- cy={center}
142
- r={radius}
143
- fill="none"
144
- stroke="transparent"
145
- strokeWidth="20"
146
- className="cursor-pointer pointer-events-auto"
147
- />
148
- {/* Track ring */}
149
- <circle
150
- cx={center}
151
- cy={center}
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
- d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArc} 1 ${handleX} ${handleY}`}
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
- {/* Center dot */}
172
- <circle
173
- cx={center}
174
- cy={center}
175
- r="3"
176
- fill="var(--color-dtour-text-muted)"
177
- className="pointer-events-none"
178
- />
179
- {/* Transparent hit area for handle — larger for easier grabbing */}
180
- <circle
181
- cx={handleX}
182
- cy={handleY}
183
- r="16"
184
- fill="transparent"
185
- className={cn(
186
- 'cursor-grab pointer-events-auto',
187
- isDragging ? 'cursor-grabbing' : 'cursor-grab',
188
- )}
189
- />
190
- {/* Handle */}
191
- <circle
192
- cx={handleX}
193
- cy={handleY}
194
- r="8"
195
- fill="var(--color-dtour-highlight)"
196
- stroke="var(--color-dtour-bg)"
197
- strokeWidth="2"
198
- className="pointer-events-none"
199
- />
200
- </svg>
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
+ );