@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,64 @@
1
+ import { useAtomValue, useSetAtom } from 'jotai';
2
+ import { useEffect, useRef } from 'react';
3
+ import {
4
+ grandExitTargetAtom,
5
+ guidedSuspendedAtom,
6
+ tourPlayingAtom,
7
+ viewModeAtom,
8
+ } from '../state/atoms.ts';
9
+
10
+ const MODES = ['guided', 'manual', 'grand'] as const;
11
+
12
+ /**
13
+ * Cycles view modes on Shift+Tab (guided → manual → grand → guided).
14
+ *
15
+ * Also manages guided suspension: pauses playback when leaving guided mode,
16
+ * and sets `guidedSuspended` when returning to guided so the current
17
+ * projection is preserved until the user interacts with the slider.
18
+ *
19
+ * When exiting grand mode, defers the actual mode switch so the grand
20
+ * tour animation can ease out first.
21
+ */
22
+ export const useModeCycling = () => {
23
+ const viewMode = useAtomValue(viewModeAtom);
24
+ const setViewMode = useSetAtom(viewModeAtom);
25
+ const setPlaying = useSetAtom(tourPlayingAtom);
26
+ const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
27
+ const setGrandExitTarget = useSetAtom(grandExitTargetAtom);
28
+
29
+ // Use ref so the keydown handler always sees the latest viewMode
30
+ // without needing to re-register the listener on every mode change.
31
+ const viewModeRef = useRef(viewMode);
32
+ viewModeRef.current = viewMode;
33
+
34
+ useEffect(() => {
35
+ const handleKeyDown = (e: KeyboardEvent) => {
36
+ if (e.key !== 'Tab' || !e.shiftKey || e.repeat || e.ctrlKey || e.metaKey || e.altKey) return;
37
+ e.preventDefault();
38
+
39
+ const current = viewModeRef.current;
40
+ const idx = MODES.indexOf(current);
41
+ const next = MODES[(idx + 1) % MODES.length]!;
42
+
43
+ if (current === 'grand') {
44
+ // Exiting grand — request ease-out, defer mode switch
45
+ // next is always 'guided' here (grand → guided in the cycle)
46
+ setGrandExitTarget(next as 'guided' | 'manual');
47
+ } else {
48
+ if (current === 'guided') {
49
+ setPlaying(false);
50
+ }
51
+ if (next === 'guided') {
52
+ setGuidedSuspended(true);
53
+ }
54
+ if (next === 'grand') {
55
+ setGrandExitTarget(null);
56
+ }
57
+ setViewMode(next);
58
+ }
59
+ };
60
+
61
+ window.addEventListener('keydown', handleKeyDown);
62
+ return () => window.removeEventListener('keydown', handleKeyDown);
63
+ }, [setViewMode, setPlaying, setGuidedSuspended, setGrandExitTarget]);
64
+ };
@@ -0,0 +1,54 @@
1
+ import { useAtomValue, useSetAtom } from 'jotai';
2
+ import { useEffect, useRef } from 'react';
3
+ import {
4
+ tourDirectionAtom,
5
+ tourPlayingAtom,
6
+ tourPositionAtom,
7
+ tourSpeedAtom,
8
+ } from '../state/atoms.ts';
9
+
10
+ /**
11
+ * rAF loop that advances tour position when playing.
12
+ *
13
+ * Reads playing/speed/direction from atoms, writes position.
14
+ * Automatically pauses when the tab is hidden (rAF stops firing).
15
+ * Wraps at 0/1 for cyclic tour.
16
+ */
17
+ export const usePlayback = () => {
18
+ const playing = useAtomValue(tourPlayingAtom);
19
+ const speed = useAtomValue(tourSpeedAtom);
20
+ const direction = useAtomValue(tourDirectionAtom);
21
+ const setPosition = useSetAtom(tourPositionAtom);
22
+
23
+ // Use refs for values read inside rAF to avoid re-creating the effect
24
+ const speedRef = useRef(speed);
25
+ speedRef.current = speed;
26
+ const directionRef = useRef(direction);
27
+ directionRef.current = direction;
28
+
29
+ useEffect(() => {
30
+ if (!playing) return;
31
+
32
+ let prevTime: number | null = null;
33
+ let rafId: number;
34
+
35
+ const tick = (time: number) => {
36
+ if (prevTime !== null) {
37
+ const dt = (time - prevTime) / 1000; // seconds
38
+ // Full tour cycle = 20s at speed=1
39
+ const delta = (dt * speedRef.current * directionRef.current) / 20;
40
+ setPosition((prev) => {
41
+ let next = prev + delta;
42
+ // Wrap cyclically
43
+ next = next - Math.floor(next);
44
+ return next;
45
+ });
46
+ }
47
+ prevTime = time;
48
+ rafId = requestAnimationFrame(tick);
49
+ };
50
+
51
+ rafId = requestAnimationFrame(tick);
52
+ return () => cancelAnimationFrame(rafId);
53
+ }, [playing, setPosition]);
54
+ };
@@ -0,0 +1,162 @@
1
+ import type { ScatterInstance, ScatterStatus } from '@dtour/scatter';
2
+ import { useAtomValue, useSetAtom } from 'jotai';
3
+ import { useEffect, useRef } from 'react';
4
+ import { hexToRgb, hexToRgb255, isHexColor } from '../lib/color-utils.ts';
5
+ import {
6
+ backgroundColorAtom,
7
+ cameraPanXAtom,
8
+ cameraPanYAtom,
9
+ cameraZoomAtom,
10
+ colorMapAtom,
11
+ guidedSuspendedAtom,
12
+ legendClearGenAtom,
13
+ legendSelectionAtom,
14
+ metadataAtom,
15
+ paletteAtom,
16
+ pointColorAtom,
17
+ pointOpacityAtom,
18
+ pointSizeAtom,
19
+ resolvedThemeAtom,
20
+ tourPositionAtom,
21
+ } from '../state/atoms.ts';
22
+
23
+ /**
24
+ * Bridge between Jotai atoms and a ScatterInstance.
25
+ *
26
+ * Subscribes to atom changes and forwards them as postMessage calls
27
+ * to the GPU worker. Also subscribes to scatter status events and
28
+ * writes metadata back into Jotai.
29
+ */
30
+ export const useScatter = (scatter: ScatterInstance | null) => {
31
+ const position = useAtomValue(tourPositionAtom);
32
+ const pointSize = useAtomValue(pointSizeAtom);
33
+ const opacity = useAtomValue(pointOpacityAtom);
34
+ const color = useAtomValue(pointColorAtom);
35
+ const guidedSuspended = useAtomValue(guidedSuspendedAtom);
36
+ const panX = useAtomValue(cameraPanXAtom);
37
+ const panY = useAtomValue(cameraPanYAtom);
38
+ const zoom = useAtomValue(cameraZoomAtom);
39
+ const backgroundColor = useAtomValue(backgroundColorAtom);
40
+ const palette = useAtomValue(paletteAtom);
41
+ const resolvedTheme = useAtomValue(resolvedThemeAtom);
42
+ const rawColorMap = useAtomValue(colorMapAtom);
43
+ const metadata = useAtomValue(metadataAtom);
44
+ const setMetadata = useSetAtom(metadataAtom);
45
+ const legendSelection = useAtomValue(legendSelectionAtom);
46
+ const legendClearGen = useAtomValue(legendClearGenAtom);
47
+ const setLegendSelection = useSetAtom(legendSelectionAtom);
48
+
49
+ // Forward background color
50
+ useEffect(() => {
51
+ scatter?.setBackgroundColor(backgroundColor);
52
+ }, [scatter, backgroundColor]);
53
+
54
+ // Forward camera — registered first so the worker receives setCamera before
55
+ // setTourPosition (which triggers a render). Otherwise the first render uses
56
+ // the client's default zoom=1 instead of the atom value.
57
+ useEffect(() => {
58
+ scatter?.setCamera({ pan: [panX, panY], zoom });
59
+ }, [scatter, panX, panY, zoom]);
60
+
61
+ // Forward tour position (skipped when suspended after returning from manual/grand)
62
+ useEffect(() => {
63
+ if (guidedSuspended) return;
64
+ scatter?.setTourPosition(position);
65
+ }, [scatter, position, guidedSuspended]);
66
+
67
+ // Forward point style (size + opacity + uniform color)
68
+ useEffect(() => {
69
+ if (!scatter) return;
70
+
71
+ if (Array.isArray(color)) {
72
+ // RGB tuple — uniform color; clear any per-point encoding
73
+ scatter.clearColor();
74
+ scatter.setStyle({ pointSize, opacity, color });
75
+ } else if (isHexColor(color)) {
76
+ // Hex string — parse to RGB uniform color
77
+ scatter.clearColor();
78
+ scatter.setStyle({ pointSize, opacity, color: hexToRgb(color) });
79
+ } else {
80
+ // Column name — encode per-point colors via data worker
81
+ scatter.setStyle({ pointSize, opacity });
82
+ // Resolve theme-aware colorMap to Record<string, [r,g,b]> for the scatter worker
83
+ let resolvedColorMap: Record<string, [number, number, number]> | undefined;
84
+ if (rawColorMap) {
85
+ resolvedColorMap = {};
86
+ for (const [label, value] of Object.entries(rawColorMap)) {
87
+ const hex = typeof value === 'string' ? value : value[resolvedTheme];
88
+ resolvedColorMap[label] = hexToRgb255(hex);
89
+ }
90
+ }
91
+ scatter.encodeColor(color, palette, resolvedTheme, resolvedColorMap);
92
+ }
93
+ }, [scatter, pointSize, opacity, color, palette, resolvedTheme, rawColorMap]);
94
+
95
+ // Forward legend selection → scatter.selectByColumn
96
+ useEffect(() => {
97
+ if (!scatter || !metadata || legendSelection === null || legendSelection.size === 0) return;
98
+
99
+ // Determine the active color column
100
+ if (typeof color !== 'string' || isHexColor(color)) return;
101
+ const column = color;
102
+
103
+ const isCategorical = metadata.categoricalColumnNames.includes(column);
104
+
105
+ if (isCategorical) {
106
+ scatter.selectByColumn(column, { labelIndices: Array.from(legendSelection) });
107
+ } else {
108
+ // Continuous: 13 stops (indices 0–12). Middle stops each cover range/12,
109
+ // end stops (0 and 12) cover half that (range/24).
110
+ const colIndex = metadata.columnNames.indexOf(column);
111
+ if (colIndex === -1) return;
112
+ const min = metadata.mins[colIndex]!;
113
+ const max = metadata.maxes[colIndex]!;
114
+ const range = max - min;
115
+ const midWidth = range / 12;
116
+ const endWidth = midWidth / 2;
117
+
118
+ const ranges: number[] = [];
119
+ for (const stopIdx of legendSelection) {
120
+ const lo = stopIdx === 0 ? min : min + endWidth + (stopIdx - 1) * midWidth;
121
+ const hi = stopIdx === 12 ? max : min + endWidth + stopIdx * midWidth;
122
+ ranges.push(lo, hi);
123
+ }
124
+
125
+ scatter.selectByColumn(column, { valueRanges: new Float32Array(ranges) });
126
+ }
127
+ }, [scatter, legendSelection, color, metadata]);
128
+
129
+ // Clear scatter selection when legend explicitly deselects (gen bumped by ColorLegend)
130
+ useEffect(() => {
131
+ if (!scatter || legendClearGen === 0) return;
132
+ scatter.clearSelection();
133
+ }, [scatter, legendClearGen]);
134
+
135
+ // Reset legend selection and clear GPU selection mask when color column changes
136
+ const prevColorRef = useRef(color);
137
+ useEffect(() => {
138
+ if (prevColorRef.current !== color) {
139
+ prevColorRef.current = color;
140
+ setLegendSelection(null);
141
+ scatter?.clearSelection();
142
+ }
143
+ }, [scatter, color, setLegendSelection]);
144
+
145
+ // Subscribe to scatter status events and update metadata atom.
146
+ // Use a ref so the setMetadata closure never goes stale.
147
+ const setMetadataRef = useRef(setMetadata);
148
+ setMetadataRef.current = setMetadata;
149
+
150
+ const setLegendSelectionRef = useRef(setLegendSelection);
151
+ setLegendSelectionRef.current = setLegendSelection;
152
+
153
+ useEffect(() => {
154
+ if (!scatter) return;
155
+ return scatter.subscribe((s: ScatterStatus) => {
156
+ if (s.type === 'metadata') {
157
+ setMetadataRef.current(s.metadata);
158
+ setLegendSelectionRef.current(null);
159
+ }
160
+ });
161
+ }, [scatter]);
162
+ };
@@ -0,0 +1,19 @@
1
+ import { useSetAtom } from 'jotai';
2
+ import { useEffect } from 'react';
3
+ import { systemThemeAtom } from '../state/atoms.ts';
4
+
5
+ /**
6
+ * Subscribes to the OS-level color scheme preference and updates
7
+ * `systemThemeAtom`. Called once in DtourInner.
8
+ */
9
+ export const useSystemTheme = () => {
10
+ const setSystemTheme = useSetAtom(systemThemeAtom);
11
+
12
+ useEffect(() => {
13
+ const mql = window.matchMedia('(prefers-color-scheme: dark)');
14
+ const update = () => setSystemTheme(mql.matches ? 'dark' : 'light');
15
+ update();
16
+ mql.addEventListener('change', update);
17
+ return () => mql.removeEventListener('change', update);
18
+ }, [setSystemTheme]);
19
+ };
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ // @dtour/viewer — React UI for dtour: circular selector, preview gallery, tour controls.
2
+ import './styles.css';
3
+
4
+ // Primary API — self-contained component with spec-driven state
5
+ export { Dtour } from './Dtour.tsx';
6
+ export type { DtourProps, DtourHandle } from './Dtour.tsx';
7
+ export type { DtourSpec } from './spec.ts';
8
+ export { dtourSpecSchema, DTOUR_DEFAULTS } from './spec.ts';
9
+
10
+ // Portal container — for Shadow DOM isolation (e.g. anywidget/Marimo)
11
+ export { PortalContainerContext } from './portal-container.tsx';
12
+
13
+ // Advanced composable API — for users who need granular control with their own Provider
14
+ export { DtourViewer } from './DtourViewer.tsx';
15
+ export type { DtourViewerProps } from './DtourViewer.tsx';
16
+ export { DtourToolbar } from './components/DtourToolbar.tsx';
17
+ export { CircularSlider } from './components/CircularSlider.tsx';
18
+ export type { CircularSliderProps } from './components/CircularSlider.tsx';
19
+ export { createDefaultViews } from './views.ts';
20
+
21
+ // Radial chart — quality metrics visualization
22
+ export { RadialChart, parseMetrics } from './radial-chart/index.ts';
23
+ export type { RadialTrackConfig, ParsedTrack, RadialChartProps } from './radial-chart/index.ts';
24
+
25
+ // Jotai atoms — for advanced users composing with DtourViewer + own Provider
26
+ export {
27
+ // Tour
28
+ tourPositionAtom,
29
+ tourPlayingAtom,
30
+ tourSpeedAtom,
31
+ tourDirectionAtom,
32
+ // Preview
33
+ previewCountAtom,
34
+ previewPaddingAtom,
35
+ selectedKeyframeAtom,
36
+ // Point style
37
+ pointSizeAtom,
38
+ pointOpacityAtom,
39
+ pointColorAtom,
40
+ colorMapAtom,
41
+ // Camera
42
+ cameraPanXAtom,
43
+ cameraPanYAtom,
44
+ cameraZoomAtom,
45
+ // View mode
46
+ viewModeAtom,
47
+ // Legend
48
+ showLegendAtom,
49
+ legendVisibleAtom,
50
+ // Theme
51
+ themeModeAtom,
52
+ resolvedThemeAtom,
53
+ // Read-only
54
+ metadataAtom,
55
+ } from './state/atoms.ts';
@@ -0,0 +1,105 @@
1
+ /** Gap between adjacent previews (CSS px). */
2
+ export const GAP = 32;
3
+ /** Maximum preview size (CSS px). */
4
+ export const MAX_SIZE = 320;
5
+ /**
6
+ * Per-edge-count ratio arrays.
7
+ * k=1 (4 previews) → [1] all same
8
+ * k=2 (8 previews) → [1, 0.5] corners 1, middle 0.5
9
+ * k=3 (12 previews) → [1, 0.75] corners 1, mid 0.75
10
+ * k=4 (16 previews) → [1, 0.75, 0.5] corners 1, near-corner 0.75, middle 0.5
11
+ */
12
+ const RATIOS_BY_K: Record<number, readonly number[]> = {
13
+ 1: [1],
14
+ 2: [1, 0.5],
15
+ 3: [1, 0.75],
16
+ 4: [1, 0.75, 0.5],
17
+ };
18
+
19
+ const FALLBACK_RATIOS: readonly number[] = [1, 0.75, 0.5];
20
+
21
+ /**
22
+ * Size ratio for position `j` on an edge of `k` emitted points.
23
+ * distFromCorner = min(j, k - j), then looked up in the per-k ratio table.
24
+ */
25
+ export function sizeRatio(j: number, k: number): number {
26
+ const ratios = RATIOS_BY_K[k] ?? FALLBACK_RATIOS;
27
+ const dist = Math.min(j, k - j);
28
+ return ratios[Math.min(dist, ratios.length - 1)] ?? 1;
29
+ }
30
+
31
+ export type GallerySizes = {
32
+ /** CSS grid-template-columns value (e.g. "320px 256px 320px") */
33
+ gridTemplateColumns: string;
34
+ /** CSS grid-template-rows value */
35
+ gridTemplateRows: string;
36
+ /** Per-preview pixel size (indexed by preview slot) */
37
+ sizes: number[];
38
+ /** Largest preview size in px (for padding/selector math) */
39
+ previewSize: number;
40
+ /** Horizontal padding (px) */
41
+ padX: number;
42
+ /** Vertical padding (px) */
43
+ padY: number;
44
+ };
45
+
46
+ /**
47
+ * Compute ratio-weighted CSS grid templates and preview sizes.
48
+ *
49
+ * Track sizes are pixel values derived from the **shorter** container
50
+ * dimension so previews stay square and identically sized on both axes.
51
+ * `space-between` on the grid container distributes extra space on the
52
+ * longer axis to push tracks to the perimeter edges.
53
+ *
54
+ * k=1 → "Spx Spx"
55
+ * k=2 → "Spx 0.8Spx Spx"
56
+ * k=3 → "Spx 0.8Spx 0.8Spx Spx"
57
+ * k=4 → "Spx 0.8Spx 0.6Spx 0.8Spx Spx"
58
+ */
59
+ export function computeGallerySizes(
60
+ containerWidth: number,
61
+ containerHeight: number,
62
+ previewCount: number,
63
+ scale = 1,
64
+ ): GallerySizes {
65
+ const k = Math.max(1, previewCount / 4);
66
+ const numTracks = k + 1;
67
+
68
+ // Build ratio array for the grid tracks
69
+ const ratios: number[] = [];
70
+ let ratioSum = 0;
71
+ for (let j = 0; j <= k; j++) {
72
+ const r = sizeRatio(j, k);
73
+ ratios.push(r);
74
+ ratioSum += r;
75
+ }
76
+
77
+ // Derive all track sizes from the short side so previews stay square.
78
+ // Available = shortSide - gaps between tracks.
79
+ // baseSize is the unit; corner = 1.0×base, mid-edge = 0.8×base, etc.
80
+ const shortSide = Math.min(containerWidth, containerHeight);
81
+ const availableForCells = shortSide - (numTracks - 1) * GAP;
82
+ const baseSize = Math.min(MAX_SIZE, availableForCells / ratioSum) * scale;
83
+
84
+ const template = ratios.map((r) => `${Math.round(baseSize * r)}px`).join(' ');
85
+ const previewSize = baseSize;
86
+
87
+ // Per-preview sizes: each preview's size depends on its edge position.
88
+ // Preview i sits at edge position j = i % k, sized by sizeRatio(j, k).
89
+ const sizes: number[] = [];
90
+ for (let i = 0; i < previewCount; i++) {
91
+ sizes.push(baseSize * sizeRatio(i % k, k));
92
+ }
93
+
94
+ const padX = previewSize / 2;
95
+ const padY = previewSize / 2;
96
+
97
+ return {
98
+ gridTemplateColumns: template,
99
+ gridTemplateRows: template,
100
+ sizes,
101
+ previewSize,
102
+ padX,
103
+ padY,
104
+ };
105
+ }
@@ -0,0 +1,135 @@
1
+ import { GAP, MAX_SIZE, sizeRatio } from './gallery-positions.ts';
2
+
3
+ const MIN_SIZE = 80;
4
+
5
+ /**
6
+ * CircularSlider draws its ring at `size * RING_RATIO` from centre.
7
+ * The selector diameter we return must account for this ratio so
8
+ * the *ring* (not the SVG bounding-box) respects the padding.
9
+ */
10
+ const RING_RATIO = 0.4;
11
+
12
+ /**
13
+ * Compute the largest selector diameter (px) whose visible ring does
14
+ * not overlap any preview in the gallery perimeter layout.
15
+ *
16
+ * For every preview bounding-box we compute the Euclidean distance from
17
+ * the container centre to the nearest point on that box. The container
18
+ * edges (accounting for overlayOffsetY) are an additional constraint.
19
+ * The returned diameter satisfies `size * RING_RATIO + padding ≤ minDist`,
20
+ * i.e. `size = (minDist − padding) / RING_RATIO`, clamped to
21
+ * {@link MIN_SIZE}.
22
+ */
23
+ export function computeSelectorSize(
24
+ containerWidth: number,
25
+ containerHeight: number,
26
+ previewCount: number,
27
+ isToolbarVisible: boolean,
28
+ padding: number,
29
+ scale = 1,
30
+ /** Number of radial metric tracks rendered outside the ring. */
31
+ metricTrackCount = 0,
32
+ ): number {
33
+ if (previewCount === 0 || containerWidth <= 0 || containerHeight <= 0) {
34
+ return Math.max(MIN_SIZE, Math.min(containerWidth, containerHeight));
35
+ }
36
+
37
+ const k = Math.max(1, previewCount / 4);
38
+ const numTracks = k + 1;
39
+
40
+ // Must match Gallery CSS: left-4, right-4, top/bottom = verticalInset.
41
+ // When toolbar is visible overlayOffsetY = toolbarHeight/2 = 20 shifts
42
+ // the wrapper down, so we bump vertical insets to keep 16px visual gap.
43
+ const gridLeft = 16;
44
+ const toolbarOffset = isToolbarVisible ? 20 : 0; // toolbarHeight / 2
45
+ const verticalInset = 16 + toolbarOffset;
46
+ const gridW = containerWidth - 2 * gridLeft;
47
+ const gridH = containerHeight - 2 * verticalInset;
48
+
49
+ // Track sizes (same logic as computeGallerySizes)
50
+ const ratios: number[] = [];
51
+ let ratioSum = 0;
52
+ for (let j = 0; j <= k; j++) {
53
+ const r = sizeRatio(j, k);
54
+ ratios.push(r);
55
+ ratioSum += r;
56
+ }
57
+ const shortSide = Math.min(gridW, gridH);
58
+ const availableForCells = shortSide - (numTracks - 1) * GAP;
59
+ const baseSize = Math.min(MAX_SIZE, availableForCells / ratioSum) * scale;
60
+ const trackSizes = ratios.map((r) => baseSize * r);
61
+ const trackTotal = trackSizes.reduce((a, b) => a + b, 0);
62
+
63
+ // Effective gaps: CSS justify-content/align-content: space-between
64
+ // distributes remaining free space equally between inter-track gutters.
65
+ const freeX = gridW - trackTotal - (numTracks - 1) * GAP;
66
+ const freeY = gridH - trackTotal - (numTracks - 1) * GAP;
67
+ const effGapX = GAP + (numTracks > 1 ? Math.max(0, freeX) / (numTracks - 1) : 0);
68
+ const effGapY = GAP + (numTracks > 1 ? Math.max(0, freeY) / (numTracks - 1) : 0);
69
+
70
+ // Track start positions relative to grid origin
71
+ const colStarts: number[] = [0];
72
+ const rowStarts: number[] = [0];
73
+ for (let j = 1; j <= k; j++) {
74
+ colStarts.push(colStarts[j - 1]! + trackSizes[j - 1]! + effGapX);
75
+ rowStarts.push(rowStarts[j - 1]! + trackSizes[j - 1]! + effGapY);
76
+ }
77
+
78
+ // Selector centre in the overlay-wrapper coordinate system
79
+ const cx = containerWidth / 2;
80
+ const cy = containerHeight / 2;
81
+
82
+ // Container edge constraint: the wrapper is shifted by toolbarOffset,
83
+ // so the visible bottom edge is closer to centre than the top edge.
84
+ let minDist = Math.min(containerWidth / 2, containerHeight / 2 - toolbarOffset);
85
+
86
+ for (let i = 0; i < previewCount; i++) {
87
+ // Grid cell — same edge-walk order as Gallery
88
+ let col: number;
89
+ let row: number;
90
+ if (i < k) {
91
+ row = 0;
92
+ col = i;
93
+ } else if (i < 2 * k) {
94
+ row = i - k;
95
+ col = k;
96
+ } else if (i < 3 * k) {
97
+ row = k;
98
+ col = 3 * k - i;
99
+ } else {
100
+ row = 4 * k - i;
101
+ col = 0;
102
+ }
103
+
104
+ const cellX = gridLeft + colStarts[col]!;
105
+ const cellY = verticalInset + rowStarts[row]!;
106
+ const cellW = trackSizes[col]!;
107
+ const cellH = trackSizes[row]!;
108
+ const size = baseSize * sizeRatio(i % k, k);
109
+
110
+ // Alignment within cell (matches Gallery flex alignment)
111
+ let px: number;
112
+ if (col === 0) px = cellX;
113
+ else if (col === k) px = cellX + cellW - size;
114
+ else px = cellX + (cellW - size) / 2;
115
+
116
+ let py: number;
117
+ if (row === 0) py = cellY;
118
+ else if (row === k) py = cellY + cellH - size;
119
+ else py = cellY + (cellH - size) / 2;
120
+
121
+ // Euclidean distance from centre to nearest point on preview rect
122
+ const dx = Math.max(0, px - cx, cx - (px + size));
123
+ const dy = Math.max(0, py - cy, cy - (py + size));
124
+ minDist = Math.min(minDist, Math.sqrt(dx * dx + dy * dy));
125
+ }
126
+
127
+ // The visible ring sits at size * RING_RATIO from centre. Metric tracks
128
+ // extend outward from the ring; each track is ~18px (16px bar + 2px gap).
129
+ const metricThickness = metricTrackCount * 18;
130
+
131
+ // Outermost point = size * RING_RATIO + metricThickness, so:
132
+ // size * RING_RATIO + metricThickness + padding ≤ minDist
133
+ // size = (minDist - padding - metricThickness) / RING_RATIO
134
+ return Math.max(MIN_SIZE, (minDist - padding - metricThickness) / RING_RATIO);
135
+ }
@@ -0,0 +1,22 @@
1
+ /** Check if a string is a hex color (e.g. #ff6600, #f60). */
2
+ export const isHexColor = (s: string): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
3
+
4
+ /** Parse a hex color string to [r, g, b] in 0-1 range. */
5
+ export const hexToRgb = (hex: string): [number, number, number] => {
6
+ let h = hex.slice(1);
7
+ if (h.length === 3) {
8
+ h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
9
+ }
10
+ const n = Number.parseInt(h, 16);
11
+ return [(n >> 16) / 255, ((n >> 8) & 0xff) / 255, (n & 0xff) / 255];
12
+ };
13
+
14
+ /** Parse a hex color string to [r, g, b] in 0-255 range. */
15
+ export const hexToRgb255 = (hex: string): [number, number, number] => {
16
+ let h = hex.slice(1);
17
+ if (h.length === 3) {
18
+ h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
19
+ }
20
+ const n = Number.parseInt(h, 16);
21
+ return [n >> 16, (n >> 8) & 0xff, n & 0xff];
22
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * In-place Gram-Schmidt orthonormalization for a p×2 column-major basis.
3
+ * Layout: [x0, x1, ..., xp-1, y0, y1, ..., yp-1]
4
+ *
5
+ * Normalizes column 0, orthogonalizes column 1 against column 0,
6
+ * then normalizes column 1.
7
+ */
8
+ export const gramSchmidt = (basis: Float32Array, dims: number): void => {
9
+ // Normalize column 0
10
+ let norm0 = 0;
11
+ for (let i = 0; i < dims; i++) {
12
+ norm0 += basis[i]! * basis[i]!;
13
+ }
14
+ norm0 = Math.sqrt(norm0);
15
+ if (norm0 > 1e-12) {
16
+ for (let i = 0; i < dims; i++) {
17
+ basis[i]! /= norm0;
18
+ }
19
+ }
20
+
21
+ // Orthogonalize column 1 against column 0
22
+ let dot = 0;
23
+ for (let i = 0; i < dims; i++) {
24
+ dot += basis[i]! * basis[dims + i]!;
25
+ }
26
+ for (let i = 0; i < dims; i++) {
27
+ basis[dims + i]! -= dot * basis[i]!;
28
+ }
29
+
30
+ // Normalize column 1
31
+ let norm1 = 0;
32
+ for (let i = 0; i < dims; i++) {
33
+ norm1 += basis[dims + i]! * basis[dims + i]!;
34
+ }
35
+ norm1 = Math.sqrt(norm1);
36
+ if (norm1 > 1e-12) {
37
+ for (let i = 0; i < dims; i++) {
38
+ basis[dims + i]! /= norm1;
39
+ }
40
+ }
41
+ };
@@ -0,0 +1,4 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
@@ -0,0 +1,14 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * Context for overriding where Radix portals render their content.
5
+ * When provided, dropdown menus, tooltips, etc. portal into the given
6
+ * element instead of `document.body`. This is required for Shadow DOM
7
+ * isolation (e.g. the anywidget/Marimo embed) so that portalled content
8
+ * stays inside the shadow root and inherits scoped styles.
9
+ */
10
+ export const PortalContainerContext = createContext<HTMLElement | undefined>(undefined);
11
+
12
+ export function usePortalContainer(): HTMLElement | undefined {
13
+ return useContext(PortalContainerContext);
14
+ }