@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,184 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { arcPath, keyframeAngle, rectBarPath } from './arc-path.ts';
|
|
3
|
+
import type { ParsedTrack } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export type RadialChartProps = {
|
|
6
|
+
tracks: ParsedTrack[];
|
|
7
|
+
keyframeCount: number;
|
|
8
|
+
/** Current tour position [0, 1] for flanking highlight. */
|
|
9
|
+
position: number;
|
|
10
|
+
/** SVG viewport size (same as selectorSize). */
|
|
11
|
+
size: number;
|
|
12
|
+
/** Inner radius = selector ring radius (selectorSize * 0.4). */
|
|
13
|
+
innerRadius: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TRACK_GAP = 2;
|
|
17
|
+
const STACK_GAP = 1;
|
|
18
|
+
const BAR_PAD_RAD = 0.02; // angular padding between bars in radians
|
|
19
|
+
|
|
20
|
+
type HoverInfo = { label: string; value: number; x: number; y: number };
|
|
21
|
+
|
|
22
|
+
export const RadialChart = ({
|
|
23
|
+
tracks,
|
|
24
|
+
keyframeCount,
|
|
25
|
+
position,
|
|
26
|
+
size,
|
|
27
|
+
innerRadius,
|
|
28
|
+
}: RadialChartProps) => {
|
|
29
|
+
const center = size / 2;
|
|
30
|
+
const [hover, setHover] = useState<HoverInfo | null>(null);
|
|
31
|
+
|
|
32
|
+
const handleEnter = useCallback((label: string, value: number, x: number, y: number) => {
|
|
33
|
+
setHover({ label, value, x, y });
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const handleLeave = useCallback(() => {
|
|
37
|
+
setHover(null);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const stacked = tracks.length > 0 && tracks.every((t) => t.barWidth !== 'full');
|
|
41
|
+
|
|
42
|
+
// Track-based layout: each track gets its own radial band
|
|
43
|
+
const trackLayout = useMemo(() => {
|
|
44
|
+
if (stacked) return [];
|
|
45
|
+
let offset = innerRadius + TRACK_GAP;
|
|
46
|
+
return tracks.map((t) => {
|
|
47
|
+
const rInner = offset;
|
|
48
|
+
const rOuter = offset + t.height;
|
|
49
|
+
offset = rOuter + TRACK_GAP;
|
|
50
|
+
return { rInner, rOuter };
|
|
51
|
+
});
|
|
52
|
+
}, [tracks, innerRadius, stacked]);
|
|
53
|
+
|
|
54
|
+
// Flanking keyframes based on current position
|
|
55
|
+
const fractionalIndex = position * keyframeCount;
|
|
56
|
+
const leftKf = Math.floor(fractionalIndex) % keyframeCount;
|
|
57
|
+
const rightKf = (leftKf + 1) % keyframeCount;
|
|
58
|
+
|
|
59
|
+
const segmentAngle = (2 * Math.PI) / keyframeCount;
|
|
60
|
+
const baseR = innerRadius + STACK_GAP;
|
|
61
|
+
|
|
62
|
+
if (import.meta.env.DEV) {
|
|
63
|
+
for (const track of tracks) {
|
|
64
|
+
if (track.normalizedValues.length !== keyframeCount) {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[dtour] RadialChart track "${track.label}" has ${track.normalizedValues.length} values but keyframeCount is ${keyframeCount}. Bars will be clamped to the smaller count.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stacked mode: bars share a common baseline per keyframe, stacked outward
|
|
73
|
+
const stackedBars = useMemo(() => {
|
|
74
|
+
if (!stacked) return [];
|
|
75
|
+
const bars: {
|
|
76
|
+
key: string;
|
|
77
|
+
d: string;
|
|
78
|
+
color: string;
|
|
79
|
+
label: string;
|
|
80
|
+
rawValue: number;
|
|
81
|
+
tipX: number;
|
|
82
|
+
tipY: number;
|
|
83
|
+
}[] = [];
|
|
84
|
+
|
|
85
|
+
const barWidthPx = (tracks[0]?.barWidth as number) ?? 0;
|
|
86
|
+
|
|
87
|
+
for (let kfIdx = 0; kfIdx < keyframeCount; kfIdx++) {
|
|
88
|
+
const centerAngle = keyframeAngle(kfIdx, keyframeCount);
|
|
89
|
+
|
|
90
|
+
let stackBase = baseR;
|
|
91
|
+
for (let trackIdx = 0; trackIdx < tracks.length; trackIdx++) {
|
|
92
|
+
const track = tracks[trackIdx]!;
|
|
93
|
+
if (kfIdx >= track.normalizedValues.length) continue;
|
|
94
|
+
|
|
95
|
+
const normVal = track.normalizedValues[kfIdx]!;
|
|
96
|
+
const barHeight = normVal * track.height;
|
|
97
|
+
const barOuter = stackBase + barHeight;
|
|
98
|
+
const rawValue = track.rawValues[kfIdx] as number;
|
|
99
|
+
|
|
100
|
+
const midR = (stackBase + barOuter) / 2;
|
|
101
|
+
const tipX = center + midR * Math.cos(centerAngle);
|
|
102
|
+
const tipY = center + midR * Math.sin(centerAngle);
|
|
103
|
+
|
|
104
|
+
bars.push({
|
|
105
|
+
key: `${track.label}-${kfIdx}`,
|
|
106
|
+
d: rectBarPath(stackBase, barOuter, centerAngle, barWidthPx),
|
|
107
|
+
color: track.color,
|
|
108
|
+
label: track.label,
|
|
109
|
+
rawValue,
|
|
110
|
+
tipX,
|
|
111
|
+
tipY,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
stackBase = barOuter + STACK_GAP;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return bars;
|
|
118
|
+
}, [stacked, tracks, keyframeCount, baseR, center]);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="relative" style={{ width: size, height: size }}>
|
|
122
|
+
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative chart, no screen reader title needed */}
|
|
123
|
+
<svg width={size} height={size} className="overflow-visible pointer-events-none">
|
|
124
|
+
<g transform={`translate(${center}, ${center})`}>
|
|
125
|
+
{stacked
|
|
126
|
+
? stackedBars.map((bar) => (
|
|
127
|
+
<path
|
|
128
|
+
key={bar.key}
|
|
129
|
+
d={bar.d}
|
|
130
|
+
fill={bar.color}
|
|
131
|
+
className="pointer-events-auto cursor-default opacity-60 hover:opacity-100 transition-[fill-opacity] duration-150 ease-out"
|
|
132
|
+
onMouseEnter={() => handleEnter(bar.label, bar.rawValue, bar.tipX, bar.tipY)}
|
|
133
|
+
onMouseLeave={handleLeave}
|
|
134
|
+
/>
|
|
135
|
+
))
|
|
136
|
+
: tracks.map((track, trackIdx) => {
|
|
137
|
+
const { rInner } = trackLayout[trackIdx]!;
|
|
138
|
+
return (
|
|
139
|
+
<g key={track.label}>
|
|
140
|
+
{track.normalizedValues.map((normVal, kfIdx) => {
|
|
141
|
+
if (kfIdx >= keyframeCount) return null;
|
|
142
|
+
|
|
143
|
+
const centerAngle = keyframeAngle(kfIdx, keyframeCount);
|
|
144
|
+
const angleStart = centerAngle - segmentAngle / 2 + BAR_PAD_RAD;
|
|
145
|
+
const angleEnd = centerAngle + segmentAngle / 2 - BAR_PAD_RAD;
|
|
146
|
+
|
|
147
|
+
const barOuter = rInner + normVal * track.height;
|
|
148
|
+
const rawValue = track.rawValues[kfIdx] as number;
|
|
149
|
+
|
|
150
|
+
const midR = (rInner + barOuter) / 2;
|
|
151
|
+
const tipX = center + midR * Math.cos(centerAngle);
|
|
152
|
+
const tipY = center + midR * Math.sin(centerAngle);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<path
|
|
156
|
+
key={`${track.label}-${kfIdx}`}
|
|
157
|
+
d={arcPath(rInner, barOuter, angleStart, angleEnd)}
|
|
158
|
+
fill={track.color}
|
|
159
|
+
className="pointer-events-auto cursor-default opacity-60 hover:opacity-100 transition-[fill-opacity] duration-150 ease-out"
|
|
160
|
+
onMouseEnter={() => handleEnter(track.label, rawValue, tipX, tipY)}
|
|
161
|
+
onMouseLeave={handleLeave}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</g>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</g>
|
|
169
|
+
</svg>
|
|
170
|
+
|
|
171
|
+
{/* Tooltip at bar center */}
|
|
172
|
+
{hover && (
|
|
173
|
+
<div
|
|
174
|
+
className="absolute pointer-events-none z-50"
|
|
175
|
+
style={{ left: hover.x, top: hover.y, transform: 'translate(-50%, -50%)' }}
|
|
176
|
+
>
|
|
177
|
+
<div className="rounded bg-dtour-highlight px-3 py-1.5 text-xs text-dtour-bg shadow-[0_1px_4px_rgba(0,0,0,0.6)] whitespace-nowrap">
|
|
178
|
+
{hover.label}: {hover.value.toFixed(3)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Angular start offset: 10:30 o'clock position (-135 degrees from +x axis). */
|
|
2
|
+
const START_DEG = -135;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Center angle in radians for keyframe `index` out of `count` total keyframes.
|
|
6
|
+
* Uses the same angular convention as CircularSlider (10:30 start, clockwise).
|
|
7
|
+
*/
|
|
8
|
+
export const keyframeAngle = (index: number, count: number): number =>
|
|
9
|
+
(((index / count) * 360 + START_DEG) * Math.PI) / 180;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SVG `d` attribute for an annular sector (arc segment between two radii).
|
|
13
|
+
*
|
|
14
|
+
* Draws: outer arc forward -> line to inner -> inner arc backward -> close.
|
|
15
|
+
*
|
|
16
|
+
* All angles in radians. Center is at (0, 0) — use a `<g transform>` to position.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* SVG `d` for a constant-width rectangular bar along a radial direction.
|
|
20
|
+
*
|
|
21
|
+
* Unlike arcPath (which widens with radius), this produces truly parallel
|
|
22
|
+
* sides so stacked segments align perfectly.
|
|
23
|
+
*
|
|
24
|
+
* Center is at (0, 0). `angle` is the bar's center direction in radians.
|
|
25
|
+
*/
|
|
26
|
+
export const rectBarPath = (
|
|
27
|
+
rInner: number,
|
|
28
|
+
rOuter: number,
|
|
29
|
+
angle: number,
|
|
30
|
+
width: number,
|
|
31
|
+
): string => {
|
|
32
|
+
const hw = width / 2;
|
|
33
|
+
const cos = Math.cos(angle);
|
|
34
|
+
const sin = Math.sin(angle);
|
|
35
|
+
// perpendicular to radial direction: (-sin, cos)
|
|
36
|
+
const px = -sin;
|
|
37
|
+
const py = cos;
|
|
38
|
+
|
|
39
|
+
// four corners: inner-left, outer-left, outer-right, inner-right
|
|
40
|
+
const ilx = rInner * cos + hw * px;
|
|
41
|
+
const ily = rInner * sin + hw * py;
|
|
42
|
+
const olx = rOuter * cos + hw * px;
|
|
43
|
+
const oly = rOuter * sin + hw * py;
|
|
44
|
+
const orx = rOuter * cos - hw * px;
|
|
45
|
+
const ory = rOuter * sin - hw * py;
|
|
46
|
+
const irx = rInner * cos - hw * px;
|
|
47
|
+
const iry = rInner * sin - hw * py;
|
|
48
|
+
|
|
49
|
+
return `M ${ilx} ${ily} L ${olx} ${oly} L ${orx} ${ory} L ${irx} ${iry} Z`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const arcPath = (
|
|
53
|
+
rInner: number,
|
|
54
|
+
rOuter: number,
|
|
55
|
+
angleStart: number,
|
|
56
|
+
angleEnd: number,
|
|
57
|
+
): string => {
|
|
58
|
+
const outerX1 = rOuter * Math.cos(angleStart);
|
|
59
|
+
const outerY1 = rOuter * Math.sin(angleStart);
|
|
60
|
+
const outerX2 = rOuter * Math.cos(angleEnd);
|
|
61
|
+
const outerY2 = rOuter * Math.sin(angleEnd);
|
|
62
|
+
|
|
63
|
+
const innerX1 = rInner * Math.cos(angleEnd);
|
|
64
|
+
const innerY1 = rInner * Math.sin(angleEnd);
|
|
65
|
+
const innerX2 = rInner * Math.cos(angleStart);
|
|
66
|
+
const innerY2 = rInner * Math.sin(angleStart);
|
|
67
|
+
|
|
68
|
+
// Determine if the arc spans more than 180 degrees
|
|
69
|
+
let sweep = angleEnd - angleStart;
|
|
70
|
+
if (sweep < 0) sweep += 2 * Math.PI;
|
|
71
|
+
const largeArc = sweep > Math.PI ? 1 : 0;
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
`M ${outerX1} ${outerY1}`,
|
|
75
|
+
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${outerX2} ${outerY2}`,
|
|
76
|
+
`L ${innerX1} ${innerY1}`,
|
|
77
|
+
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${innerX2} ${innerY2}`,
|
|
78
|
+
'Z',
|
|
79
|
+
].join(' ');
|
|
80
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { tableFromIPC } from '@uwdata/flechette';
|
|
2
|
+
import type { ParsedTrack, RadialTrackConfig } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/** Default color palette for auto-assigned track colors. */
|
|
5
|
+
const PALETTE = [
|
|
6
|
+
'#4080e8', // accent blue
|
|
7
|
+
'#e8a040', // warm orange
|
|
8
|
+
'#50c878', // green
|
|
9
|
+
'#e05070', // rose
|
|
10
|
+
'#9070e0', // purple
|
|
11
|
+
'#40b8d0', // teal
|
|
12
|
+
'#d0a040', // gold
|
|
13
|
+
'#70a0c0', // steel blue
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const DEFAULT_HEIGHT = 16;
|
|
17
|
+
const DEFAULT_BAR_WIDTH: 'full' | number = 'full';
|
|
18
|
+
|
|
19
|
+
const NUMERIC_TYPED_ARRAYS = new Set([
|
|
20
|
+
'Float32Array',
|
|
21
|
+
'Float64Array',
|
|
22
|
+
'Int8Array',
|
|
23
|
+
'Int16Array',
|
|
24
|
+
'Int32Array',
|
|
25
|
+
'Uint8Array',
|
|
26
|
+
'Uint16Array',
|
|
27
|
+
'Uint32Array',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse an Arrow IPC buffer into per-track normalized data ready for rendering.
|
|
32
|
+
*
|
|
33
|
+
* Each column in the Arrow table represents a metric; rows are per-view values.
|
|
34
|
+
* When `configs` is provided, only the listed metrics are shown in that order.
|
|
35
|
+
* When omitted, all numeric columns are shown with auto-assigned colors.
|
|
36
|
+
*/
|
|
37
|
+
export const parseMetrics = (
|
|
38
|
+
buffer: ArrayBuffer,
|
|
39
|
+
configs?: RadialTrackConfig[],
|
|
40
|
+
defaultBarWidth?: 'full' | number,
|
|
41
|
+
): ParsedTrack[] => {
|
|
42
|
+
const table = tableFromIPC(new Uint8Array(buffer));
|
|
43
|
+
const columns = table.toColumns() as Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
// Collect numeric columns
|
|
46
|
+
const numericColumns = new Map<string, Float32Array | Float64Array>();
|
|
47
|
+
for (const [name, arr] of Object.entries(columns)) {
|
|
48
|
+
if (!ArrayBuffer.isView(arr)) continue;
|
|
49
|
+
const tag = Object.prototype.toString.call(arr).slice(8, -1);
|
|
50
|
+
if (!NUMERIC_TYPED_ARRAYS.has(tag)) continue;
|
|
51
|
+
numericColumns.set(
|
|
52
|
+
name,
|
|
53
|
+
arr instanceof Float32Array || arr instanceof Float64Array
|
|
54
|
+
? arr
|
|
55
|
+
: new Float32Array(arr as unknown as ArrayLike<number>),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Determine which tracks to show
|
|
60
|
+
const trackDefs: { metric: string; config?: RadialTrackConfig }[] = configs
|
|
61
|
+
? configs
|
|
62
|
+
.filter((c) => numericColumns.has(c.metric))
|
|
63
|
+
.map((c) => ({ metric: c.metric, config: c }))
|
|
64
|
+
: [...numericColumns.keys()].map((m) => ({ metric: m }));
|
|
65
|
+
|
|
66
|
+
return trackDefs.map(({ metric, config }, i): ParsedTrack => {
|
|
67
|
+
const raw = numericColumns.get(metric)!;
|
|
68
|
+
|
|
69
|
+
// Normalize
|
|
70
|
+
let min: number;
|
|
71
|
+
let max: number;
|
|
72
|
+
if (config?.domain) {
|
|
73
|
+
[min, max] = config.domain;
|
|
74
|
+
} else {
|
|
75
|
+
min = Number.POSITIVE_INFINITY;
|
|
76
|
+
max = Number.NEGATIVE_INFINITY;
|
|
77
|
+
for (let j = 0; j < raw.length; j++) {
|
|
78
|
+
const v = raw[j] as number;
|
|
79
|
+
if (v < min) min = v;
|
|
80
|
+
if (v > max) max = v;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// When all values are non-negative, normalize to [0, max] so bars have
|
|
84
|
+
// proportional heights (the smallest bar is still visible).
|
|
85
|
+
// When values span negative to positive (e.g. silhouette), use [min, max].
|
|
86
|
+
const base = min >= 0 ? 0 : min;
|
|
87
|
+
const range = max - base || 1;
|
|
88
|
+
const normalizedValues = Array.from(raw, (v) => Math.max(0, Math.min(1, (v - base) / range)));
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
label: config?.label ?? metric,
|
|
92
|
+
rawValues: raw,
|
|
93
|
+
normalizedValues,
|
|
94
|
+
height: config?.height ?? DEFAULT_HEIGHT,
|
|
95
|
+
color: config?.color ?? (PALETTE[i % PALETTE.length] as string),
|
|
96
|
+
barWidth: config?.barWidth ?? defaultBarWidth ?? DEFAULT_BAR_WIDTH,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type RadialTrackConfig = {
|
|
2
|
+
/** Column name in the Arrow IPC table. */
|
|
3
|
+
metric: string;
|
|
4
|
+
/** Track height in px. Default 16. */
|
|
5
|
+
height?: number;
|
|
6
|
+
/** Bar fill color. Default auto-assigned from palette. */
|
|
7
|
+
color?: string;
|
|
8
|
+
/** 'full' = span the segment, number = fixed px width. Default 'full'. */
|
|
9
|
+
barWidth?: 'full' | number;
|
|
10
|
+
/** Explicit [min, max] domain. Default: per-track normalize. */
|
|
11
|
+
domain?: [number, number];
|
|
12
|
+
/** Tooltip label override. Default: column name. */
|
|
13
|
+
label?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ParsedTrack = {
|
|
17
|
+
label: string;
|
|
18
|
+
rawValues: Float64Array | Float32Array;
|
|
19
|
+
normalizedValues: number[];
|
|
20
|
+
height: number;
|
|
21
|
+
color: string;
|
|
22
|
+
barWidth: 'full' | number;
|
|
23
|
+
};
|
package/src/spec.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON-serializable spec for the Dtour component.
|
|
5
|
+
* All fields optional — omitted fields use defaults.
|
|
6
|
+
* The Zod schema is the single source of truth; the TS type is inferred.
|
|
7
|
+
*/
|
|
8
|
+
export const dtourSpecSchema = z.object({
|
|
9
|
+
tourBy: z.enum(['dimensions', 'pca']).optional(),
|
|
10
|
+
tourPosition: z.number().min(0).max(1).optional(),
|
|
11
|
+
tourPlaying: z.boolean().optional(),
|
|
12
|
+
tourSpeed: z.number().min(0.1).max(5).optional(),
|
|
13
|
+
tourDirection: z.enum(['forward', 'backward']).optional(),
|
|
14
|
+
previewCount: z.union([z.literal(4), z.literal(8), z.literal(12), z.literal(16)]).optional(),
|
|
15
|
+
previewScale: z.union([z.literal(1), z.literal(0.75), z.literal(0.5)]).optional(),
|
|
16
|
+
previewPadding: z.number().nonnegative().optional(),
|
|
17
|
+
pointSize: z.union([z.number().positive(), z.literal('auto')]).optional(),
|
|
18
|
+
pointOpacity: z.union([z.number().min(0).max(1), z.literal('auto')]).optional(),
|
|
19
|
+
pointColor: z.union([z.tuple([z.number(), z.number(), z.number()]), z.string()]).optional(),
|
|
20
|
+
cameraPanX: z.number().optional(),
|
|
21
|
+
cameraPanY: z.number().optional(),
|
|
22
|
+
cameraZoom: z.number().positive().optional(),
|
|
23
|
+
viewMode: z.enum(['guided', 'manual', 'grand']).optional(),
|
|
24
|
+
showLegend: z.boolean().optional(),
|
|
25
|
+
themeMode: z.enum(['light', 'dark', 'system']).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type DtourSpec = z.infer<typeof dtourSpecSchema>;
|
|
29
|
+
|
|
30
|
+
export const DTOUR_DEFAULTS: Required<DtourSpec> = {
|
|
31
|
+
tourBy: 'dimensions',
|
|
32
|
+
tourPosition: 0,
|
|
33
|
+
tourPlaying: false,
|
|
34
|
+
tourSpeed: 1,
|
|
35
|
+
tourDirection: 'forward',
|
|
36
|
+
previewCount: 4,
|
|
37
|
+
previewScale: 1,
|
|
38
|
+
previewPadding: 12,
|
|
39
|
+
pointSize: 'auto',
|
|
40
|
+
pointOpacity: 'auto',
|
|
41
|
+
pointColor: [0.25, 0.5, 0.9],
|
|
42
|
+
cameraPanX: 0,
|
|
43
|
+
cameraPanY: 0,
|
|
44
|
+
cameraZoom: 1 / 1.5,
|
|
45
|
+
viewMode: 'guided',
|
|
46
|
+
showLegend: true,
|
|
47
|
+
themeMode: 'dark',
|
|
48
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Metadata } from '@dtour/scatter';
|
|
2
|
+
import { atom } from 'jotai';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Tour state — controls position and playback along the tour path
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Controls how tour keyframes are derived: raw dimension pairs or PCA eigenvectors. */
|
|
9
|
+
export const tourByAtom = atom<'dimensions' | 'pca'>('dimensions');
|
|
10
|
+
|
|
11
|
+
export const tourPositionAtom = atom(0);
|
|
12
|
+
export const tourPlayingAtom = atom(false);
|
|
13
|
+
export const tourSpeedAtom = atom(1);
|
|
14
|
+
export const tourDirectionAtom = atom<1 | -1>(1);
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// View state — controls preview layout and keyframe selection
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const previewCountAtom = atom<4 | 8 | 12 | 16>(4);
|
|
21
|
+
export const previewScaleAtom = atom<1 | 0.75 | 0.5>(1);
|
|
22
|
+
export const previewPaddingAtom = atom(12);
|
|
23
|
+
export const selectedKeyframeAtom = atom<number | null>(null);
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Point style — visual appearance of scatter points
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export const pointSizeAtom = atom<number | 'auto'>('auto');
|
|
30
|
+
export const pointOpacityAtom = atom<number | 'auto'>('auto');
|
|
31
|
+
export const pointColorAtom = atom<[number, number, number] | string>([0.25, 0.5, 0.9]);
|
|
32
|
+
export const paletteAtom = atom<'viridis' | 'magma'>('viridis');
|
|
33
|
+
|
|
34
|
+
/** Per-label color overrides. Values are hex strings or theme-aware {light, dark} objects. */
|
|
35
|
+
export const colorMapAtom = atom<Record<string, string | { light: string; dark: string }> | null>(
|
|
36
|
+
null,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Background color — WebGPU clear color (RGB 0–1)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export const backgroundColorAtom = atom<[number, number, number]>([0, 0, 0]);
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Camera state — 2D pan and zoom
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const cameraPanXAtom = atom(0);
|
|
50
|
+
export const cameraPanYAtom = atom(0);
|
|
51
|
+
export const cameraZoomAtom = atom(1 / 1.5);
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// View mode — controls which UI is shown (guided, manual, grand)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export const viewModeAtom = atom<'guided' | 'manual' | 'grand'>('guided');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* When true, `useScatter` skips `setTourPosition` messages.
|
|
61
|
+
* Set on returning to guided mode from manual/grand so the current
|
|
62
|
+
* projection is preserved until the user clicks the circular slider
|
|
63
|
+
* or presses play.
|
|
64
|
+
*/
|
|
65
|
+
export const guidedSuspendedAtom = atom(false);
|
|
66
|
+
|
|
67
|
+
/** Target mode after grand ease-out completes. null = not exiting. */
|
|
68
|
+
export const grandExitTargetAtom = atom<'guided' | 'manual' | null>(null);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tracks the currently-displayed projection basis (p×2 column-major).
|
|
72
|
+
* Updated by tour interpolation, manual axis dragging, and zen animation.
|
|
73
|
+
* Read imperatively (via store.get) on mode switch so the new mode
|
|
74
|
+
* can initialize from the current view without jumping.
|
|
75
|
+
*/
|
|
76
|
+
export const currentBasisAtom = atom<Float32Array | null>(null);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Animation coordination — generation counter for cancellation
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Incremented each time a position animation starts or is cancelled.
|
|
84
|
+
* Running animations bail out when their captured generation doesn't
|
|
85
|
+
* match the current value, ensuring only one animation drives the
|
|
86
|
+
* position at a time — even across different components.
|
|
87
|
+
*/
|
|
88
|
+
export const animationGenAtom = atom(0);
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Canvas size — tracked for auto opacity/size computation
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export const canvasSizeAtom = atom({ width: 0, height: 0 });
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Read-only / derived — not exposed to AI setters
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export const metadataAtom = atom<Metadata | null>(null);
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Column visibility — which numeric dimensions participate in the tour
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set of active dimension indices. `null` means all columns are active
|
|
108
|
+
* (initial state before metadata loads or when all are enabled).
|
|
109
|
+
*/
|
|
110
|
+
export const activeColumnsAtom = atom<Set<number> | null>(null);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolved active dimension indices — never null after metadata loads.
|
|
114
|
+
* Returns sorted array for deterministic iteration in basis generation,
|
|
115
|
+
* grand tour, and manual mode.
|
|
116
|
+
*/
|
|
117
|
+
export const activeIndicesAtom = atom<number[]>((get) => {
|
|
118
|
+
const active = get(activeColumnsAtom);
|
|
119
|
+
const meta = get(metadataAtom);
|
|
120
|
+
if (!meta) return [];
|
|
121
|
+
if (active === null) return Array.from({ length: meta.dimCount }, (_, i) => i);
|
|
122
|
+
return Array.from(active).sort((a, b) => a - b);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Legend — collapsible color legend panel
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/** User preference for showing the legend panel. */
|
|
130
|
+
export const showLegendAtom = atom(true);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Derived: legend is visible only when showLegend is true, metadata is loaded,
|
|
134
|
+
* AND points are colored by a known data column (numeric or categorical).
|
|
135
|
+
*/
|
|
136
|
+
export const legendVisibleAtom = atom((get) => {
|
|
137
|
+
if (!get(showLegendAtom)) return false;
|
|
138
|
+
const meta = get(metadataAtom);
|
|
139
|
+
if (!meta) return false;
|
|
140
|
+
const color = get(pointColorAtom);
|
|
141
|
+
if (typeof color !== 'string') return false;
|
|
142
|
+
return meta.columnNames.includes(color) || meta.categoricalColumnNames.includes(color);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Legend selection — which legend entries are actively selected
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/** Which legend entries are selected, or null when no legend selection is active. */
|
|
150
|
+
export const legendSelectionAtom = atom<Set<number> | null>(null);
|
|
151
|
+
|
|
152
|
+
/** Bumped when ColorLegend explicitly deselects — triggers scatter.clearSelection(). */
|
|
153
|
+
export const legendClearGenAtom = atom(0);
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Theme — light/dark mode with system preference support
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/** User preference: explicit light/dark or follow system. */
|
|
160
|
+
export const themeModeAtom = atom<'light' | 'dark' | 'system'>('dark');
|
|
161
|
+
|
|
162
|
+
/** Tracks the OS-level color scheme. Updated by useSystemTheme hook. */
|
|
163
|
+
export const systemThemeAtom = atom<'light' | 'dark'>('dark');
|
|
164
|
+
|
|
165
|
+
/** Resolved theme after applying system preference. */
|
|
166
|
+
export const resolvedThemeAtom = atom<'light' | 'dark'>((get) => {
|
|
167
|
+
const mode = get(themeModeAtom);
|
|
168
|
+
return mode === 'system' ? get(systemThemeAtom) : mode;
|
|
169
|
+
});
|