@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,166 @@
1
+ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useAnimatePosition } from '../hooks/useAnimatePosition.ts';
4
+ import { computeGallerySizes } from '../layout/gallery-positions.ts';
5
+ import { cn } from '../lib/utils.ts';
6
+ import {
7
+ guidedSuspendedAtom,
8
+ previewCountAtom,
9
+ previewScaleAtom,
10
+ selectedKeyframeAtom,
11
+ tourPlayingAtom,
12
+ tourPositionAtom,
13
+ } from '../state/atoms.ts';
14
+
15
+ export type GalleryProps = {
16
+ /** Fixed pool of preview canvas elements (created at scatter init). */
17
+ previewCanvases: HTMLCanvasElement[];
18
+ /** Container width (px). */
19
+ containerWidth: number;
20
+ /** Container height (px). */
21
+ containerHeight: number;
22
+ /** Is toolbar visible? */
23
+ isToolbarVisible: boolean;
24
+ };
25
+
26
+ export const Gallery = ({
27
+ previewCanvases,
28
+ containerWidth,
29
+ containerHeight,
30
+ isToolbarVisible,
31
+ }: GalleryProps) => {
32
+ const previewCount = useAtomValue(previewCountAtom);
33
+ const previewScale = useAtomValue(previewScaleAtom);
34
+ const position = useAtomValue(tourPositionAtom);
35
+ const [selectedKeyframe, setSelectedKeyframe] = useAtom(selectedKeyframeAtom);
36
+ const setPlaying = useSetAtom(tourPlayingAtom);
37
+ const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
38
+ const { animateTo } = useAnimatePosition();
39
+ const wrapperRefs = useRef<(HTMLDivElement | null)[]>([]);
40
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
41
+
42
+ // Grid area = container minus its CSS insets.
43
+ // When the toolbar is visible overlayOffsetY shifts the wrapper down by
44
+ // toolbarHeight/2 = 20px. Bump the top & bottom CSS insets by the same
45
+ // amount so the *visual* padding from the visible edges stays at 32px.
46
+ const verticalInset = isToolbarVisible ? 36 : 16; // 16 + toolbarHeight/2
47
+ const gridWidth = containerWidth - 32; // left-4 + right-4 = 32px
48
+ const gridHeight = containerHeight - 2 * verticalInset;
49
+
50
+ const { gridTemplateColumns, gridTemplateRows, sizes } = useMemo(
51
+ () => computeGallerySizes(gridWidth, gridHeight, previewCount, previewScale),
52
+ [gridWidth, gridHeight, previewCount, previewScale],
53
+ );
54
+
55
+ // Adopt each canvas into its wrapper div (once, on mount)
56
+ useEffect(() => {
57
+ for (let i = 0; i < previewCanvases.length; i++) {
58
+ const wrapper = wrapperRefs.current[i];
59
+ const canvas = previewCanvases[i];
60
+ if (wrapper && canvas && canvas.parentElement !== wrapper) {
61
+ wrapper.appendChild(canvas);
62
+ }
63
+ }
64
+ }, [previewCanvases]);
65
+
66
+ const currentKeyframe = Math.round(position * previewCount) % previewCount;
67
+
68
+ const getBorderColor = (i: number): string | undefined => {
69
+ const isActive = i === selectedKeyframe || i === currentKeyframe;
70
+ if (isActive || i === hoveredIndex) return 'var(--color-dtour-highlight)';
71
+ return undefined;
72
+ };
73
+
74
+ const getBorderWidth = (i: number): number | undefined => {
75
+ const isActive = i === selectedKeyframe || i === currentKeyframe;
76
+ if (isActive) return 2;
77
+ return undefined;
78
+ };
79
+
80
+ const getBoxShadow = (i: number): string => {
81
+ if (i === selectedKeyframe)
82
+ return '0 0 8px color-mix(in srgb, var(--color-dtour-highlight) 30%, transparent)';
83
+ if (i === currentKeyframe)
84
+ return '0 0 8px color-mix(in srgb, var(--color-dtour-highlight) 30%, transparent)';
85
+ if (i === hoveredIndex) return '0 0 6px rgba(255, 255, 255, 0.15)';
86
+ return 'none';
87
+ };
88
+
89
+ const handleClick = useCallback(
90
+ (i: number) => {
91
+ setGuidedSuspended(false);
92
+ setSelectedKeyframe(i);
93
+ setPlaying(false);
94
+ animateTo(i / previewCount);
95
+ },
96
+ [previewCount, setSelectedKeyframe, setPlaying, setGuidedSuspended, animateTo],
97
+ );
98
+
99
+ const k = previewCount / 4;
100
+
101
+ return (
102
+ <div
103
+ className="absolute left-4 right-4 grid gap-8 justify-between content-between pointer-events-none"
104
+ style={{ top: verticalInset, bottom: verticalInset, gridTemplateColumns, gridTemplateRows }}
105
+ >
106
+ {previewCanvases.map((_, i) => {
107
+ const visible = i < previewCount;
108
+
109
+ let col: number;
110
+ let row: number;
111
+ if (i < k) {
112
+ // top edge: left → right
113
+ row = 0;
114
+ col = i;
115
+ } else if (i < 2 * k) {
116
+ // right edge: top → bottom
117
+ row = i - k;
118
+ col = k;
119
+ } else if (i < 3 * k) {
120
+ // bottom edge: right → left
121
+ row = k;
122
+ col = 3 * k - i;
123
+ } else {
124
+ // left edge: bottom → top
125
+ row = 4 * k - i;
126
+ col = 0;
127
+ }
128
+
129
+ const verticalAlignment =
130
+ row === 0 ? 'items-start' : row < k ? 'items-center' : 'items-end';
131
+ const horizontalAlignment =
132
+ col === 0 ? 'justify-start' : col < k ? 'justify-center' : 'justify-end';
133
+
134
+ return (
135
+ <div
136
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed pool keyed by slot index
137
+ key={i}
138
+ className={cn('flex pointer-events-none', verticalAlignment, horizontalAlignment)}
139
+ style={{ gridColumn: col + 1, gridRow: row + 1 }}
140
+ >
141
+ <div
142
+ ref={(el) => {
143
+ wrapperRefs.current[i] = el;
144
+ }}
145
+ onClick={visible ? () => handleClick(i) : undefined}
146
+ onKeyDown={undefined}
147
+ onMouseEnter={visible ? () => setHoveredIndex(i) : undefined}
148
+ onMouseLeave={visible ? () => setHoveredIndex(null) : undefined}
149
+ className={cn(
150
+ 'pointer-events-auto overflow-hidden border border-dtour-border rounded transition-[border-color,border-width,box-shadow] duration-200 ease-in-out z-20',
151
+ visible ? 'block cursor-pointer' : 'hidden',
152
+ )}
153
+ style={{
154
+ width: visible ? sizes[i] : 0,
155
+ height: visible ? sizes[i] : 0,
156
+ borderColor: getBorderColor(i),
157
+ borderWidth: getBorderWidth(i),
158
+ boxShadow: getBoxShadow(i),
159
+ }}
160
+ />
161
+ </div>
162
+ );
163
+ })}
164
+ </div>
165
+ );
166
+ };
@@ -0,0 +1,240 @@
1
+ import type { ScatterInstance } from '@dtour/scatter';
2
+ import { useAtomValue, useSetAtom } from 'jotai';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useLongPressIndicator } from '../hooks/useLongPressIndicator.ts';
5
+ import {
6
+ cameraPanXAtom,
7
+ cameraPanYAtom,
8
+ cameraZoomAtom,
9
+ guidedSuspendedAtom,
10
+ legendSelectionAtom,
11
+ viewModeAtom,
12
+ } from '../state/atoms.ts';
13
+
14
+ type LassoOverlayProps = {
15
+ scatter: ScatterInstance | null;
16
+ width: number;
17
+ height: number;
18
+ };
19
+
20
+ const LONG_PRESS_MS = 750;
21
+ const MIN_MOVE_PX = 5;
22
+ const MIN_POINT_DISTANCE = 5;
23
+ const THROTTLE_MS = 10;
24
+
25
+ /** Convert CSS coords to NDC accounting for camera. */
26
+ const cssToNdc = (
27
+ x: number,
28
+ y: number,
29
+ width: number,
30
+ height: number,
31
+ panX: number,
32
+ panY: number,
33
+ zoom: number,
34
+ ): [number, number] => {
35
+ const aspect = width / height || 1;
36
+ const ndcX = (((x / width) * 2 - 1) * aspect) / zoom - panX;
37
+ const ndcY = -((y / height) * 2 - 1) / zoom - panY;
38
+ return [ndcX, ndcY];
39
+ };
40
+
41
+ export const LassoOverlay = ({ scatter, width, height }: LassoOverlayProps) => {
42
+ const panX = useAtomValue(cameraPanXAtom);
43
+ const panY = useAtomValue(cameraPanYAtom);
44
+ const zoom = useAtomValue(cameraZoomAtom);
45
+ const viewMode = useAtomValue(viewModeAtom);
46
+ const setViewMode = useSetAtom(viewModeAtom);
47
+ const setGuidedSuspended = useSetAtom(guidedSuspendedAtom);
48
+ const setLegendSelection = useSetAtom(legendSelectionAtom);
49
+
50
+ const [lassoMode, setLassoMode] = useState(false);
51
+ const [path, setPath] = useState<[number, number][]>([]);
52
+
53
+ const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
54
+ const startPos = useRef<[number, number] | null>(null);
55
+ const lastPointTime = useRef(0);
56
+ const overlayRef = useRef<HTMLDivElement>(null);
57
+
58
+ const { show: showIndicator, hide: hideIndicator } = useLongPressIndicator();
59
+
60
+ const clearLongPress = useCallback(() => {
61
+ if (longPressTimer.current) {
62
+ clearTimeout(longPressTimer.current);
63
+ longPressTimer.current = null;
64
+ hideIndicator(); // Only revert if cancelling (timer was still pending)
65
+ }
66
+ }, [hideIndicator]);
67
+
68
+ const handlePointerDown = useCallback(
69
+ (e: React.PointerEvent) => {
70
+ if (lassoMode || e.button !== 0) return;
71
+ startPos.current = [e.clientX, e.clientY];
72
+
73
+ showIndicator(e.clientX, e.clientY);
74
+
75
+ longPressTimer.current = setTimeout(() => {
76
+ setLassoMode(true);
77
+ setPath([]);
78
+ longPressTimer.current = null;
79
+ }, LONG_PRESS_MS);
80
+ },
81
+ [lassoMode, showIndicator],
82
+ );
83
+
84
+ const handlePointerMove = useCallback(
85
+ (e: React.PointerEvent) => {
86
+ // Cancel long press if moved too far
87
+ if (!lassoMode && startPos.current && longPressTimer.current) {
88
+ const dx = e.clientX - startPos.current[0];
89
+ const dy = e.clientY - startPos.current[1];
90
+ if (Math.sqrt(dx * dx + dy * dy) > MIN_MOVE_PX) {
91
+ clearLongPress();
92
+ }
93
+ return;
94
+ }
95
+
96
+ if (!lassoMode) return;
97
+
98
+ const now = performance.now();
99
+ if (now - lastPointTime.current < THROTTLE_MS) return;
100
+
101
+ const rect = overlayRef.current?.getBoundingClientRect();
102
+ if (!rect) return;
103
+
104
+ const x = e.clientX - rect.left;
105
+ const y = e.clientY - rect.top;
106
+
107
+ setPath((prev) => {
108
+ if (prev.length > 0) {
109
+ const last = prev[prev.length - 1]!;
110
+ const dx = x - last[0];
111
+ const dy = y - last[1];
112
+ if (Math.sqrt(dx * dx + dy * dy) < MIN_POINT_DISTANCE) return prev;
113
+ }
114
+ return [...prev, [x, y]];
115
+ });
116
+
117
+ lastPointTime.current = now;
118
+ },
119
+ [lassoMode, clearLongPress],
120
+ );
121
+
122
+ const handlePointerLeave = useCallback(() => {
123
+ // Pointer left the canvas area (e.g. moved to sidebar / resize handle).
124
+ // Cancel the long-press timer so we don't accidentally enter lasso mode.
125
+ if (!lassoMode) {
126
+ clearLongPress();
127
+ startPos.current = null;
128
+ }
129
+ }, [lassoMode, clearLongPress]);
130
+
131
+ // Also cancel on window blur (e.g. user switches tabs/apps mid-press)
132
+ useEffect(() => {
133
+ const handleBlur = () => {
134
+ if (!lassoMode) {
135
+ clearLongPress();
136
+ startPos.current = null;
137
+ }
138
+ };
139
+ window.addEventListener('blur', handleBlur);
140
+ return () => window.removeEventListener('blur', handleBlur);
141
+ }, [lassoMode, clearLongPress]);
142
+
143
+ const handlePointerUp = useCallback(() => {
144
+ clearLongPress();
145
+ hideIndicator();
146
+
147
+ if (!lassoMode || path.length < 3 || !scatter) {
148
+ setLassoMode(false);
149
+ setPath([]);
150
+ return;
151
+ }
152
+
153
+ // Convert CSS path to NDC polygon and send to GPU worker
154
+ const polygon = new Float32Array(path.length * 2);
155
+ for (let i = 0; i < path.length; i++) {
156
+ const [ndcX, ndcY] = cssToNdc(path[i]![0], path[i]![1], width, height, panX, panY, zoom);
157
+ polygon[i * 2] = ndcX;
158
+ polygon[i * 2 + 1] = ndcY;
159
+ }
160
+
161
+ scatter.lassoSelect(polygon);
162
+ setLegendSelection(null);
163
+
164
+ setLassoMode(false);
165
+ setPath([]);
166
+ }, [
167
+ lassoMode,
168
+ path,
169
+ scatter,
170
+ width,
171
+ height,
172
+ panX,
173
+ panY,
174
+ zoom,
175
+ clearLongPress,
176
+ hideIndicator,
177
+ setLegendSelection,
178
+ ]);
179
+
180
+ // Double-click or Escape clears selection
181
+ const handleDoubleClick = useCallback(() => {
182
+ scatter?.clearSelection();
183
+ setLegendSelection(null);
184
+ }, [scatter, setLegendSelection]);
185
+
186
+ useEffect(() => {
187
+ const handleKeyDown = (e: KeyboardEvent) => {
188
+ if (e.key === 'Escape') {
189
+ if (viewMode === 'grand') {
190
+ // In grand mode, Escape returns to guided mode
191
+ setGuidedSuspended(true);
192
+ setViewMode('guided');
193
+ } else {
194
+ scatter?.clearSelection();
195
+ }
196
+ setLegendSelection(null);
197
+ setLassoMode(false);
198
+ setPath([]);
199
+ clearLongPress();
200
+ }
201
+ };
202
+ window.addEventListener('keydown', handleKeyDown);
203
+ return () => window.removeEventListener('keydown', handleKeyDown);
204
+ }, [scatter, clearLongPress, viewMode, setViewMode, setGuidedSuspended, setLegendSelection]);
205
+
206
+ // Build path string for SVG polygon
207
+ const pathStr = path.map(([x, y]) => `${x},${y}`).join(' ');
208
+
209
+ return (
210
+ <div
211
+ ref={overlayRef}
212
+ className="absolute top-0 left-0 touch-none"
213
+ style={{ width, height, cursor: lassoMode ? 'crosshair' : undefined }}
214
+ onPointerDown={handlePointerDown}
215
+ onPointerMove={handlePointerMove}
216
+ onPointerUp={handlePointerUp}
217
+ onPointerLeave={handlePointerLeave}
218
+ onDoubleClick={handleDoubleClick}
219
+ >
220
+ {/* Lasso polygon */}
221
+ {lassoMode && path.length > 1 && (
222
+ <svg
223
+ width={width}
224
+ height={height}
225
+ role="img"
226
+ aria-label="Lasso selection path"
227
+ className="absolute top-0 left-0 pointer-events-none"
228
+ >
229
+ <polygon
230
+ points={pathStr}
231
+ fill="rgba(79, 143, 247, 0.1)"
232
+ stroke="#4f8ff7"
233
+ strokeWidth={1.5}
234
+ strokeDasharray="4 2"
235
+ />
236
+ </svg>
237
+ )}
238
+ </div>
239
+ );
240
+ };
@@ -0,0 +1,37 @@
1
+ export const Logo = () => (
2
+ <svg
3
+ width="100%"
4
+ height="100%"
5
+ viewBox="0 0 31.2 9.1"
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ xmlSpace="preserve"
8
+ version="1.1"
9
+ >
10
+ <title>dtour</title>
11
+ <path
12
+ fill="currentColor"
13
+ fillRule="evenodd"
14
+ d="M 3.036 8.82 C 2.413 8.82 1.875 8.67 1.422 8.369 C 0.968 8.068 0.618 7.66 0.371 7.143 C 0.123 6.626 0 6.044 0 5.396 C 0 4.744 0.124 4.161 0.373 3.646 C 0.621 3.131 0.974 2.725 1.432 2.427 C 1.89 2.129 2.432 1.98 3.06 1.98 C 3.694 1.98 4.227 2.129 4.66 2.427 C 5.093 2.725 5.42 3.132 5.643 3.647 C 5.865 4.162 5.976 4.745 5.976 5.396 C 5.976 6.043 5.864 6.624 5.642 7.141 C 5.419 7.658 5.089 8.067 4.653 8.368 C 4.217 8.669 3.678 8.82 3.036 8.82 Z M 2.96 7.684 C 3.372 7.684 3.708 7.587 3.967 7.392 C 4.226 7.197 4.417 6.928 4.538 6.583 C 4.659 6.238 4.72 5.843 4.72 5.396 C 4.72 4.947 4.659 4.55 4.537 4.207 C 4.415 3.864 4.227 3.596 3.973 3.404 C 3.719 3.212 3.395 3.116 3 3.116 C 2.585 3.116 2.244 3.218 1.975 3.421 C 1.706 3.624 1.507 3.898 1.378 4.243 C 1.249 4.588 1.184 4.972 1.184 5.396 C 1.184 5.824 1.249 6.211 1.378 6.557 C 1.507 6.903 1.703 7.177 1.965 7.38 C 2.227 7.583 2.559 7.684 2.96 7.684 Z M 6.187 8.682 C 5.998 8.644 5.821 8.587 5.656 8.513 C 5.348 8.374 5.115 8.153 4.956 7.852 C 4.817 7.584 4.743 7.312 4.734 7.035 C 4.725 6.758 4.72 6.444 4.72 6.092 L 4.72 0 L 5.976 0 L 5.976 6.028 C 5.976 6.289 5.979 6.516 5.985 6.708 C 5.991 6.9 6.032 7.061 6.108 7.192 C 6.156 7.275 6.214 7.346 6.281 7.405 C 6.463 7.217 6.718 7.1 7 7.1 C 7.552 7.1 8 7.548 8 8.1 C 8 8.652 7.552 9.1 7 9.1 C 6.665 9.1 6.369 8.935 6.187 8.682 Z"
15
+ />
16
+ <path
17
+ fill="currentColor"
18
+ d="M 6.187 8.682 C 5.998 8.644 5.821 8.587 5.656 8.513 C 5.348 8.374 5.115 8.153 4.956 7.852 C 4.817 7.584 4.743 7.312 4.734 7.035 C 4.725 6.758 4.72 6.444 4.72 6.092 L 4.72 0 L 5.976 0 L 5.976 6.028 C 5.976 6.289 5.979 6.516 5.985 6.708 C 5.991 6.9 6.032 7.061 6.108 7.192 C 6.156 7.275 6.214 7.346 6.281 7.405 C 6.463 7.217 6.718 7.1 7 7.1 C 7.552 7.1 8 7.548 8 8.1 C 8 8.652 7.552 9.1 7 9.1 C 6.665 9.1 6.369 8.935 6.187 8.682 Z"
19
+ />
20
+ <path
21
+ fill="currentColor"
22
+ d="M 11.928 8.64 C 11.521 8.72 11.122 8.754 10.73 8.741 C 10.338 8.728 9.988 8.652 9.68 8.513 C 9.372 8.374 9.139 8.153 8.98 7.852 C 8.841 7.584 8.767 7.312 8.758 7.035 C 8.749 6.758 8.744 6.444 8.744 6.092 L 8.744 0.36 L 10 0.36 L 10 6.028 C 10 6.289 10.003 6.516 10.009 6.708 C 10.015 6.9 10.056 7.061 10.132 7.192 C 10.276 7.44 10.505 7.582 10.82 7.617 C 11.135 7.652 11.504 7.639 11.928 7.576 L 11.928 8.64 Z M 7.5 3.168 L 7.5 2.16 L 11.928 2.16 L 11.928 3.168 L 7.5 3.168 Z"
23
+ />
24
+ <path
25
+ fill="currentColor"
26
+ d="M 15.8 8.82 C 15.152 8.82 14.591 8.674 14.116 8.382 C 13.641 8.09 13.275 7.687 13.016 7.172 C 12.757 6.657 12.628 6.065 12.628 5.396 C 12.628 4.719 12.76 4.123 13.024 3.61 C 13.288 3.097 13.658 2.697 14.133 2.41 C 14.608 2.123 15.164 1.98 15.8 1.98 C 16.449 1.98 17.012 2.126 17.488 2.417 C 17.964 2.708 18.332 3.111 18.591 3.624 C 18.85 4.137 18.98 4.728 18.98 5.396 C 18.98 6.072 18.849 6.667 18.588 7.181 C 18.327 7.695 17.958 8.097 17.481 8.386 C 17.004 8.675 16.444 8.82 15.8 8.82 Z M 15.8 7.636 C 16.421 7.636 16.885 7.428 17.19 7.011 C 17.495 6.594 17.648 6.056 17.648 5.396 C 17.648 4.717 17.494 4.176 17.185 3.771 C 16.876 3.366 16.415 3.164 15.8 3.164 C 15.38 3.164 15.034 3.259 14.763 3.448 C 14.492 3.637 14.29 3.899 14.158 4.234 C 14.026 4.569 13.96 4.956 13.96 5.396 C 13.96 6.076 14.116 6.619 14.427 7.026 C 14.738 7.433 15.196 7.636 15.8 7.636 Z"
27
+ />
28
+ <path
29
+ fill="currentColor"
30
+ d="M 22.744 8.816 C 22.296 8.816 21.92 8.743 21.615 8.598 C 21.31 8.453 21.063 8.263 20.873 8.029 C 20.683 7.795 20.538 7.541 20.438 7.266 C 20.338 6.991 20.27 6.722 20.234 6.457 C 20.198 6.192 20.18 5.96 20.18 5.76 L 20.18 2.16 L 21.452 2.16 L 21.452 5.344 C 21.452 5.596 21.473 5.855 21.515 6.122 C 21.557 6.389 21.637 6.636 21.754 6.865 C 21.871 7.094 22.037 7.278 22.251 7.418 C 22.465 7.558 22.744 7.628 23.088 7.628 C 23.313 7.628 23.526 7.59 23.725 7.515 C 23.924 7.44 24.099 7.32 24.25 7.156 C 24.401 6.992 24.519 6.776 24.606 6.508 C 24.693 6.24 24.736 5.915 24.736 5.532 L 25.516 5.824 C 25.516 6.412 25.406 6.931 25.186 7.381 C 24.966 7.831 24.65 8.183 24.237 8.436 C 23.824 8.689 23.327 8.816 22.744 8.816 Z M 24.884 8.64 L 24.884 6.768 L 24.736 6.768 L 24.736 2.16 L 26 2.16 L 26 8.64 L 24.884 8.64 Z"
31
+ />
32
+ <path
33
+ fill="currentColor"
34
+ d="M 27.68 8.64 L 27.68 2.16 L 28.796 2.16 L 28.796 3.732 L 28.64 3.528 C 28.719 3.32 28.822 3.129 28.95 2.956 C 29.078 2.783 29.227 2.64 29.396 2.528 C 29.559 2.407 29.742 2.313 29.945 2.247 C 30.148 2.181 30.356 2.141 30.568 2.127 C 30.78 2.113 30.983 2.124 31.176 2.16 L 31.176 3.336 C 30.967 3.279 30.733 3.262 30.474 3.287 C 30.215 3.312 29.979 3.395 29.764 3.536 C 29.56 3.665 29.398 3.825 29.279 4.014 C 29.16 4.203 29.074 4.413 29.022 4.643 C 28.97 4.873 28.944 5.115 28.944 5.368 L 28.944 8.64 L 27.68 8.64 Z"
35
+ />
36
+ </svg>
37
+ );
@@ -0,0 +1,36 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { type VariantProps, cva } from 'class-variance-authority';
3
+ import type { ButtonHTMLAttributes } from 'react';
4
+ import { cn } from '../../lib/utils.ts';
5
+
6
+ const buttonVariants = cva(
7
+ 'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium cursor-pointer transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.97] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-dtour-accent disabled:pointer-events-none disabled:opacity-50',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'bg-dtour-accent text-white hover:bg-dtour-accent-hover',
12
+ ghost: 'text-dtour-text hover:bg-dtour-surface hover:text-dtour-highlight',
13
+ outline: 'border border-dtour-border bg-transparent text-dtour-text hover:bg-dtour-surface',
14
+ },
15
+ size: {
16
+ default: 'h-8 px-3',
17
+ sm: 'h-7 px-2 text-xs',
18
+ icon: 'h-8 w-8',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ variant: 'default',
23
+ size: 'default',
24
+ },
25
+ },
26
+ );
27
+
28
+ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
29
+ VariantProps<typeof buttonVariants> & {
30
+ asChild?: boolean;
31
+ };
32
+
33
+ export const Button = ({ className, variant, size, asChild = false, ...props }: ButtonProps) => {
34
+ const Comp = asChild ? Slot : 'button';
35
+ return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
36
+ };
@@ -0,0 +1,92 @@
1
+ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
2
+ import type { ComponentPropsWithoutRef } from 'react';
3
+ import { cn } from '../../lib/utils.ts';
4
+ import { usePortalContainer } from '../../portal-container.tsx';
5
+
6
+ export const DropdownMenu = DropdownMenuPrimitive.Root;
7
+ export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
8
+
9
+ export const DropdownMenuContent = ({
10
+ className,
11
+ sideOffset = 4,
12
+ ...props
13
+ }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>) => {
14
+ const container = usePortalContainer();
15
+ return (
16
+ <DropdownMenuPrimitive.Portal container={container}>
17
+ <DropdownMenuPrimitive.Content
18
+ sideOffset={sideOffset}
19
+ className={cn(
20
+ 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-dtour-border bg-dtour-surface p-1 text-dtour-text shadow-md',
21
+ 'origin-(--radix-dropdown-menu-content-transform-origin) animate-ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ </DropdownMenuPrimitive.Portal>
27
+ );
28
+ };
29
+
30
+ export const DropdownMenuItem = ({
31
+ className,
32
+ ...props
33
+ }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>) => (
34
+ <DropdownMenuPrimitive.Item
35
+ className={cn(
36
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-dtour-border focus:text-dtour-highlight data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
37
+ className,
38
+ )}
39
+ {...props}
40
+ />
41
+ );
42
+
43
+ export const DropdownMenuLabel = ({
44
+ className,
45
+ ...props
46
+ }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>) => (
47
+ <DropdownMenuPrimitive.Label
48
+ className={cn('px-2 py-1.5 text-xs font-semibold text-dtour-text-muted', className)}
49
+ {...props}
50
+ />
51
+ );
52
+
53
+ export const DropdownMenuCheckboxItem = ({
54
+ className,
55
+ children,
56
+ checked = false,
57
+ ...props
58
+ }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>) => (
59
+ <DropdownMenuPrimitive.CheckboxItem
60
+ className={cn(
61
+ 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-dtour-border focus:text-dtour-highlight data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
62
+ className,
63
+ )}
64
+ checked={checked}
65
+ {...props}
66
+ >
67
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
68
+ <DropdownMenuPrimitive.ItemIndicator>
69
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
70
+ <path
71
+ d="M11.5 3.5L5.5 9.5L2.5 6.5"
72
+ stroke="currentColor"
73
+ strokeWidth="1.5"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ />
77
+ </svg>
78
+ </DropdownMenuPrimitive.ItemIndicator>
79
+ </span>
80
+ {children}
81
+ </DropdownMenuPrimitive.CheckboxItem>
82
+ );
83
+
84
+ export const DropdownMenuSeparator = ({
85
+ className,
86
+ ...props
87
+ }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>) => (
88
+ <DropdownMenuPrimitive.Separator
89
+ className={cn('-mx-1 my-1 h-px bg-dtour-border', className)}
90
+ {...props}
91
+ />
92
+ );
@@ -0,0 +1,89 @@
1
+ import * as SliderPrimitive from '@radix-ui/react-slider';
2
+ import type { ComponentPropsWithoutRef, ElementRef } from 'react';
3
+ import { forwardRef } from 'react';
4
+ import { cn } from '../../lib/utils.ts';
5
+
6
+ const Slider = forwardRef<
7
+ ElementRef<typeof SliderPrimitive.Root>,
8
+ ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & { ticks?: number }
9
+ >(({ className, orientation, ticks, value, ...props }, ref) => {
10
+ const current = value?.[0] ?? 0;
11
+ const min = props.min ?? 0;
12
+ const max = props.max ?? 100;
13
+ return (
14
+ <SliderPrimitive.Root
15
+ ref={ref}
16
+ orientation={orientation}
17
+ {...(value ? { value } : {})}
18
+ className={cn(
19
+ 'relative flex touch-none select-none',
20
+ orientation === 'vertical' ? 'h-full flex-col items-center' : 'w-full items-center',
21
+ className,
22
+ )}
23
+ {...props}
24
+ >
25
+ {ticks != null && ticks > 1 && (
26
+ <div
27
+ className={cn(
28
+ 'pointer-events-none absolute',
29
+ orientation === 'vertical'
30
+ ? 'inset-y-0 left-1/2 flex -translate-x-1/2 flex-col justify-between'
31
+ : 'inset-x-0 top-1/2 flex -translate-y-1/2 justify-between',
32
+ )}
33
+ >
34
+ {Array.from({ length: ticks }, (_, i) => {
35
+ const tickValue =
36
+ orientation === 'vertical'
37
+ ? max - (i * (max - min)) / (ticks - 1)
38
+ : min + (i * (max - min)) / (ticks - 1);
39
+ const filled = tickValue <= current;
40
+ return (
41
+ <span
42
+ // biome-ignore lint/suspicious/noArrayIndexKey: static tick marks
43
+ key={i}
44
+ className={cn(
45
+ 'flex h-[9px] w-[9px] items-center justify-center rounded-full',
46
+ filled ? 'bg-dtour-accent' : 'bg-dtour-border',
47
+ )}
48
+ />
49
+ );
50
+ })}
51
+ </div>
52
+ )}
53
+ <SliderPrimitive.Track
54
+ className={cn(
55
+ 'relative grow overflow-hidden rounded-full bg-dtour-border',
56
+ orientation === 'vertical' ? 'w-1.5' : 'h-1.5 w-full',
57
+ )}
58
+ >
59
+ <SliderPrimitive.Range
60
+ className={cn(
61
+ 'absolute rounded-full bg-dtour-accent',
62
+ orientation === 'vertical' ? 'w-full' : 'h-full',
63
+ )}
64
+ />
65
+ </SliderPrimitive.Track>
66
+ {ticks != null && ticks > 1 && (
67
+ <div
68
+ className={cn(
69
+ 'pointer-events-none absolute',
70
+ orientation === 'vertical'
71
+ ? 'inset-y-0 left-1/2 flex -translate-x-1/2 flex-col justify-between'
72
+ : 'inset-x-0 top-1/2 flex -translate-y-1/2 justify-between',
73
+ )}
74
+ >
75
+ {Array.from({ length: ticks }, (_, i) => (
76
+ // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
77
+ <span key={i} className="h-[9px] w-[9px] flex items-center justify-center">
78
+ <span className="h-[3px] w-[3px] rounded-full bg-white/30" />
79
+ </span>
80
+ ))}
81
+ </div>
82
+ )}
83
+ <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-dtour-accent bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-dtour-accent disabled:pointer-events-none disabled:opacity-50" />
84
+ </SliderPrimitive.Root>
85
+ );
86
+ });
87
+ Slider.displayName = SliderPrimitive.Root.displayName;
88
+
89
+ export { Slider };