@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,224 @@
|
|
|
1
|
+
import type { ScatterInstance } from '@dtour/scatter';
|
|
2
|
+
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { gramSchmidt } from '../lib/gram-schmidt.ts';
|
|
5
|
+
import {
|
|
6
|
+
activeIndicesAtom,
|
|
7
|
+
cameraPanXAtom,
|
|
8
|
+
cameraPanYAtom,
|
|
9
|
+
cameraZoomAtom,
|
|
10
|
+
currentBasisAtom,
|
|
11
|
+
metadataAtom,
|
|
12
|
+
} from '../state/atoms.ts';
|
|
13
|
+
|
|
14
|
+
type AxisOverlayProps = {
|
|
15
|
+
scatter: ScatterInstance | null;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Convert SVG pixel delta to NDC delta (inverse camera). */
|
|
21
|
+
const svgDeltaToNdc = (
|
|
22
|
+
dx: number,
|
|
23
|
+
dy: number,
|
|
24
|
+
width: number,
|
|
25
|
+
height: number,
|
|
26
|
+
zoom: number,
|
|
27
|
+
): [number, number] => {
|
|
28
|
+
const aspect = width / height || 1;
|
|
29
|
+
const ndcDx = ((dx / width) * 2 * aspect) / zoom;
|
|
30
|
+
const ndcDy = (-(dy / height) * 2) / zoom;
|
|
31
|
+
return [ndcDx, ndcDy];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const HANDLE_RADIUS = 6;
|
|
35
|
+
const AXIS_COLORS = ['#e06060', '#60a0e0', '#60c060', '#c080e0', '#e0a040', '#40c0c0'];
|
|
36
|
+
|
|
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;
|
|
74
|
+
}
|
|
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();
|
|
101
|
+
|
|
102
|
+
const [ndcDx, ndcDy] = svgDeltaToNdc(e.movementX, e.movementY, width, height, zoom);
|
|
103
|
+
const basis = basisRef.current;
|
|
104
|
+
|
|
105
|
+
// Update basis row d: column 0 (x projection) and column 1 (y projection)
|
|
106
|
+
basis[d]! += ndcDx;
|
|
107
|
+
basis[dims + d]! += ndcDy;
|
|
108
|
+
|
|
109
|
+
// Re-orthonormalize
|
|
110
|
+
gramSchmidt(basis, dims);
|
|
111
|
+
|
|
112
|
+
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;
|
|
125
|
+
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
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { cn } from '../lib/utils';
|
|
3
|
+
|
|
4
|
+
export type CircularSliderProps = {
|
|
5
|
+
/** Current position [0, 1]. */
|
|
6
|
+
value: number;
|
|
7
|
+
/** Called on each drag move (immediate position update). */
|
|
8
|
+
onChange: (value: number) => void;
|
|
9
|
+
/** Called on initial click/tap (animated seek). Falls back to onChange if omitted. */
|
|
10
|
+
onSeek?: (value: number) => void;
|
|
11
|
+
/** Called on first drag move after mousedown (lets parent cancel animations). */
|
|
12
|
+
onDragStart?: () => void;
|
|
13
|
+
/** Number of tick marks around the ring (typically = number of tour views). */
|
|
14
|
+
tickCount?: number;
|
|
15
|
+
/** SVG diameter in px. Default 200. */
|
|
16
|
+
size?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
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
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[angleFromPointer, onChange, onSeek],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!isDragging) return;
|
|
71
|
+
|
|
72
|
+
const onMove = (e: MouseEvent | TouchEvent) => {
|
|
73
|
+
if (!hasDraggedRef.current) {
|
|
74
|
+
hasDraggedRef.current = true;
|
|
75
|
+
onDragStart?.(); // let parent cancel any running animation
|
|
76
|
+
}
|
|
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;
|
|
114
|
+
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 && (
|
|
162
|
+
<path
|
|
163
|
+
d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArc} 1 ${handleX} ${handleY}`}
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="var(--color-dtour-highlight)"
|
|
166
|
+
strokeWidth="4"
|
|
167
|
+
strokeLinecap="round"
|
|
168
|
+
className="pointer-events-none"
|
|
169
|
+
/>
|
|
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
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { GLASBEY_DARK, GLASBEY_LIGHT, MAGMA_25, OKABE_ITO, VIRIDIS_25 } from '@dtour/scatter';
|
|
2
|
+
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
import { hexToRgb255 } from '../lib/color-utils.ts';
|
|
5
|
+
import {
|
|
6
|
+
colorMapAtom,
|
|
7
|
+
legendClearGenAtom,
|
|
8
|
+
legendSelectionAtom,
|
|
9
|
+
metadataAtom,
|
|
10
|
+
paletteAtom,
|
|
11
|
+
pointColorAtom,
|
|
12
|
+
resolvedThemeAtom,
|
|
13
|
+
} from '../state/atoms.ts';
|
|
14
|
+
|
|
15
|
+
const handleSwatchClick = (
|
|
16
|
+
index: number,
|
|
17
|
+
event: React.MouseEvent,
|
|
18
|
+
setSelection: (update: (prev: Set<number> | null) => Set<number> | null) => void,
|
|
19
|
+
setClearGen: (update: (prev: number) => number) => void,
|
|
20
|
+
) => {
|
|
21
|
+
const isMeta = event.metaKey || event.ctrlKey;
|
|
22
|
+
const isAlt = event.altKey;
|
|
23
|
+
|
|
24
|
+
setSelection((prev) => {
|
|
25
|
+
if (isMeta) {
|
|
26
|
+
const next = new Set(prev ?? []);
|
|
27
|
+
if (next.has(index)) next.delete(index);
|
|
28
|
+
else next.add(index);
|
|
29
|
+
if (next.size === 0) {
|
|
30
|
+
setClearGen((g) => g + 1);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isAlt) {
|
|
37
|
+
if (!prev) return prev;
|
|
38
|
+
const next = new Set(prev);
|
|
39
|
+
next.delete(index);
|
|
40
|
+
if (next.size === 0) {
|
|
41
|
+
setClearGen((g) => g + 1);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Plain click: select only this one, or toggle off if sole selection
|
|
48
|
+
if (prev && prev.size === 1 && prev.has(index)) {
|
|
49
|
+
setClearGen((g) => g + 1);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return new Set([index]);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const ColorLegend = () => {
|
|
57
|
+
const metadata = useAtomValue(metadataAtom);
|
|
58
|
+
const pointColor = useAtomValue(pointColorAtom);
|
|
59
|
+
const palette = useAtomValue(paletteAtom);
|
|
60
|
+
const resolvedTheme = useAtomValue(resolvedThemeAtom);
|
|
61
|
+
const rawColorMap = useAtomValue(colorMapAtom);
|
|
62
|
+
const [legendSelection, setLegendSelection] = useAtom(legendSelectionAtom);
|
|
63
|
+
const setClearGen = useSetAtom(legendClearGenAtom);
|
|
64
|
+
|
|
65
|
+
const onSwatchClick = useCallback(
|
|
66
|
+
(index: number, event: React.MouseEvent) => {
|
|
67
|
+
handleSwatchClick(index, event, setLegendSelection, setClearGen);
|
|
68
|
+
},
|
|
69
|
+
[setLegendSelection, setClearGen],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (typeof pointColor !== 'string' || !metadata) return null;
|
|
73
|
+
const column = pointColor;
|
|
74
|
+
|
|
75
|
+
const isCategorical = metadata.categoricalColumnNames.includes(column);
|
|
76
|
+
const hasSelection = legendSelection !== null;
|
|
77
|
+
|
|
78
|
+
if (isCategorical) {
|
|
79
|
+
const labels = metadata.categoricalLabels[column] ?? [];
|
|
80
|
+
let colors: [number, number, number][];
|
|
81
|
+
if (rawColorMap) {
|
|
82
|
+
colors = labels.map((label) => {
|
|
83
|
+
const v = rawColorMap[label];
|
|
84
|
+
if (!v) return [128, 128, 128] as [number, number, number];
|
|
85
|
+
const hex = typeof v === 'string' ? v : v[resolvedTheme];
|
|
86
|
+
return hexToRgb255(hex);
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
const glasbey = resolvedTheme === 'light' ? GLASBEY_LIGHT : GLASBEY_DARK;
|
|
90
|
+
colors =
|
|
91
|
+
labels.length <= OKABE_ITO.length
|
|
92
|
+
? OKABE_ITO
|
|
93
|
+
: ([
|
|
94
|
+
...OKABE_ITO,
|
|
95
|
+
...Array(Math.ceil((labels.length - OKABE_ITO.length) / glasbey.length))
|
|
96
|
+
.fill(undefined)
|
|
97
|
+
.flatMap(() => glasbey),
|
|
98
|
+
] as [number, number, number][]);
|
|
99
|
+
}
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex h-full flex-col overflow-hidden bg-dtour-bg text-xs text-dtour-text">
|
|
102
|
+
<div className="flex h-10 shrink-0 items-center border-b border-dtour-surface px-3 font-semibold text-dtour-highlight truncate">
|
|
103
|
+
{column}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex flex-col gap-1.5 overflow-y-auto px-3 pt-3 pb-3">
|
|
106
|
+
{labels.map((label, i) => {
|
|
107
|
+
const [r, g, b] = colors[i % colors.length]!;
|
|
108
|
+
const dimmed = hasSelection && !legendSelection.has(i);
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
key={label}
|
|
112
|
+
type="button"
|
|
113
|
+
className={`flex items-center gap-2 min-w-0 cursor-pointer rounded px-1 py-0.5 transition-opacity hover:bg-dtour-highlight/10 ${dimmed ? 'opacity-35' : 'opacity-100'}`}
|
|
114
|
+
onClick={(e) => onSwatchClick(i, e)}
|
|
115
|
+
>
|
|
116
|
+
<span
|
|
117
|
+
className="shrink-0 w-3 h-3 rounded-sm"
|
|
118
|
+
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
|
119
|
+
/>
|
|
120
|
+
<span className="truncate">{label}</span>
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Continuous (numeric column)
|
|
130
|
+
const colIndex = metadata.columnNames.indexOf(column);
|
|
131
|
+
if (colIndex === -1) return null;
|
|
132
|
+
const min = metadata.mins[colIndex]!;
|
|
133
|
+
const max = metadata.maxes[colIndex]!;
|
|
134
|
+
|
|
135
|
+
// 13 stops from 25-entry cmap (step 2): labeled on even stopIdx, unlabeled in between
|
|
136
|
+
const step = 2;
|
|
137
|
+
const stops: { value: number; color: [number, number, number]; stopIdx: number }[] = [];
|
|
138
|
+
const baseCmap = palette === 'magma' ? MAGMA_25 : VIRIDIS_25;
|
|
139
|
+
const cmap =
|
|
140
|
+
resolvedTheme === 'light' ? ([...baseCmap].reverse() as [number, number, number][]) : baseCmap;
|
|
141
|
+
for (let i = 0; i < cmap.length; i += step) {
|
|
142
|
+
const t = i / (cmap.length - 1);
|
|
143
|
+
stops.push({ value: min + t * (max - min), color: cmap[i]!, stopIdx: i / step });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex h-full flex-col overflow-hidden bg-dtour-bg text-xs text-dtour-text">
|
|
148
|
+
<div className="flex h-10 shrink-0 items-center border-b border-dtour-surface px-3 font-semibold text-dtour-highlight truncate">
|
|
149
|
+
{column}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="flex flex-col gap-0.5 overflow-y-auto px-3 pt-3 pb-3">
|
|
152
|
+
{stops.reverse().map(({ value, color: [r, g, b], stopIdx }) => {
|
|
153
|
+
const dimmed = hasSelection && !legendSelection.has(stopIdx);
|
|
154
|
+
const showLabel = stopIdx % 2 === 0;
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
key={value}
|
|
158
|
+
type="button"
|
|
159
|
+
className={`flex items-center gap-2 min-w-0 cursor-pointer rounded px-1 py-0.5 transition-opacity hover:bg-dtour-highlight/10 ${dimmed ? 'opacity-35' : 'opacity-100'}`}
|
|
160
|
+
onClick={(e) => onSwatchClick(stopIdx, e)}
|
|
161
|
+
>
|
|
162
|
+
<span
|
|
163
|
+
className="shrink-0 w-3 h-3 rounded-sm"
|
|
164
|
+
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
|
165
|
+
/>
|
|
166
|
+
{showLabel && <span className="truncate">{formatValue(value)}</span>}
|
|
167
|
+
</button>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const formatValue = (v: number): string => {
|
|
176
|
+
if (Number.isInteger(v) && Math.abs(v) < 1e6) return String(v);
|
|
177
|
+
return v.toPrecision(3);
|
|
178
|
+
};
|