@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.
Files changed (114) hide show
  1. package/dist/Dtour.d.ts +46 -0
  2. package/dist/Dtour.d.ts.map +1 -0
  3. package/dist/DtourViewer.d.ts +24 -0
  4. package/dist/DtourViewer.d.ts.map +1 -0
  5. package/dist/components/AxisOverlay.d.ts +9 -0
  6. package/dist/components/AxisOverlay.d.ts.map +1 -0
  7. package/dist/components/CircularSlider.d.ts +16 -0
  8. package/dist/components/CircularSlider.d.ts.map +1 -0
  9. package/dist/components/ColorLegend.d.ts +2 -0
  10. package/dist/components/ColorLegend.d.ts.map +1 -0
  11. package/dist/components/DtourToolbar.d.ts +5 -0
  12. package/dist/components/DtourToolbar.d.ts.map +1 -0
  13. package/dist/components/Gallery.d.ts +12 -0
  14. package/dist/components/Gallery.d.ts.map +1 -0
  15. package/dist/components/LassoOverlay.d.ts +9 -0
  16. package/dist/components/LassoOverlay.d.ts.map +1 -0
  17. package/dist/components/Logo.d.ts +2 -0
  18. package/dist/components/Logo.d.ts.map +1 -0
  19. package/dist/components/ui/button.d.ts +12 -0
  20. package/dist/components/ui/button.d.ts.map +1 -0
  21. package/dist/components/ui/dropdown-menu.d.ts +10 -0
  22. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  23. package/dist/components/ui/slider.d.ts +6 -0
  24. package/dist/components/ui/slider.d.ts.map +1 -0
  25. package/dist/components/ui/tooltip.d.ts +8 -0
  26. package/dist/components/ui/tooltip.d.ts.map +1 -0
  27. package/dist/hooks/useAnimatePosition.d.ts +13 -0
  28. package/dist/hooks/useAnimatePosition.d.ts.map +1 -0
  29. package/dist/hooks/useGrandTour.d.ts +14 -0
  30. package/dist/hooks/useGrandTour.d.ts.map +1 -0
  31. package/dist/hooks/useLongPressIndicator.d.ts +5 -0
  32. package/dist/hooks/useLongPressIndicator.d.ts.map +1 -0
  33. package/dist/hooks/useModeCycling.d.ts +12 -0
  34. package/dist/hooks/useModeCycling.d.ts.map +1 -0
  35. package/dist/hooks/usePlayback.d.ts +9 -0
  36. package/dist/hooks/usePlayback.d.ts.map +1 -0
  37. package/dist/hooks/useScatter.d.ts +10 -0
  38. package/dist/hooks/useScatter.d.ts.map +1 -0
  39. package/dist/hooks/useSystemTheme.d.ts +6 -0
  40. package/dist/hooks/useSystemTheme.d.ts.map +1 -0
  41. package/dist/index.d.ts +16 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/layout/gallery-positions.d.ts +38 -0
  44. package/dist/layout/gallery-positions.d.ts.map +1 -0
  45. package/dist/layout/selector-size.d.ts +15 -0
  46. package/dist/layout/selector-size.d.ts.map +1 -0
  47. package/dist/lib/color-utils.d.ts +7 -0
  48. package/dist/lib/color-utils.d.ts.map +1 -0
  49. package/dist/lib/gram-schmidt.d.ts +9 -0
  50. package/dist/lib/gram-schmidt.d.ts.map +1 -0
  51. package/dist/lib/utils.d.ts +3 -0
  52. package/dist/lib/utils.d.ts.map +1 -0
  53. package/dist/portal-container.d.ts +10 -0
  54. package/dist/portal-container.d.ts.map +1 -0
  55. package/dist/radial-chart/RadialChart.d.ts +13 -0
  56. package/dist/radial-chart/RadialChart.d.ts.map +1 -0
  57. package/dist/radial-chart/arc-path.d.ts +23 -0
  58. package/dist/radial-chart/arc-path.d.ts.map +1 -0
  59. package/dist/radial-chart/index.d.ts +5 -0
  60. package/dist/radial-chart/index.d.ts.map +1 -0
  61. package/dist/radial-chart/parse-metrics.d.ts +10 -0
  62. package/dist/radial-chart/parse-metrics.d.ts.map +1 -0
  63. package/dist/radial-chart/types.d.ts +23 -0
  64. package/dist/radial-chart/types.d.ts.map +1 -0
  65. package/dist/spec.d.ts +42 -0
  66. package/dist/spec.d.ts.map +1 -0
  67. package/dist/state/atoms.d.ts +150 -0
  68. package/dist/state/atoms.d.ts.map +1 -0
  69. package/dist/state/spec-sync.d.ts +5 -0
  70. package/dist/state/spec-sync.d.ts.map +1 -0
  71. package/dist/viewer.css +3 -0
  72. package/dist/viewer.js +14501 -0
  73. package/dist/views.d.ts +30 -0
  74. package/dist/views.d.ts.map +1 -0
  75. package/package.json +48 -0
  76. package/src/Dtour.tsx +300 -0
  77. package/src/DtourViewer.tsx +541 -0
  78. package/src/components/AxisOverlay.tsx +224 -0
  79. package/src/components/CircularSlider.tsx +202 -0
  80. package/src/components/ColorLegend.tsx +178 -0
  81. package/src/components/DtourToolbar.tsx +642 -0
  82. package/src/components/Gallery.tsx +166 -0
  83. package/src/components/LassoOverlay.tsx +240 -0
  84. package/src/components/Logo.tsx +37 -0
  85. package/src/components/ui/button.tsx +36 -0
  86. package/src/components/ui/dropdown-menu.tsx +92 -0
  87. package/src/components/ui/slider.tsx +89 -0
  88. package/src/components/ui/tooltip.tsx +45 -0
  89. package/src/hooks/useAnimatePosition.ts +102 -0
  90. package/src/hooks/useGrandTour.ts +176 -0
  91. package/src/hooks/useLongPressIndicator.ts +342 -0
  92. package/src/hooks/useModeCycling.ts +64 -0
  93. package/src/hooks/usePlayback.ts +54 -0
  94. package/src/hooks/useScatter.ts +162 -0
  95. package/src/hooks/useSystemTheme.ts +19 -0
  96. package/src/index.ts +55 -0
  97. package/src/layout/gallery-positions.ts +105 -0
  98. package/src/layout/selector-size.ts +135 -0
  99. package/src/lib/color-utils.ts +22 -0
  100. package/src/lib/gram-schmidt.ts +41 -0
  101. package/src/lib/utils.ts +4 -0
  102. package/src/portal-container.tsx +14 -0
  103. package/src/radial-chart/RadialChart.tsx +184 -0
  104. package/src/radial-chart/arc-path.ts +80 -0
  105. package/src/radial-chart/index.ts +4 -0
  106. package/src/radial-chart/parse-metrics.ts +99 -0
  107. package/src/radial-chart/types.ts +23 -0
  108. package/src/spec.ts +48 -0
  109. package/src/state/atoms.ts +169 -0
  110. package/src/state/spec-sync.ts +190 -0
  111. package/src/styles.css +44 -0
  112. package/src/views.ts +76 -0
  113. package/tsconfig.json +12 -0
  114. 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
+ };