@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,6 +1,14 @@
|
|
|
1
1
|
import type { ScatterInstance } from '@dtour/scatter';
|
|
2
2
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
|
3
|
-
import {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(dimIndex: number, e: React.
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
);
|