@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- 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>
|