@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,6 +1,14 @@
1
1
  import type { ScatterInstance } from '@dtour/scatter';
2
2
  import { useAtomValue, useSetAtom, useStore } from 'jotai';
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
4
12
  import { gramSchmidt } from '../lib/gram-schmidt.ts';
5
13
  import {
6
14
  activeIndicesAtom,
@@ -15,6 +23,17 @@ type AxisOverlayProps = {
15
23
  scatter: ScatterInstance | null;
16
24
  width: number;
17
25
  height: number;
26
+ /** When true, axes track `currentBasisAtom` reactively and are not draggable. */
27
+ readOnly?: boolean;
28
+ };
29
+
30
+ export type AxisOverlayHandle = {
31
+ /** Imperatively update axis positions without a React re-render. */
32
+ setBasis: (basis: Float32Array) => void;
33
+ /** Set 3D rotation — rotates axis lines to match the camera rotation. */
34
+ setRotation3d: (residualPC: Float32Array, matrix: Float32Array) => void;
35
+ /** Clear 3D rotation — revert to standard 2D axis display. */
36
+ clearRotation3d: () => void;
18
37
  };
19
38
 
20
39
  /** Convert SVG pixel delta to NDC delta (inverse camera). */
@@ -34,191 +53,322 @@ const svgDeltaToNdc = (
34
53
  const HANDLE_RADIUS = 6;
35
54
  const AXIS_COLORS = ['#e06060', '#60a0e0', '#60c060', '#c080e0', '#e0a040', '#40c0c0'];
36
55
 
37
- export const AxisOverlay = ({ scatter, width, height }: AxisOverlayProps) => {
38
- const metadata = useAtomValue(metadataAtom);
39
- const panX = useAtomValue(cameraPanXAtom);
40
- const panY = useAtomValue(cameraPanYAtom);
41
- const zoom = useAtomValue(cameraZoomAtom);
42
- const activeIndices = useAtomValue(activeIndicesAtom);
43
- const store = useStore();
44
- const setCurrentBasis = useSetAtom(currentBasisAtom);
45
-
46
- const basisRef = useRef<Float32Array | null>(null);
47
- const [, forceRender] = useState(0);
48
- const draggingRef = useRef<number | null>(null);
49
-
50
- const dims = metadata?.dimCount ?? 0;
51
- const columnNames = metadata?.columnNames ?? [];
52
-
53
- const activeSet = useMemo(() => new Set(activeIndices), [activeIndices]);
54
-
55
- // Initialize basis from the current projection (read imperatively to avoid
56
- // subscribing). This preserves the view when switching into manual mode.
57
- // Re-runs when activeIndices change to zero out inactive dimensions.
58
- useEffect(() => {
59
- if (dims < 2 || activeIndices.length < 2) return;
60
- const current = store.get(currentBasisAtom);
61
- let basis: Float32Array;
62
- if (current && current.length === dims * 2) {
63
- basis = new Float32Array(current);
64
- } else {
65
- basis = new Float32Array(dims * 2);
66
- basis[activeIndices[0]!] = 1;
67
- basis[dims + activeIndices[1]!] = 1;
68
- }
69
- // Zero out inactive dimensions and re-orthonormalize
70
- for (let d = 0; d < dims; d++) {
71
- if (!activeSet.has(d)) {
72
- basis[d] = 0;
73
- basis[dims + d] = 0;
56
+ type AxisElementRefs = {
57
+ outlineLine: SVGLineElement;
58
+ colorLine: SVGLineElement;
59
+ circle: SVGCircleElement;
60
+ text: SVGTextElement;
61
+ };
62
+
63
+ export const AxisOverlay = forwardRef<AxisOverlayHandle, AxisOverlayProps>(
64
+ ({ scatter, width, height, readOnly }, ref) => {
65
+ const metadata = useAtomValue(metadataAtom);
66
+ const zoom = useAtomValue(cameraZoomAtom);
67
+ const activeIndices = useAtomValue(activeIndicesAtom);
68
+ const store = useStore();
69
+ const setCurrentBasis = useSetAtom(currentBasisAtom);
70
+
71
+ const basisRef = useRef<Float32Array | null>(null);
72
+ const residualPCRef = useRef<Float32Array | null>(null);
73
+ const rotationMatRef = useRef<Float32Array | null>(null);
74
+ const [, forceRender] = useState(0);
75
+ const draggingRef = useRef<number | null>(null);
76
+
77
+ const dims = metadata?.dimCount ?? 0;
78
+ const columnNames = metadata?.columnNames ?? [];
79
+
80
+ const activeSet = useMemo(() => new Set(activeIndices), [activeIndices]);
81
+
82
+ // Refs for imperative setBasis — must not depend on React state
83
+ const sizeRef = useRef({ width, height });
84
+ sizeRef.current = { width, height };
85
+ const dimsRef = useRef(dims);
86
+ dimsRef.current = dims;
87
+ const activeIndicesRef = useRef(activeIndices);
88
+ activeIndicesRef.current = activeIndices;
89
+
90
+ // Per-dimension SVG element refs for imperative DOM updates
91
+ const elementRefsMap = useRef(new Map<number, AxisElementRefs>());
92
+
93
+ // Callback ref factory — captures SVG element refs during render
94
+ const setElementRef = useCallback(
95
+ (dimIndex: number, kind: keyof AxisElementRefs) => (el: SVGElement | null) => {
96
+ if (!el) return;
97
+ let entry = elementRefsMap.current.get(dimIndex);
98
+ if (!entry) {
99
+ entry = {} as AxisElementRefs;
100
+ elementRefsMap.current.set(dimIndex, entry);
101
+ }
102
+ // biome-ignore lint/suspicious/noExplicitAny: assigning specific SVG element subtype
103
+ (entry as any)[kind] = el;
104
+ },
105
+ [],
106
+ );
107
+
108
+ // Imperative updateDom — updates SVG attributes directly, no React re-render.
109
+ // Applies 3D rotation when residualPCRef and rotationMatRef are set.
110
+ const updateDom = useCallback((basis: Float32Array) => {
111
+ basisRef.current = basis;
112
+ const { width: w, height: h } = sizeRef.current;
113
+ const cx = w / 2;
114
+ const cy = h / 2;
115
+ const scale = Math.min(w, h) * 0.35;
116
+ const d = dimsRef.current;
117
+ const rpc = residualPCRef.current;
118
+ const rot = rotationMatRef.current;
119
+
120
+ for (const dim of activeIndicesRef.current) {
121
+ let bx = basis[dim]!;
122
+ let by = basis[d + dim]!;
123
+
124
+ if (rpc && rot) {
125
+ const bz = rpc[dim]!;
126
+ // Column-major 3×3: rx = m0*bx + m3*by + m6*bz, ry = m1*bx + m4*by + m7*bz
127
+ const rx = rot[0]! * bx + rot[3]! * by + rot[6]! * bz;
128
+ const ry = rot[1]! * bx + rot[4]! * by + rot[7]! * bz;
129
+ bx = rx;
130
+ by = ry;
131
+ }
132
+
133
+ const endX = cx + bx * scale;
134
+ const endY = cy - by * scale;
135
+ const refs = elementRefsMap.current.get(dim);
136
+ if (!refs) continue;
137
+ const ex = String(endX);
138
+ const ey = String(endY);
139
+ refs.outlineLine.setAttribute('x2', ex);
140
+ refs.outlineLine.setAttribute('y2', ey);
141
+ refs.colorLine.setAttribute('x2', ex);
142
+ refs.colorLine.setAttribute('y2', ey);
143
+ refs.circle.setAttribute('cx', ex);
144
+ refs.circle.setAttribute('cy', ey);
145
+ refs.text.setAttribute('x', String(endX + (bx >= 0 ? 10 : -10)));
146
+ refs.text.setAttribute('y', String(endY + 4));
147
+ refs.text.setAttribute('text-anchor', bx >= 0 ? 'start' : 'end');
74
148
  }
75
- }
76
- gramSchmidt(basis, dims);
77
- basisRef.current = basis;
78
- forceRender((n) => n + 1);
79
- // No setDirectBasis here — the GPU already shows this projection
80
- }, [dims, activeIndices, activeSet, store]);
81
-
82
- const sendBasis = useCallback(() => {
83
- if (!scatter || !basisRef.current) return;
84
- const copy = basisRef.current.slice();
85
- scatter.setDirectBasis(copy);
86
- setCurrentBasis(new Float32Array(copy));
87
- }, [scatter, setCurrentBasis]);
88
-
89
- const handlePointerDown = useCallback((dimIndex: number, e: React.PointerEvent) => {
90
- e.preventDefault();
91
- e.stopPropagation();
92
- (e.target as Element).setPointerCapture(e.pointerId);
93
- draggingRef.current = dimIndex;
94
- }, []);
95
-
96
- const handlePointerMove = useCallback(
97
- (e: React.PointerEvent) => {
98
- const d = draggingRef.current;
99
- if (d === null || !basisRef.current) return;
100
- e.preventDefault();
149
+ }, []);
101
150
 
102
- const [ndcDx, ndcDy] = svgDeltaToNdc(e.movementX, e.movementY, width, height, zoom);
103
- const basis = basisRef.current;
151
+ useImperativeHandle(
152
+ ref,
153
+ () => ({
154
+ setBasis: updateDom,
155
+ setRotation3d: (residualPC: Float32Array, matrix: Float32Array) => {
156
+ residualPCRef.current = residualPC;
157
+ rotationMatRef.current = matrix;
158
+ if (basisRef.current) updateDom(basisRef.current);
159
+ },
160
+ clearRotation3d: () => {
161
+ residualPCRef.current = null;
162
+ rotationMatRef.current = null;
163
+ if (basisRef.current) updateDom(basisRef.current);
164
+ },
165
+ }),
166
+ [updateDom],
167
+ );
104
168
 
105
- // Update basis row d: column 0 (x projection) and column 1 (y projection)
106
- basis[d]! += ndcDx;
107
- basis[dims + d]! += ndcDy;
169
+ // Read-only mode: subscribe to currentBasisAtom and mirror into basisRef
170
+ // on every change so the axes track the guided tour interpolation.
171
+ // This handles initial mount, slider drag, and wheel scrub.
172
+ // During playback, the imperative setBasis path provides smoother updates.
173
+ const currentBasis = useAtomValue(currentBasisAtom);
174
+ useEffect(() => {
175
+ if (!readOnly) return;
176
+ if (currentBasis && currentBasis.length === dims * 2) {
177
+ basisRef.current = new Float32Array(currentBasis);
178
+ forceRender((n) => n + 1);
179
+ }
180
+ }, [readOnly, currentBasis, dims]);
108
181
 
109
- // Re-orthonormalize
182
+ // Interactive mode: initialize basis once from the current projection
183
+ // (read imperatively to avoid subscribing). Preserves the view when
184
+ // switching into manual mode. Re-runs when activeIndices change to
185
+ // zero out inactive dimensions.
186
+ useEffect(() => {
187
+ if (readOnly) return;
188
+ if (dims < 2 || activeIndices.length < 2) return;
189
+ const current = store.get(currentBasisAtom);
190
+ let basis: Float32Array;
191
+ if (current && current.length === dims * 2) {
192
+ basis = new Float32Array(current);
193
+ } else {
194
+ basis = new Float32Array(dims * 2);
195
+ basis[activeIndices[0]!] = 1;
196
+ basis[dims + activeIndices[1]!] = 1;
197
+ }
198
+ // Zero out inactive dimensions and re-orthonormalize
199
+ for (let d = 0; d < dims; d++) {
200
+ if (!activeSet.has(d)) {
201
+ basis[d] = 0;
202
+ basis[dims + d] = 0;
203
+ }
204
+ }
110
205
  gramSchmidt(basis, dims);
111
-
206
+ basisRef.current = basis;
112
207
  forceRender((n) => n + 1);
113
- sendBasis();
114
- },
115
- [width, height, zoom, dims, sendBasis],
116
- );
117
-
118
- const handlePointerUp = useCallback(() => {
119
- draggingRef.current = null;
120
- }, []);
121
-
122
- const handleAltClick = useCallback(
123
- (dimIndex: number, e: React.MouseEvent) => {
124
- if (!e.altKey || !basisRef.current) return;
208
+ // No setDirectBasis here — the GPU already shows this projection
209
+ }, [readOnly, dims, activeIndices, activeSet, store]);
210
+
211
+ const sendBasis = useCallback(() => {
212
+ if (!scatter || !basisRef.current) return;
213
+ const copy = basisRef.current.slice();
214
+ scatter.setDirectBasis(copy);
215
+ setCurrentBasis(new Float32Array(copy));
216
+ }, [scatter, setCurrentBasis]);
217
+
218
+ const handlePointerDown = useCallback((dimIndex: number, e: React.PointerEvent) => {
125
219
  e.preventDefault();
126
- const basis = basisRef.current;
127
- // Negate basis row d (flip axis direction)
128
- basis[dimIndex]! = -basis[dimIndex]!;
129
- basis[dims + dimIndex]! = -basis[dims + dimIndex]!;
130
- forceRender((n) => n + 1);
131
- sendBasis();
132
- },
133
- [dims, sendBasis],
134
- );
135
-
136
- if (!metadata || dims < 2 || !basisRef.current || width === 0) return null;
137
-
138
- const basis = basisRef.current;
139
- const cx = width / 2;
140
- const cy = height / 2;
141
-
142
- // Scale factor for axis lines (proportion of view)
143
- const scale = Math.min(width, height) * 0.35;
144
-
145
- return (
146
- <svg
147
- width={width}
148
- height={height}
149
- role="img"
150
- aria-label="Axis overlay for manual projection control"
151
- className="absolute top-0 left-0 pointer-events-none"
152
- onPointerMove={handlePointerMove}
153
- onPointerUp={handlePointerUp}
154
- >
155
- {activeIndices.map((d) => {
156
- // Basis row d gives (x, y) projection weights
157
- const bx = basis[d]!;
158
- const by = basis[dims + d]!;
159
-
160
- // Use scale to determine line endpoint from center
161
- const lineEndX = cx + bx * scale;
162
- const lineEndY = cy - by * scale; // flip Y
163
-
164
- const color = AXIS_COLORS[d % AXIS_COLORS.length]!;
165
- const label = columnNames[d] ?? `dim${d}`;
166
-
167
- return (
168
- <g key={label}>
169
- {/* Axis line outline */}
170
- <line
171
- x1={cx}
172
- y1={cy}
173
- x2={lineEndX}
174
- y2={lineEndY}
175
- stroke="var(--color-dtour-bg)"
176
- strokeWidth={3}
177
- strokeOpacity={0.6}
178
- />
179
- {/* Axis line */}
180
- <line
181
- x1={cx}
182
- y1={cy}
183
- x2={lineEndX}
184
- y2={lineEndY}
185
- stroke={color}
186
- strokeWidth={1}
187
- strokeOpacity={0.6}
188
- />
189
- {/* Draggable handle */}
190
- <circle
191
- cx={lineEndX}
192
- cy={lineEndY}
193
- r={HANDLE_RADIUS}
194
- fill={color}
195
- stroke="var(--color-dtour-bg)"
196
- strokeWidth={1}
197
- className="cursor-grab pointer-events-auto"
198
- onPointerDown={(e) => handlePointerDown(d, e)}
199
- onKeyDown={(e) =>
200
- e.key === 'Enter' && handleAltClick(d, e as unknown as React.MouseEvent)
201
- }
202
- onClick={(e) => handleAltClick(d, e)}
203
- />
204
- {/* Label */}
205
- <text
206
- x={lineEndX + (bx >= 0 ? 10 : -10)}
207
- y={lineEndY + 4}
208
- fill={color}
209
- fontSize={11}
210
- fontFamily="monospace"
211
- textAnchor={bx >= 0 ? 'start' : 'end'}
212
- className="pointer-events-none select-none"
213
- stroke="var(--color-dtour-bg)"
214
- strokeWidth={2}
215
- paintOrder="stroke"
216
- >
217
- {label}
218
- </text>
219
- </g>
220
- );
221
- })}
222
- </svg>
223
- );
224
- };
220
+ e.stopPropagation();
221
+ (e.target as Element).setPointerCapture(e.pointerId);
222
+ draggingRef.current = dimIndex;
223
+ }, []);
224
+
225
+ const handlePointerMove = useCallback(
226
+ (e: React.PointerEvent) => {
227
+ const d = draggingRef.current;
228
+ if (d === null || !basisRef.current) return;
229
+ e.preventDefault();
230
+
231
+ const [ndcDx, ndcDy] = svgDeltaToNdc(e.movementX, e.movementY, width, height, zoom);
232
+ const basis = basisRef.current;
233
+
234
+ // Update basis row d: column 0 (x projection) and column 1 (y projection)
235
+ basis[d]! += ndcDx;
236
+ basis[dims + d]! += ndcDy;
237
+
238
+ // Re-orthonormalize
239
+ gramSchmidt(basis, dims);
240
+
241
+ forceRender((n) => n + 1);
242
+ sendBasis();
243
+ },
244
+ [width, height, zoom, dims, sendBasis],
245
+ );
246
+
247
+ const handlePointerUp = useCallback(() => {
248
+ draggingRef.current = null;
249
+ }, []);
250
+
251
+ const handleAltClick = useCallback(
252
+ (dimIndex: number, e: React.MouseEvent) => {
253
+ if (!e.altKey || !basisRef.current) return;
254
+ e.preventDefault();
255
+ const basis = basisRef.current;
256
+ // Negate basis row d (flip axis direction)
257
+ basis[dimIndex]! = -basis[dimIndex]!;
258
+ basis[dims + dimIndex]! = -basis[dims + dimIndex]!;
259
+ forceRender((n) => n + 1);
260
+ sendBasis();
261
+ },
262
+ [dims, sendBasis],
263
+ );
264
+
265
+ if (!metadata || dims < 2 || !basisRef.current || width === 0) return null;
266
+
267
+ const basis = basisRef.current;
268
+ const cx = width / 2;
269
+ const cy = height / 2;
270
+
271
+ // Scale factor for axis lines (proportion of view)
272
+ const scale = Math.min(width, height) * 0.35;
273
+
274
+ return (
275
+ <svg
276
+ width={width}
277
+ height={height}
278
+ role="img"
279
+ aria-label={readOnly ? 'Axis overlay' : 'Axis overlay for manual projection control'}
280
+ className="absolute top-0 left-0 pointer-events-none"
281
+ onPointerMove={readOnly ? undefined : handlePointerMove}
282
+ onPointerUp={readOnly ? undefined : handlePointerUp}
283
+ >
284
+ {activeIndices.map((d) => {
285
+ // Basis row d gives (x, y) projection weights
286
+ let bx = basis[d]!;
287
+ let by = basis[dims + d]!;
288
+
289
+ // Apply 3D rotation when active
290
+ const rpc = residualPCRef.current;
291
+ const rot = rotationMatRef.current;
292
+ if (rpc && rot) {
293
+ const bz = rpc[d]!;
294
+ const rx = rot[0]! * bx + rot[3]! * by + rot[6]! * bz;
295
+ const ry = rot[1]! * bx + rot[4]! * by + rot[7]! * bz;
296
+ bx = rx;
297
+ by = ry;
298
+ }
299
+
300
+ // Use scale to determine line endpoint from center
301
+ const lineEndX = cx + bx * scale;
302
+ const lineEndY = cy - by * scale; // flip Y
303
+
304
+ const color = AXIS_COLORS[d % AXIS_COLORS.length]!;
305
+ const label = columnNames[d] ?? `dim${d}`;
306
+
307
+ return (
308
+ <g key={label}>
309
+ {/* Axis line outline */}
310
+ <line
311
+ ref={setElementRef(d, 'outlineLine')}
312
+ x1={cx}
313
+ y1={cy}
314
+ x2={lineEndX}
315
+ y2={lineEndY}
316
+ stroke="var(--color-dtour-bg)"
317
+ strokeWidth={3}
318
+ strokeOpacity={0.6}
319
+ />
320
+ {/* Axis line */}
321
+ <line
322
+ ref={setElementRef(d, 'colorLine')}
323
+ x1={cx}
324
+ y1={cy}
325
+ x2={lineEndX}
326
+ y2={lineEndY}
327
+ stroke={color}
328
+ strokeWidth={1}
329
+ strokeOpacity={0.6}
330
+ strokeDasharray={readOnly ? '2,2' : '0'}
331
+ />
332
+ {/* Handle: draggable in interactive mode, decorative dot in read-only */}
333
+ <circle
334
+ ref={setElementRef(d, 'circle')}
335
+ cx={lineEndX}
336
+ cy={lineEndY}
337
+ r={HANDLE_RADIUS}
338
+ fill={readOnly ? 'var(--color-dtour-bg)' : color}
339
+ stroke={readOnly ? color : 'var(--color-dtour-bg)'}
340
+ strokeWidth={1}
341
+ strokeDasharray={readOnly ? '2,2' : '0'}
342
+ className={readOnly ? undefined : 'cursor-grab pointer-events-auto'}
343
+ onPointerDown={readOnly ? undefined : (e) => handlePointerDown(d, e)}
344
+ onKeyDown={
345
+ readOnly
346
+ ? undefined
347
+ : (e) =>
348
+ e.key === 'Enter' && handleAltClick(d, e as unknown as React.MouseEvent)
349
+ }
350
+ onClick={readOnly ? undefined : (e) => handleAltClick(d, e)}
351
+ />
352
+ {/* Label */}
353
+ <text
354
+ ref={setElementRef(d, 'text')}
355
+ x={lineEndX + (bx >= 0 ? 10 : -10)}
356
+ y={lineEndY + 4}
357
+ fill={color}
358
+ fontSize={11}
359
+ fontFamily="monospace"
360
+ textAnchor={bx >= 0 ? 'start' : 'end'}
361
+ className="pointer-events-none select-none"
362
+ stroke="var(--color-dtour-bg)"
363
+ strokeWidth={2}
364
+ paintOrder="stroke"
365
+ >
366
+ {label}
367
+ </text>
368
+ </g>
369
+ );
370
+ })}
371
+ </svg>
372
+ );
373
+ },
374
+ );