@delightstack/components 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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1391 @@
1
+ <script lang="ts" module>
2
+ export interface Hotspot {
3
+ /** Where the hotspot sits on the sphere, in degrees (pitch = up/down, yaw = left/right) */
4
+ position: { pitch: number; yaw: number };
5
+ /** Text shown for the hotspot */
6
+ label?: string;
7
+ /** Arbitrary user data attached to the hotspot (passed back in click callbacks) */
8
+ data?: Record<string, unknown>;
9
+ }
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ import { untrack } from 'svelte';
14
+ import { DelightError } from '@delightstack/utilities';
15
+ import Button from '../actions/Button.svelte';
16
+
17
+ /* ── Minimal Three.js type surface ───────────────────────── */
18
+
19
+ interface ThreeVector2 {
20
+ x: number;
21
+ y: number;
22
+ }
23
+
24
+ interface ThreeVector3 {
25
+ x: number;
26
+ y: number;
27
+ z: number;
28
+ clone(): ThreeVector3;
29
+ normalize(): ThreeVector3;
30
+ project(camera: ThreeCamera): ThreeVector3;
31
+ dot(v: ThreeVector3): number;
32
+ }
33
+
34
+ interface ThreeTexture {
35
+ colorSpace: string;
36
+ dispose(): void;
37
+ }
38
+
39
+ interface ThreeMaterial {
40
+ map: ThreeTexture | null;
41
+ needsUpdate: boolean;
42
+ dispose(): void;
43
+ }
44
+
45
+ interface ThreeMesh {
46
+ // no methods/properties used directly
47
+ }
48
+
49
+ interface ThreeGeometry {
50
+ dispose(): void;
51
+ }
52
+
53
+ interface ThreeCamera {
54
+ position: { set(x: number, y: number, z: number): void };
55
+ fov: number;
56
+ aspect: number;
57
+ lookAt(x: number, y: number, z: number): void;
58
+ updateProjectionMatrix(): void;
59
+ getWorldDirection(target: ThreeVector3): ThreeVector3;
60
+ }
61
+
62
+ interface ThreeRenderer {
63
+ setSize(width: number, height: number): void;
64
+ setPixelRatio(ratio: number): void;
65
+ getSize(target: ThreeVector2): ThreeVector2;
66
+ render(scene: ThreeScene, camera: ThreeCamera): void;
67
+ dispose(): void;
68
+ }
69
+
70
+ interface ThreeScene {
71
+ add(object: ThreeMesh): void;
72
+ }
73
+
74
+ interface ThreeTextureLoader {
75
+ load(
76
+ url: string,
77
+ onLoad: (texture: ThreeTexture) => void,
78
+ onProgress: undefined,
79
+ onError: (err: unknown) => void,
80
+ ): void;
81
+ }
82
+
83
+ interface ThreeModule {
84
+ Scene: new () => ThreeScene;
85
+ PerspectiveCamera: new (
86
+ fov: number,
87
+ aspect: number,
88
+ near: number,
89
+ far: number,
90
+ ) => ThreeCamera;
91
+ WebGLRenderer: new (params: {
92
+ canvas: HTMLCanvasElement;
93
+ antialias: boolean;
94
+ }) => ThreeRenderer;
95
+ SphereGeometry: new (
96
+ radius: number,
97
+ widthSegments: number,
98
+ heightSegments: number,
99
+ ) => ThreeGeometry;
100
+ MeshBasicMaterial: new (params: { map: ThreeTexture; side: number }) => ThreeMaterial;
101
+ Mesh: new (geometry: ThreeGeometry, material: ThreeMaterial) => ThreeMesh;
102
+ TextureLoader: new () => ThreeTextureLoader;
103
+ Vector2: new () => ThreeVector2;
104
+ Vector3: new (x?: number, y?: number, z?: number) => ThreeVector3;
105
+ SRGBColorSpace: string;
106
+ BackSide: number;
107
+ }
108
+
109
+ const DEG2RAD = Math.PI / 180;
110
+ const FOV_MIN = 30;
111
+ const FOV_MAX = 120;
112
+ const AUTO_RESUME_DELAY = 3000;
113
+ const INERTIA_DAMPING = 0.92;
114
+ const VIEWCHANGE_THROTTLE = 50;
115
+
116
+ const propId = $props.id();
117
+
118
+ let {
119
+ /** Equirectangular panorama image URL */
120
+ src,
121
+
122
+ /** Starting camera orientation in degrees */
123
+ initial_view = { pitch: 0, yaw: 0 },
124
+
125
+ /** Field of view in degrees */
126
+ fov = 75,
127
+
128
+ /** Gentle continuous rotation */
129
+ auto_rotate = false,
130
+
131
+ /** Rotation speed multiplier */
132
+ auto_rotate_speed = 1,
133
+
134
+ /** Show zoom/fullscreen buttons */
135
+ show_controls = true,
136
+
137
+ /** Whether the panorama is being rendered inside an embedded context
138
+ * (Carousel/Gallery). When true, controls are hidden, keyboard text
139
+ * selection is disabled, and the canvas is rendered at lower DPR for
140
+ * better performance. */
141
+ embedded = false,
142
+
143
+ /** Enable drag/touch/scroll interaction */
144
+ interactive = true,
145
+
146
+ /** Enable device orientation control on mobile */
147
+ gyroscope = false,
148
+
149
+ /** Interactive markers in the panorama */
150
+ hotspots = [] as Hotspot[],
151
+
152
+ /** Static image URL when WebGL unavailable */
153
+ fallback = undefined as string | undefined,
154
+
155
+ /** Show loading skeleton */
156
+ skeleton = false,
157
+
158
+ /** Element ID */
159
+ id = propId,
160
+
161
+ /** Additional CSS classes */
162
+ class: class_name = '',
163
+
164
+ /** Bindable root element reference */
165
+ element = $bindable(undefined as HTMLElement | undefined),
166
+
167
+ /** Camera changed */
168
+ onviewchange = undefined as
169
+ | undefined
170
+ | ((detail: { pitch: number; yaw: number; fov: number }) => void),
171
+
172
+ /** Hotspot clicked */
173
+ onhotspotclick = undefined as undefined | ((detail: { hotspot: Hotspot }) => void),
174
+
175
+ /** Panorama ready */
176
+ onload = undefined as undefined | (() => void),
177
+
178
+ /** Failed to load */
179
+ onerror = undefined as undefined | ((detail: { error: Error }) => void),
180
+ }: {
181
+ src: string;
182
+ initial_view?: { pitch: number; yaw: number };
183
+ fov?: number;
184
+ auto_rotate?: boolean;
185
+ auto_rotate_speed?: number;
186
+ show_controls?: boolean;
187
+ embedded?: boolean;
188
+ interactive?: boolean;
189
+ gyroscope?: boolean;
190
+ hotspots?: Hotspot[];
191
+ fallback?: string;
192
+ skeleton?: boolean;
193
+ id?: string;
194
+ class?: string;
195
+ element?: HTMLElement | undefined;
196
+ onviewchange?: (detail: { pitch: number; yaw: number; fov: number }) => void;
197
+ onhotspotclick?: (detail: { hotspot: Hotspot }) => void;
198
+ onload?: () => void;
199
+ onerror?: (detail: { error: Error }) => void;
200
+ } = $props();
201
+
202
+ /* ── State ────────────────────────────────────────────────── */
203
+
204
+ let canvas_el: HTMLCanvasElement | undefined = $state(undefined);
205
+ let has_webgl = $state(true);
206
+ let loaded = $state(false);
207
+ let loading = $state(true);
208
+ let error_state = $state(false);
209
+
210
+ let current_pitch = $state(initial_view.pitch);
211
+ let current_yaw = $state(initial_view.yaw);
212
+ let current_fov = $state(fov);
213
+ let is_fullscreen = $state(false);
214
+
215
+ // Hotspot screen positions: [x, y, visible]
216
+ let hotspot_positions = $state<{ x: number; y: number; visible: boolean }[]>([]);
217
+
218
+ /* ── Internals (not reactive) ─────────────────────────────── */
219
+
220
+ let three: ThreeModule | undefined;
221
+ let renderer: ThreeRenderer | undefined;
222
+ let scene: ThreeScene | undefined;
223
+ let camera: ThreeCamera | undefined;
224
+ let sphere_mesh: ThreeMesh | undefined;
225
+ let texture: ThreeTexture | undefined;
226
+ let material: ThreeMaterial | undefined;
227
+ let geometry: ThreeGeometry | undefined;
228
+
229
+ let animation_frame_id = 0;
230
+ let is_dragging = $state(false);
231
+ let drag_start_x = 0;
232
+ let drag_start_y = 0;
233
+ let drag_start_yaw = 0;
234
+ let drag_start_pitch = 0;
235
+ let velocity_x = 0;
236
+ let velocity_y = 0;
237
+ let last_move_x = 0;
238
+ let last_move_y = 0;
239
+ let last_move_time = 0;
240
+ let auto_rotate_paused = false;
241
+ let auto_rotate_resume_timer: ReturnType<typeof setTimeout> | undefined;
242
+ let last_viewchange_time = 0;
243
+ let intersection_observer: IntersectionObserver | undefined;
244
+ let is_visible = true;
245
+ let prefers_reduced_motion = false;
246
+ let gyro_enabled = false;
247
+ let gyro_initial_alpha: number | null = null;
248
+ let gyro_initial_beta: number | null = null;
249
+
250
+ /* ── WebGL check ──────────────────────────────────────────── */
251
+
252
+ function checkWebGL(): boolean {
253
+ try {
254
+ const test_canvas = document.createElement('canvas');
255
+ const ctx =
256
+ test_canvas.getContext('webgl') || test_canvas.getContext('experimental-webgl');
257
+ return !!ctx;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /* ── Reduced motion ───────────────────────────────────────── */
264
+
265
+ function checkReducedMotion(): boolean {
266
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
267
+ }
268
+
269
+ /* ── Camera orientation helpers ───────────────────────────── */
270
+
271
+ function updateCameraRotation() {
272
+ if (!camera) return;
273
+ // Clamp pitch to avoid flipping
274
+ current_pitch = Math.max(-85, Math.min(85, current_pitch));
275
+
276
+ const phi = (90 - current_pitch) * DEG2RAD;
277
+ const theta = current_yaw * DEG2RAD;
278
+
279
+ const target_x = 500 * Math.sin(phi) * Math.cos(theta);
280
+ const target_y = 500 * Math.cos(phi);
281
+ const target_z = 500 * Math.sin(phi) * Math.sin(theta);
282
+
283
+ camera.lookAt(target_x, target_y, target_z);
284
+ }
285
+
286
+ function emitViewChange() {
287
+ if (!onviewchange) return;
288
+ const now = performance.now();
289
+ if (now - last_viewchange_time < VIEWCHANGE_THROTTLE) return;
290
+ last_viewchange_time = now;
291
+ onviewchange({
292
+ pitch: Math.round(current_pitch * 100) / 100,
293
+ yaw: Math.round(current_yaw * 100) / 100,
294
+ fov: Math.round(current_fov * 100) / 100,
295
+ });
296
+ }
297
+
298
+ /* ── Interaction handlers ─────────────────────────────────── */
299
+
300
+ function pauseAutoRotate() {
301
+ auto_rotate_paused = true;
302
+ clearTimeout(auto_rotate_resume_timer);
303
+ auto_rotate_resume_timer = setTimeout(() => {
304
+ auto_rotate_paused = false;
305
+ }, AUTO_RESUME_DELAY);
306
+ }
307
+
308
+ function handlePointerDown(e: PointerEvent) {
309
+ if (!interactive) return;
310
+ is_dragging = true;
311
+ drag_start_x = e.clientX;
312
+ drag_start_y = e.clientY;
313
+ drag_start_yaw = current_yaw;
314
+ drag_start_pitch = current_pitch;
315
+ velocity_x = 0;
316
+ velocity_y = 0;
317
+ last_move_x = e.clientX;
318
+ last_move_y = e.clientY;
319
+ last_move_time = performance.now();
320
+ pauseAutoRotate();
321
+ (e.currentTarget as HTMLElement)?.setPointerCapture(e.pointerId);
322
+ }
323
+
324
+ function handlePointerMove(e: PointerEvent) {
325
+ if (!is_dragging || !interactive) return;
326
+ const now = performance.now();
327
+ const dt = Math.max(1, now - last_move_time);
328
+ const dx = e.clientX - drag_start_x;
329
+ const dy = e.clientY - drag_start_y;
330
+
331
+ velocity_x = (e.clientX - last_move_x) / dt;
332
+ velocity_y = (e.clientY - last_move_y) / dt;
333
+ last_move_x = e.clientX;
334
+ last_move_y = e.clientY;
335
+ last_move_time = now;
336
+
337
+ const scale = current_fov / 1000;
338
+ current_yaw = drag_start_yaw - dx * scale;
339
+ current_pitch = drag_start_pitch + dy * scale;
340
+ updateCameraRotation();
341
+ emitViewChange();
342
+ }
343
+
344
+ function handlePointerUp(e: PointerEvent) {
345
+ if (!is_dragging) return;
346
+ is_dragging = false;
347
+ (e.currentTarget as HTMLElement)?.releasePointerCapture(e.pointerId);
348
+
349
+ // Start inertia unless reduced motion
350
+ if (!prefers_reduced_motion) {
351
+ applyInertia();
352
+ }
353
+ }
354
+
355
+ function applyInertia() {
356
+ if (Math.abs(velocity_x) < 0.001 && Math.abs(velocity_y) < 0.001) return;
357
+
358
+ const scale = current_fov / 1000;
359
+ current_yaw -= velocity_x * 16 * scale;
360
+ current_pitch += velocity_y * 16 * scale;
361
+ velocity_x *= INERTIA_DAMPING;
362
+ velocity_y *= INERTIA_DAMPING;
363
+ updateCameraRotation();
364
+ emitViewChange();
365
+
366
+ if (Math.abs(velocity_x) > 0.001 || Math.abs(velocity_y) > 0.001) {
367
+ requestAnimationFrame(applyInertia);
368
+ }
369
+ }
370
+
371
+ function handleWheel(e: WheelEvent) {
372
+ if (!interactive) return;
373
+ e.preventDefault();
374
+ const delta = e.deltaY > 0 ? 5 : -5;
375
+ current_fov = Math.max(FOV_MIN, Math.min(FOV_MAX, current_fov + delta));
376
+ if (camera) {
377
+ camera.fov = current_fov;
378
+ camera.updateProjectionMatrix();
379
+ }
380
+ pauseAutoRotate();
381
+ emitViewChange();
382
+ }
383
+
384
+ /* ── Touch zoom (pinch) ───────────────────────────────────── */
385
+
386
+ let pinch_start_distance = 0;
387
+ let pinch_start_fov = 0;
388
+
389
+ function getTouchDistance(e: TouchEvent): number {
390
+ if (e.touches.length < 2) return 0;
391
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
392
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
393
+ return Math.sqrt(dx * dx + dy * dy);
394
+ }
395
+
396
+ function handleTouchStart(e: TouchEvent) {
397
+ if (e.touches.length === 2) {
398
+ pinch_start_distance = getTouchDistance(e);
399
+ pinch_start_fov = current_fov;
400
+ }
401
+ }
402
+
403
+ function handleTouchMove(e: TouchEvent) {
404
+ if (!interactive || e.touches.length < 2) return;
405
+ e.preventDefault();
406
+ const dist = getTouchDistance(e);
407
+ if (pinch_start_distance > 0 && dist > 0) {
408
+ const scale = pinch_start_distance / dist;
409
+ current_fov = Math.max(FOV_MIN, Math.min(FOV_MAX, pinch_start_fov * scale));
410
+ if (camera) {
411
+ camera.fov = current_fov;
412
+ camera.updateProjectionMatrix();
413
+ }
414
+ emitViewChange();
415
+ }
416
+ }
417
+
418
+ function handleTouchEnd() {
419
+ pinch_start_distance = 0;
420
+ }
421
+
422
+ /* ── Keyboard ─────────────────────────────────────────────── */
423
+
424
+ function handleKeyDown(e: KeyboardEvent) {
425
+ if (!interactive) return;
426
+ const PAN_STEP = 5;
427
+
428
+ switch (e.key) {
429
+ case 'ArrowLeft':
430
+ e.preventDefault();
431
+ current_yaw -= PAN_STEP;
432
+ updateCameraRotation();
433
+ emitViewChange();
434
+ pauseAutoRotate();
435
+ break;
436
+ case 'ArrowRight':
437
+ e.preventDefault();
438
+ current_yaw += PAN_STEP;
439
+ updateCameraRotation();
440
+ emitViewChange();
441
+ pauseAutoRotate();
442
+ break;
443
+ case 'ArrowUp':
444
+ e.preventDefault();
445
+ current_pitch += PAN_STEP;
446
+ updateCameraRotation();
447
+ emitViewChange();
448
+ pauseAutoRotate();
449
+ break;
450
+ case 'ArrowDown':
451
+ e.preventDefault();
452
+ current_pitch -= PAN_STEP;
453
+ updateCameraRotation();
454
+ emitViewChange();
455
+ pauseAutoRotate();
456
+ break;
457
+ case '+':
458
+ case '=':
459
+ e.preventDefault();
460
+ zoomIn();
461
+ break;
462
+ case '-':
463
+ e.preventDefault();
464
+ zoomOut();
465
+ break;
466
+ case 'Home':
467
+ e.preventDefault();
468
+ resetView();
469
+ break;
470
+ case ' ':
471
+ e.preventDefault();
472
+ auto_rotate_paused = !auto_rotate_paused;
473
+ break;
474
+ }
475
+ }
476
+
477
+ /* ── Control actions ──────────────────────────────────────── */
478
+
479
+ function zoomIn() {
480
+ animateFovTo(Math.max(FOV_MIN, current_fov - 15));
481
+ }
482
+
483
+ function zoomOut() {
484
+ animateFovTo(Math.min(FOV_MAX, current_fov + 15));
485
+ }
486
+
487
+ /**
488
+ * Double-click / double-tap toggles between the initial `fov` and a
489
+ * zoomed-in fov. If the user has already zoomed in via wheel/pinch,
490
+ * the double-click resets to initial. The change is animated over
491
+ * ~300ms to give physical-feeling zoom.
492
+ */
493
+ let fov_tween_raf = 0;
494
+ function animateFovTo(target: number, duration_ms = 300) {
495
+ if (fov_tween_raf) cancelAnimationFrame(fov_tween_raf);
496
+ const start = current_fov;
497
+ const delta = target - start;
498
+ const t0 = performance.now();
499
+ const step = (now: number) => {
500
+ const t = Math.min(1, (now - t0) / duration_ms);
501
+ // easeOutCubic — fast start, gentle settle (feels like physical inertia)
502
+ const eased = 1 - Math.pow(1 - t, 3);
503
+ current_fov = start + delta * eased;
504
+ if (camera) {
505
+ camera.fov = current_fov;
506
+ camera.updateProjectionMatrix();
507
+ }
508
+ emitViewChange();
509
+ if (t < 1) {
510
+ fov_tween_raf = requestAnimationFrame(step);
511
+ } else {
512
+ fov_tween_raf = 0;
513
+ }
514
+ };
515
+ fov_tween_raf = requestAnimationFrame(step);
516
+ }
517
+
518
+ function handleDoubleClick(e: MouseEvent) {
519
+ if (!interactive) return;
520
+ e.preventDefault();
521
+ // Zoom in to roughly 1/3 of initial — closer than half feels noticeably
522
+ // punchy without crossing the FOV_MIN boundary (30°) for typical
523
+ // initial fovs (40-90°).
524
+ const zoomed_fov = Math.max(FOV_MIN, fov * 0.35);
525
+ // Consider "zoomed in" if current is meaningfully below initial.
526
+ const is_zoomed_in = current_fov < fov - 0.5;
527
+ const target = is_zoomed_in ? fov : zoomed_fov;
528
+ pauseAutoRotate();
529
+ animateFovTo(target);
530
+ }
531
+
532
+ function resetView() {
533
+ current_pitch = initial_view.pitch;
534
+ current_yaw = initial_view.yaw;
535
+ current_fov = fov;
536
+ if (camera) {
537
+ camera.fov = current_fov;
538
+ camera.updateProjectionMatrix();
539
+ }
540
+ updateCameraRotation();
541
+ emitViewChange();
542
+ }
543
+
544
+ function toggleFullscreen() {
545
+ if (!element) return;
546
+ if (document.fullscreenElement) {
547
+ document.exitFullscreen();
548
+ } else {
549
+ element.requestFullscreen();
550
+ }
551
+ }
552
+
553
+ /* ── Hotspot projection ───────────────────────────────────── */
554
+
555
+ function projectHotspots() {
556
+ if (!camera || !renderer || !three) {
557
+ hotspot_positions = hotspots.map(() => ({
558
+ x: 0,
559
+ y: 0,
560
+ visible: false,
561
+ }));
562
+ return;
563
+ }
564
+
565
+ const size = renderer.getSize(new three.Vector2());
566
+ const half_w = size.x / 2;
567
+ const half_h = size.y / 2;
568
+
569
+ hotspot_positions = hotspots.map((hs) => {
570
+ const phi = (90 - hs.position.pitch) * DEG2RAD;
571
+ const theta = hs.position.yaw * DEG2RAD;
572
+
573
+ const x = 500 * Math.sin(phi) * Math.cos(theta);
574
+ const y = 500 * Math.cos(phi);
575
+ const z = 500 * Math.sin(phi) * Math.sin(theta);
576
+
577
+ const pos = new three!.Vector3(x, y, z);
578
+
579
+ // Check if behind camera using dot product
580
+ const cam_dir = new three!.Vector3();
581
+ camera!.getWorldDirection(cam_dir);
582
+ const to_hotspot = pos.clone().normalize();
583
+ const dot = cam_dir.dot(to_hotspot);
584
+
585
+ if (dot < 0) {
586
+ return { x: 0, y: 0, visible: false };
587
+ }
588
+
589
+ // Project to screen
590
+ const projected = pos.clone().project(camera!);
591
+ const screen_x = projected.x * half_w + half_w;
592
+ const screen_y = -(projected.y * half_h) + half_h;
593
+
594
+ const in_bounds =
595
+ screen_x >= -50 &&
596
+ screen_x <= size.x + 50 &&
597
+ screen_y >= -50 &&
598
+ screen_y <= size.y + 50;
599
+
600
+ return { x: screen_x, y: screen_y, visible: in_bounds };
601
+ });
602
+ }
603
+
604
+ /* ── Gyroscope ────────────────────────────────────────────── */
605
+
606
+ function handleDeviceOrientation(e: DeviceOrientationEvent) {
607
+ if (!gyro_enabled) return;
608
+ if (e.alpha === null || e.beta === null || e.gamma === null) return;
609
+
610
+ if (gyro_initial_alpha === null) {
611
+ gyro_initial_alpha = e.alpha;
612
+ gyro_initial_beta = e.beta;
613
+ }
614
+
615
+ // Map device orientation to panorama view
616
+ current_yaw = initial_view.yaw + (e.alpha - gyro_initial_alpha!) * -1;
617
+ current_pitch = initial_view.pitch + (e.beta - gyro_initial_beta!) * -1;
618
+ current_pitch = Math.max(-85, Math.min(85, current_pitch));
619
+
620
+ updateCameraRotation();
621
+ emitViewChange();
622
+ }
623
+
624
+ async function enableGyroscope() {
625
+ if (!gyroscope) return;
626
+
627
+ // Check if device orientation is available
628
+ if (typeof DeviceOrientationEvent === 'undefined') return;
629
+
630
+ // iOS 13+ requires permission
631
+ const DOE = DeviceOrientationEvent as typeof DeviceOrientationEvent & {
632
+ requestPermission?: () => Promise<string>;
633
+ };
634
+ if (typeof DOE.requestPermission === 'function') {
635
+ try {
636
+ const permission = await DOE.requestPermission();
637
+ if (permission !== 'granted') return;
638
+ } catch {
639
+ return;
640
+ }
641
+ }
642
+
643
+ gyro_enabled = true;
644
+ window.addEventListener('deviceorientation', handleDeviceOrientation);
645
+ }
646
+
647
+ /* ── Render loop ──────────────────────────────────────────── */
648
+
649
+ function renderLoop() {
650
+ animation_frame_id = requestAnimationFrame(renderLoop);
651
+
652
+ if (!is_visible || !renderer || !scene || !camera) return;
653
+
654
+ // Auto-rotate
655
+ if (auto_rotate && !auto_rotate_paused && !is_dragging && !prefers_reduced_motion) {
656
+ current_yaw += 0.02 * auto_rotate_speed;
657
+ updateCameraRotation();
658
+ emitViewChange();
659
+ }
660
+
661
+ // Project hotspots
662
+ if (hotspots.length > 0) {
663
+ projectHotspots();
664
+ }
665
+
666
+ renderer.render(scene, camera);
667
+ }
668
+
669
+ /* ── Resize handling ──────────────────────────────────────── */
670
+
671
+ function handleResize() {
672
+ if (!canvas_el || !renderer || !camera) return;
673
+ const container = canvas_el.parentElement;
674
+ if (!container) return;
675
+
676
+ const width = container.clientWidth;
677
+ const height = container.clientHeight;
678
+ const dpr = embedded
679
+ ? Math.min(window.devicePixelRatio || 1, 1.5)
680
+ : window.devicePixelRatio || 1;
681
+
682
+ renderer.setSize(width, height);
683
+ renderer.setPixelRatio(dpr);
684
+ camera.aspect = width / height;
685
+ camera.updateProjectionMatrix();
686
+ }
687
+
688
+ /* ── Fullscreen change ────────────────────────────────────── */
689
+
690
+ function handleFullscreenChange() {
691
+ is_fullscreen = !!document.fullscreenElement;
692
+ // Give the browser a tick to update layout before resizing
693
+ requestAnimationFrame(handleResize);
694
+ }
695
+
696
+ /* ── Initialize Three.js ──────────────────────────────────── */
697
+
698
+ async function initialize() {
699
+ if (!canvas_el) return;
700
+
701
+ loading = true;
702
+ error_state = false;
703
+
704
+ try {
705
+ // @ts-ignore — three is an optional peer dependency
706
+ three = await import('three');
707
+ } catch (err) {
708
+ error_state = true;
709
+ loading = false;
710
+ onerror?.({
711
+ error: new DelightError('Failed to load Three.js. Ensure "three" is installed.'),
712
+ });
713
+ return;
714
+ }
715
+
716
+ const three_module = three;
717
+ if (!three_module) return;
718
+
719
+ const container = canvas_el.parentElement;
720
+ if (!container) return;
721
+
722
+ const width = container.clientWidth;
723
+ const height = container.clientHeight;
724
+ const dpr = embedded
725
+ ? Math.min(window.devicePixelRatio || 1, 1.5)
726
+ : window.devicePixelRatio || 1;
727
+
728
+ // Scene
729
+ scene = new three_module.Scene();
730
+
731
+ // Camera
732
+ camera = new three_module.PerspectiveCamera(current_fov, width / height, 1, 1100);
733
+ camera.position.set(0, 0, 0);
734
+
735
+ // Renderer
736
+ renderer = new three_module.WebGLRenderer({
737
+ canvas: canvas_el,
738
+ antialias: false,
739
+ });
740
+ renderer.setSize(width, height);
741
+ renderer.setPixelRatio(dpr);
742
+
743
+ // Sphere geometry
744
+ geometry = new three_module.SphereGeometry(500, 60, 40);
745
+
746
+ // Load texture
747
+ const loader = new three_module.TextureLoader();
748
+ try {
749
+ texture = await new Promise<ThreeTexture>((resolve, reject) => {
750
+ loader.load(
751
+ src,
752
+ (tex) => resolve(tex),
753
+ undefined,
754
+ (err) => reject(err),
755
+ );
756
+ });
757
+ } catch (err) {
758
+ error_state = true;
759
+ loading = false;
760
+ onerror?.({
761
+ error:
762
+ err instanceof Error ? err : new DelightError('Failed to load panorama image'),
763
+ });
764
+ return;
765
+ }
766
+
767
+ texture.colorSpace = three_module.SRGBColorSpace;
768
+
769
+ // Material
770
+ material = new three_module.MeshBasicMaterial({
771
+ map: texture,
772
+ side: three_module.BackSide,
773
+ });
774
+
775
+ // Mesh
776
+ sphere_mesh = new three_module.Mesh(geometry, material);
777
+ scene.add(sphere_mesh);
778
+
779
+ // Set initial view
780
+ updateCameraRotation();
781
+
782
+ // Start render loop
783
+ renderLoop();
784
+
785
+ // Set up visibility observer
786
+ intersection_observer = new IntersectionObserver(
787
+ (entries) => {
788
+ is_visible = entries[0]?.isIntersecting ?? true;
789
+ },
790
+ { threshold: 0.1 },
791
+ );
792
+ intersection_observer.observe(container);
793
+
794
+ // Resize observer
795
+ const resize_observer = new ResizeObserver(handleResize);
796
+ resize_observer.observe(container);
797
+
798
+ // Fullscreen listener
799
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
800
+
801
+ // Gyroscope
802
+ if (gyroscope) {
803
+ enableGyroscope();
804
+ }
805
+
806
+ loading = false;
807
+ loaded = true;
808
+ onload?.();
809
+
810
+ return () => {
811
+ resize_observer.disconnect();
812
+ };
813
+ }
814
+
815
+ /* ── Lifecycle ────────────────────────────────────────────── */
816
+
817
+ $effect(() => {
818
+ prefers_reduced_motion = checkReducedMotion();
819
+
820
+ const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
821
+ const handler = () => {
822
+ prefers_reduced_motion = mql.matches;
823
+ };
824
+ mql.addEventListener('change', handler);
825
+
826
+ return () => {
827
+ mql.removeEventListener('change', handler);
828
+ };
829
+ });
830
+
831
+ $effect(() => {
832
+ has_webgl = checkWebGL();
833
+ if (!has_webgl) {
834
+ loading = false;
835
+ return;
836
+ }
837
+
838
+ // Track canvas_el
839
+ if (!canvas_el) return;
840
+
841
+ let resize_cleanup: (() => void) | undefined;
842
+
843
+ untrack(() => {
844
+ initialize().then((cleanup) => {
845
+ resize_cleanup = cleanup;
846
+ });
847
+ });
848
+
849
+ return () => {
850
+ // Cleanup
851
+ cancelAnimationFrame(animation_frame_id);
852
+ clearTimeout(auto_rotate_resume_timer);
853
+
854
+ if (gyro_enabled) {
855
+ window.removeEventListener('deviceorientation', handleDeviceOrientation);
856
+ }
857
+
858
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
859
+ intersection_observer?.disconnect();
860
+ resize_cleanup?.();
861
+
862
+ // Dispose Three.js resources
863
+ renderer?.dispose();
864
+ geometry?.dispose();
865
+ material?.dispose();
866
+ texture?.dispose();
867
+
868
+ renderer = undefined;
869
+ scene = undefined;
870
+ camera = undefined;
871
+ sphere_mesh = undefined;
872
+ texture = undefined;
873
+ material = undefined;
874
+ geometry = undefined;
875
+ three = undefined;
876
+ loaded = false;
877
+ };
878
+ });
879
+
880
+ // React to src changes after initial load. Use untrack inside so this
881
+ // effect only re-fires when src itself changes — without it, the read of
882
+ // `loaded` after init becoming true would cause the effect to refire and
883
+ // reload the same texture (visible as a second loading spinner flash).
884
+ $effect(() => {
885
+ void src;
886
+ untrack(() => {
887
+ if (!loaded || !three || !material) return;
888
+
889
+ loading = true;
890
+ error_state = false;
891
+
892
+ const loader = new three!.TextureLoader();
893
+ loader.load(
894
+ src,
895
+ (new_texture) => {
896
+ new_texture.colorSpace = three!.SRGBColorSpace;
897
+ texture?.dispose();
898
+ texture = new_texture;
899
+ material!.map = new_texture;
900
+ material!.needsUpdate = true;
901
+ loading = false;
902
+ onload?.();
903
+ },
904
+ undefined,
905
+ (err) => {
906
+ error_state = true;
907
+ loading = false;
908
+ onerror?.({
909
+ error:
910
+ err instanceof Error
911
+ ? err
912
+ : new DelightError('Failed to load panorama image'),
913
+ });
914
+ },
915
+ );
916
+ });
917
+ });
918
+
919
+ // React to fov prop changes
920
+ $effect(() => {
921
+ void fov;
922
+ if (!camera) return;
923
+ current_fov = fov;
924
+ camera.fov = current_fov;
925
+ camera.updateProjectionMatrix();
926
+ });
927
+ </script>
928
+
929
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
930
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
931
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
932
+ <div
933
+ {id}
934
+ class={['panorama', class_name].filter(Boolean).join(' ')}
935
+ class:dragging={is_dragging}
936
+ class:fullscreen={is_fullscreen}
937
+ bind:this={element}
938
+ role="application"
939
+ aria-label="360 degree panorama viewer"
940
+ tabindex="0"
941
+ onkeydown={handleKeyDown}>
942
+ {#if !has_webgl}
943
+ <!-- WebGL not supported fallback -->
944
+ {#if fallback}
945
+ <img src={fallback} alt="Panorama view" />
946
+ {:else}
947
+ <div class="fallback">
948
+ <svg
949
+ xmlns="http://www.w3.org/2000/svg"
950
+ viewBox="0 0 24 24"
951
+ fill="none"
952
+ stroke="currentColor"
953
+ stroke-width="1.5"
954
+ stroke-linecap="round"
955
+ stroke-linejoin="round"
956
+ aria-hidden="true">
957
+ <circle cx="12" cy="12" r="10" />
958
+ <path d="M2 12h20" />
959
+ <path
960
+ d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
961
+ </svg>
962
+ <span>360 view not supported</span>
963
+ </div>
964
+ {/if}
965
+ {:else if error_state}
966
+ <!-- Error state -->
967
+ {#if fallback}
968
+ <img src={fallback} alt="Panorama view" />
969
+ {:else}
970
+ <div class="fallback">
971
+ <svg
972
+ xmlns="http://www.w3.org/2000/svg"
973
+ viewBox="0 0 24 24"
974
+ fill="none"
975
+ stroke="currentColor"
976
+ stroke-width="1.5"
977
+ stroke-linecap="round"
978
+ stroke-linejoin="round"
979
+ aria-hidden="true">
980
+ <circle cx="12" cy="12" r="10" />
981
+ <line x1="12" y1="8" x2="12" y2="12" />
982
+ <line x1="12" y1="16" x2="12.01" y2="16" />
983
+ </svg>
984
+ <span>Failed to load panorama</span>
985
+ </div>
986
+ {/if}
987
+ {:else}
988
+ <!-- Three.js canvas -->
989
+ <canvas
990
+ bind:this={canvas_el}
991
+ onpointerdown={handlePointerDown}
992
+ onpointermove={handlePointerMove}
993
+ onpointerup={handlePointerUp}
994
+ onpointercancel={handlePointerUp}
995
+ ondblclick={handleDoubleClick}
996
+ onwheel={handleWheel}
997
+ ontouchstart={handleTouchStart}
998
+ ontouchmove={handleTouchMove}
999
+ ontouchend={handleTouchEnd}>
1000
+ </canvas>
1001
+
1002
+ <!-- Loading overlay: kept mounted so we can fade it out after the
1003
+ texture is in place, hiding the dark/empty WebGL canvas during load.
1004
+ With `skeleton`, a shimmer overlay stands in for the spinner — the
1005
+ canvas still mounts underneath so loading proceeds and dismisses it. -->
1006
+ {#if skeleton}
1007
+ <div class="skeleton" class:is-loaded={!loading && loaded}></div>
1008
+ {:else}
1009
+ <div class="loading" class:is-loaded={!loading && loaded}>
1010
+ <div class="spinner"></div>
1011
+ </div>
1012
+ {/if}
1013
+
1014
+ <!-- Hotspots -->
1015
+ {#each hotspots as hotspot, i}
1016
+ {@const pos = hotspot_positions[i]}
1017
+ {#if pos?.visible}
1018
+ <button
1019
+ class="hotspot"
1020
+ style:left="{pos.x}px"
1021
+ style:top="{pos.y}px"
1022
+ type="button"
1023
+ title={hotspot.label}
1024
+ aria-label={hotspot.label ?? `Hotspot ${i + 1}`}
1025
+ onclick={() => onhotspotclick?.({ hotspot })}>
1026
+ <span class="marker">
1027
+ <svg
1028
+ xmlns="http://www.w3.org/2000/svg"
1029
+ viewBox="0 0 24 24"
1030
+ fill="none"
1031
+ stroke="currentColor"
1032
+ stroke-width="2"
1033
+ stroke-linecap="round"
1034
+ stroke-linejoin="round"
1035
+ width="16"
1036
+ height="16"
1037
+ aria-hidden="true">
1038
+ <circle cx="12" cy="12" r="3" />
1039
+ </svg>
1040
+ </span>
1041
+ {#if hotspot.label}
1042
+ <span class="label">{hotspot.label}</span>
1043
+ {/if}
1044
+ </button>
1045
+ {/if}
1046
+ {/each}
1047
+
1048
+ <!-- Controls -->
1049
+ {#if show_controls && !embedded && loaded}
1050
+ <div class="controls">
1051
+ <Button translucent icon size="0" tooltip="Zoom in" onclick={zoomIn}>
1052
+ <svg
1053
+ xmlns="http://www.w3.org/2000/svg"
1054
+ viewBox="0 0 24 24"
1055
+ fill="none"
1056
+ stroke="currentColor"
1057
+ stroke-width="2"
1058
+ stroke-linecap="round"
1059
+ stroke-linejoin="round"
1060
+ aria-hidden="true">
1061
+ <line x1="12" y1="5" x2="12" y2="19" />
1062
+ <line x1="5" y1="12" x2="19" y2="12" />
1063
+ </svg>
1064
+ </Button>
1065
+ <Button translucent icon size="0" tooltip="Zoom out" onclick={zoomOut}>
1066
+ <svg
1067
+ xmlns="http://www.w3.org/2000/svg"
1068
+ viewBox="0 0 24 24"
1069
+ fill="none"
1070
+ stroke="currentColor"
1071
+ stroke-width="2"
1072
+ stroke-linecap="round"
1073
+ stroke-linejoin="round"
1074
+ aria-hidden="true">
1075
+ <line x1="5" y1="12" x2="19" y2="12" />
1076
+ </svg>
1077
+ </Button>
1078
+ <Button
1079
+ translucent
1080
+ icon
1081
+ size="0"
1082
+ tooltip={is_fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
1083
+ onclick={toggleFullscreen}>
1084
+ {#if is_fullscreen}
1085
+ <svg
1086
+ xmlns="http://www.w3.org/2000/svg"
1087
+ viewBox="0 0 24 24"
1088
+ fill="none"
1089
+ stroke="currentColor"
1090
+ stroke-width="2"
1091
+ stroke-linecap="round"
1092
+ stroke-linejoin="round"
1093
+ aria-hidden="true">
1094
+ <polyline points="4 14 8 14 8 18" />
1095
+ <polyline points="20 10 16 10 16 6" />
1096
+ <line x1="14" y1="10" x2="21" y2="3" />
1097
+ <line x1="3" y1="21" x2="10" y2="14" />
1098
+ </svg>
1099
+ {:else}
1100
+ <svg
1101
+ xmlns="http://www.w3.org/2000/svg"
1102
+ viewBox="0 0 24 24"
1103
+ fill="none"
1104
+ stroke="currentColor"
1105
+ stroke-width="2"
1106
+ stroke-linecap="round"
1107
+ stroke-linejoin="round"
1108
+ aria-hidden="true">
1109
+ <polyline points="15 3 21 3 21 9" />
1110
+ <polyline points="9 21 3 21 3 15" />
1111
+ <line x1="21" y1="3" x2="14" y2="10" />
1112
+ <line x1="3" y1="21" x2="10" y2="14" />
1113
+ </svg>
1114
+ {/if}
1115
+ </Button>
1116
+ </div>
1117
+ {/if}
1118
+ {/if}
1119
+ </div>
1120
+
1121
+ <style>
1122
+ .panorama {
1123
+ position: relative;
1124
+ width: 100%;
1125
+ aspect-ratio: 16 / 9;
1126
+ border-radius: var(--radius-md, 0.5rem);
1127
+ @supports (corner-shape: squircle) {
1128
+ corner-shape: squircle;
1129
+ border-radius: calc(var(--radius-md, 0.5rem) * var(--squircle-ratio, 2));
1130
+ }
1131
+ overflow: hidden;
1132
+ background: light-dark(var(--color-surface, #f3f4f6), var(--color-surface, #1f2937));
1133
+ cursor: grab;
1134
+ outline: none;
1135
+
1136
+ &:focus-visible {
1137
+ outline: 2px solid var(--color-action, #3b82f6);
1138
+ outline-offset: 2px;
1139
+ }
1140
+
1141
+ &.dragging {
1142
+ cursor: grabbing;
1143
+ }
1144
+
1145
+ &.fullscreen {
1146
+ border-radius: 0;
1147
+ aspect-ratio: auto;
1148
+ width: 100%;
1149
+ height: 100%;
1150
+ }
1151
+ }
1152
+
1153
+ canvas {
1154
+ display: block;
1155
+ width: 100%;
1156
+ height: 100%;
1157
+ touch-action: none;
1158
+ }
1159
+
1160
+ /* ── Skeleton ─────────────────────────────────────────────── */
1161
+
1162
+ .skeleton {
1163
+ position: absolute;
1164
+ inset: 0;
1165
+ overflow: hidden;
1166
+ /* Translucent skeleton tint over an opaque base — the WebGL canvas
1167
+ underneath clears to black and must not show through. */
1168
+ background:
1169
+ linear-gradient(
1170
+ var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1)),
1171
+ var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1))
1172
+ ),
1173
+ light-dark(var(--color-bg, #fff), var(--color-bg, #111));
1174
+ opacity: 1;
1175
+ transition:
1176
+ opacity 220ms ease,
1177
+ visibility 0s linear 0s;
1178
+
1179
+ &.is-loaded {
1180
+ opacity: 0;
1181
+ visibility: hidden;
1182
+ pointer-events: none;
1183
+ transition:
1184
+ opacity 220ms ease,
1185
+ visibility 0s linear 220ms;
1186
+ }
1187
+
1188
+ &::after {
1189
+ content: '';
1190
+ position: absolute;
1191
+ inset: 0;
1192
+ transform: translateX(-100%);
1193
+ background-image: linear-gradient(
1194
+ 105deg,
1195
+ transparent 25%,
1196
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1197
+ transparent 75%
1198
+ );
1199
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1200
+ infinite;
1201
+ }
1202
+ }
1203
+
1204
+ @keyframes -global-delight-skeleton-shimmer {
1205
+ 0% {
1206
+ transform: translateX(-100%);
1207
+ }
1208
+ 55%,
1209
+ 100% {
1210
+ transform: translateX(100%);
1211
+ }
1212
+ }
1213
+
1214
+ /* ── Loading ──────────────────────────────────────────────── */
1215
+
1216
+ .loading {
1217
+ position: absolute;
1218
+ inset: 0;
1219
+ display: flex;
1220
+ align-items: center;
1221
+ justify-content: center;
1222
+ /* Opaque while loading so the empty WebGL canvas (which clears to
1223
+ black) doesn't show through and make the panorama look "dark"
1224
+ the first time it appears. */
1225
+ background: light-dark(var(--color-bg, #fff), var(--color-bg, #111));
1226
+ opacity: 1;
1227
+ transition:
1228
+ opacity 220ms ease,
1229
+ visibility 0s linear 0s;
1230
+
1231
+ &.is-loaded {
1232
+ opacity: 0;
1233
+ visibility: hidden;
1234
+ pointer-events: none;
1235
+ transition:
1236
+ opacity 220ms ease,
1237
+ visibility 0s linear 220ms;
1238
+ }
1239
+ }
1240
+
1241
+ .spinner {
1242
+ width: 32px;
1243
+ height: 32px;
1244
+ border: 3px solid var(--color-border, #d1d5db);
1245
+ border-top-color: var(--color-action, #3b82f6);
1246
+ border-radius: 50%;
1247
+ animation: spin 0.8s linear infinite;
1248
+ }
1249
+
1250
+ @keyframes spin {
1251
+ to {
1252
+ transform: rotate(360deg);
1253
+ }
1254
+ }
1255
+
1256
+ /* ── Fallback ─────────────────────────────────────────────── */
1257
+
1258
+ .fallback {
1259
+ position: absolute;
1260
+ inset: 0;
1261
+ display: flex;
1262
+ flex-direction: column;
1263
+ align-items: center;
1264
+ justify-content: center;
1265
+ gap: 0.75rem;
1266
+ background-color: var(--color-bg-muted, rgba(128, 128, 128, 0.1));
1267
+ color: var(--color-text-muted, rgba(128, 128, 128, 0.6));
1268
+ font-size: 14px;
1269
+
1270
+ svg {
1271
+ width: 3rem;
1272
+ height: 3rem;
1273
+ opacity: 0.5;
1274
+ }
1275
+ }
1276
+
1277
+ img {
1278
+ display: block;
1279
+ width: 100%;
1280
+ height: 100%;
1281
+ object-fit: cover;
1282
+ }
1283
+
1284
+ /* ── Controls ─────────────────────────────────────────────── */
1285
+
1286
+ .controls {
1287
+ position: absolute;
1288
+ bottom: 1rem;
1289
+ right: 1rem;
1290
+ display: flex;
1291
+ flex-direction: column;
1292
+ gap: 0.5rem;
1293
+ z-index: 2;
1294
+ :global(.button) {
1295
+ --color-bg-active: rgb(0 0 0 / 0.55);
1296
+ --color-bg: rgb(0 0 0 / 0.35);
1297
+ --color-text: rgb(255 255 255 / 0.8);
1298
+ --color-text-active: rgb(255 255 255 / 1);
1299
+ --button-border: transparent;
1300
+ --button-border-active: transparent;
1301
+ }
1302
+ }
1303
+
1304
+ /* ── Hotspots ─────────────────────────────────────────────── */
1305
+
1306
+ .hotspot {
1307
+ position: absolute;
1308
+ transform: translate(-50%, -50%);
1309
+ cursor: pointer;
1310
+ z-index: 1;
1311
+ background: none;
1312
+ border: none;
1313
+ padding: 0;
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ align-items: center;
1317
+ gap: 4px;
1318
+
1319
+ &:focus-visible {
1320
+ outline: 2px solid var(--color-action, #3b82f6);
1321
+ outline-offset: 4px;
1322
+ border-radius: var(--radius-full, 9999px);
1323
+ }
1324
+
1325
+ .marker {
1326
+ width: 32px;
1327
+ height: 32px;
1328
+ border-radius: var(--radius-full, 9999px);
1329
+ background: var(--color-action, #3b82f6);
1330
+ color: var(--color-action-text, #fff);
1331
+ display: flex;
1332
+ align-items: center;
1333
+ justify-content: center;
1334
+ animation: pulse 2s infinite;
1335
+ transition: transform 0.15s ease;
1336
+ }
1337
+
1338
+ &:hover .marker {
1339
+ transform: scale(1.15);
1340
+ }
1341
+
1342
+ .label {
1343
+ font-size: 12px;
1344
+ font-weight: 500;
1345
+ color: var(--color-text, inherit);
1346
+ background: light-dark(
1347
+ color-mix(in oklch, var(--color-bg, #fff) 90%, transparent),
1348
+ color-mix(in oklch, var(--color-bg, #1f2937) 80%, transparent)
1349
+ );
1350
+ backdrop-filter: blur(4px);
1351
+ padding: 2px 8px;
1352
+ border-radius: var(--radius-sm, 0.25rem);
1353
+ @supports (corner-shape: squircle) {
1354
+ corner-shape: squircle;
1355
+ border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
1356
+ }
1357
+ white-space: nowrap;
1358
+ pointer-events: none;
1359
+ }
1360
+ }
1361
+
1362
+ @keyframes pulse {
1363
+ 0%,
1364
+ 100% {
1365
+ box-shadow: 0 0 0 0
1366
+ color-mix(in oklch, var(--color-action, #3b82f6) 40%, transparent);
1367
+ }
1368
+ 50% {
1369
+ box-shadow: 0 0 0 8px
1370
+ color-mix(in oklch, var(--color-action, #3b82f6) 0%, transparent);
1371
+ }
1372
+ }
1373
+
1374
+ /* ── Reduced motion ───────────────────────────────────────── */
1375
+
1376
+ @media (prefers-reduced-motion: reduce) {
1377
+ .skeleton::after {
1378
+ animation: none;
1379
+ }
1380
+
1381
+ .spinner {
1382
+ animation: none;
1383
+ border-top-color: var(--color-border, #d1d5db);
1384
+ opacity: 0.5;
1385
+ }
1386
+
1387
+ .hotspot .marker {
1388
+ animation: none;
1389
+ }
1390
+ }
1391
+ </style>