@found-in-space/skykit 0.2.0-alpha.1 → 0.2.0-dev.20260527.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 (42) hide show
  1. package/README.md +143 -6
  2. package/examples/custom-object-layer/custom-object-layer.js +1 -24
  3. package/examples/xr-free-roam/index.html +62 -4
  4. package/examples/xr-free-roam/xr-free-roam.css +249 -18
  5. package/examples/xr-free-roam/xr-free-roam.js +644 -217
  6. package/package.json +31 -5
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +217 -0
  9. package/src/__tests__/skykit-data.test.js +131 -0
  10. package/src/__tests__/skykit-parallax.test.js +4 -4
  11. package/src/__tests__/skykit-touch-os.test.js +71 -0
  12. package/src/__tests__/skykit-xr.test.js +123 -2
  13. package/src/__tests__/skykit.test.js +138 -1
  14. package/src/anchored-images.js +14 -15
  15. package/src/browser-addons.d.ts +16 -0
  16. package/src/browser-addons.js +155 -0
  17. package/src/browser-constellations.d.ts +13 -0
  18. package/src/browser-constellations.js +387 -0
  19. package/src/browser-journey.d.ts +8 -0
  20. package/src/browser-journey.js +240 -0
  21. package/src/browser.d.ts +98 -0
  22. package/src/browser.js +215 -13
  23. package/src/data.d.ts +133 -0
  24. package/src/data.js +447 -0
  25. package/src/embed.d.ts +5 -0
  26. package/src/embed.js +52 -2
  27. package/src/hr-diagram.js +23 -5
  28. package/src/index.d.ts +32 -7
  29. package/src/plugins.js +87 -43
  30. package/src/story.d.ts +57 -0
  31. package/src/story.js +396 -0
  32. package/src/three-shim.d.ts +32 -0
  33. package/src/touch-os.d.ts +70 -0
  34. package/src/touch-os.js +275 -0
  35. package/src/utils.js +96 -6
  36. package/src/viewer-entry.d.ts +10 -0
  37. package/src/viewer-entry.js +4 -0
  38. package/src/viewer.js +110 -12
  39. package/src/xr/plugins.js +224 -13
  40. package/src/xr/session.js +60 -14
  41. package/src/xr.d.ts +22 -0
  42. package/src/xr.js +1 -0
@@ -0,0 +1,387 @@
1
+ import * as THREE from 'three';
2
+
3
+ import { raDecToIcrsDirection } from '@found-in-space/spatial';
4
+
5
+ import {
6
+ createAnchoredImageCatalog,
7
+ createAnchoredImageSkyPlugin,
8
+ createViewAnchoredImageController,
9
+ } from './anchored-images.js';
10
+ import { createObject3dLayer } from './layers.js';
11
+
12
+ const CONSTELLATIONS_CAPABILITY = 'skykit:browser.constellations';
13
+ const WESTERN_SKYCULTURE_VERSION = '0.3.0';
14
+ const WESTERN_SKYCULTURE_BASE =
15
+ `https://cdn.jsdelivr.net/npm/@found-in-space/stellarium-skycultures-western@${WESTERN_SKYCULTURE_VERSION}/dist/`;
16
+ const WESTERN_SKYCULTURE_MANIFEST = `${WESTERN_SKYCULTURE_BASE}manifest.json`;
17
+ const DEFAULT_BOUNDARY_RADIUS = 8;
18
+
19
+ /**
20
+ * @param {{
21
+ * browser: import('./browser.d.ts').SkykitBrowser;
22
+ * host?: unknown;
23
+ * options?: import('./browser.d.ts').SkykitBrowserConstellationsOptions;
24
+ * }} context
25
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowserConstellationsFacade>}
26
+ */
27
+ export async function installSkykitConstellationsBrowserCapability({ browser, host, options = {} }) {
28
+ const settings = normalizeConstellationOptions(host, options);
29
+ const loaded = await loadSkycultureManifest(settings);
30
+ const manifest = loaded.manifest;
31
+ const assetBaseUrl = settings.assetBaseUrl ?? loaded.assetBaseUrl;
32
+ const root = createConstellationBoundaryObject(manifest, settings);
33
+ let visible = settings.visible !== false;
34
+ let artMode = normalizeArtMode(settings.art);
35
+ /** @type {import('./index.d.ts').SkykitPluginTeardown | null} */
36
+ let boundaryTeardown = null;
37
+ /** @type {import('./index.d.ts').SkykitPluginTeardown | null} */
38
+ let artTeardown = null;
39
+ /** @type {import('./index.d.ts').AnchoredImageSkyPlugin | null} */
40
+ let artPlugin = null;
41
+
42
+ root.visible = visible;
43
+ boundaryTeardown = await browser.install({
44
+ id: 'constellation-boundaries',
45
+ setup(context) {
46
+ const layer = createObject3dLayer({
47
+ id: 'constellation-boundaries',
48
+ object3d: root,
49
+ anchorMode: 'observer-centric',
50
+ priority: settings.priority,
51
+ });
52
+ const remove = context.addPart({
53
+ ...layer,
54
+ dispose() {
55
+ layer.dispose?.();
56
+ disposeObject(root);
57
+ },
58
+ getSnapshot() {
59
+ return {
60
+ id: 'constellation-boundaries',
61
+ visible: root.visible,
62
+ lineCount: root.userData.lineCount ?? 0,
63
+ };
64
+ },
65
+ });
66
+ return remove;
67
+ },
68
+ });
69
+ browser.capabilities.add(CONSTELLATIONS_CAPABILITY);
70
+
71
+ if (artMode !== 'off') {
72
+ await installArt();
73
+ }
74
+
75
+ const handle = {
76
+ async load() {
77
+ return handle;
78
+ },
79
+ show() {
80
+ visible = true;
81
+ syncVisibility(browser, root, visible);
82
+ return visible;
83
+ },
84
+ hide() {
85
+ visible = false;
86
+ syncVisibility(browser, root, visible);
87
+ return visible;
88
+ },
89
+ toggle(force) {
90
+ visible = typeof force === 'boolean' ? force : !visible;
91
+ syncVisibility(browser, root, visible);
92
+ return visible;
93
+ },
94
+ async setArt(mode) {
95
+ artMode = normalizeArtMode(mode);
96
+ if (artMode === 'off') {
97
+ artTeardown?.();
98
+ artTeardown = null;
99
+ artPlugin = null;
100
+ return artMode;
101
+ }
102
+ await installArt();
103
+ return artMode;
104
+ },
105
+ getSnapshot() {
106
+ return {
107
+ capability: CONSTELLATIONS_CAPABILITY,
108
+ visible,
109
+ art: artMode,
110
+ manifestId: manifest.id ?? null,
111
+ lineCount: root.userData.lineCount ?? 0,
112
+ artPlugin: artPlugin?.getSnapshot?.() ?? null,
113
+ };
114
+ },
115
+ dispose() {
116
+ artTeardown?.();
117
+ boundaryTeardown?.();
118
+ artTeardown = null;
119
+ boundaryTeardown = null;
120
+ artPlugin = null;
121
+ },
122
+ };
123
+
124
+ return handle;
125
+
126
+ async function installArt() {
127
+ if (artPlugin) return;
128
+ const catalog = await createAnchoredImageCatalog({
129
+ manifest: toAnchoredImageManifest(manifest, assetBaseUrl),
130
+ });
131
+ artPlugin = createAnchoredImageSkyPlugin({
132
+ id: 'constellation-art',
133
+ catalog,
134
+ controller: createViewAnchoredImageController({
135
+ strategy: 'nearest',
136
+ maxAngleDeg: finiteNumber(settings.artMaxAngleDeg, 60),
137
+ }),
138
+ loading: artMode === 'preload' ? 'preload' : 'lazy',
139
+ opacity: finiteNumber(settings.artOpacity, 0.22),
140
+ skipTextureErrors: settings.skipTextureErrors !== false,
141
+ });
142
+ artTeardown = await browser.install(artPlugin);
143
+ syncVisibility(browser, root, visible);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {unknown} host
149
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
150
+ * @returns {import('./browser.d.ts').SkykitBrowserConstellationsOptions}
151
+ */
152
+ function normalizeConstellationOptions(host, options) {
153
+ const data = host && typeof host === 'object' && 'dataset' in host
154
+ ? /** @type {{ dataset?: Record<string, string | undefined> }} */ (host).dataset ?? {}
155
+ : {};
156
+ const requested = options.skyculture ?? data.skykitConstellations ?? 'western';
157
+ const manifestUrl = options.manifestUrl
158
+ ?? data.skykitConstellationManifest
159
+ ?? (looksLikeUrl(requested) ? String(requested) : undefined)
160
+ ?? (String(requested || 'western').toLowerCase() === 'western' ? WESTERN_SKYCULTURE_MANIFEST : undefined);
161
+ const assetBaseUrl = options.assetBaseUrl
162
+ ?? data.skykitConstellationAssets
163
+ ?? (manifestUrl ? new URL('./', manifestUrl).href : undefined)
164
+ ?? WESTERN_SKYCULTURE_BASE;
165
+ return {
166
+ ...options,
167
+ skyculture: requested,
168
+ manifestUrl,
169
+ assetBaseUrl,
170
+ art: options.art ?? data.skykitConstellationArt ?? 'off',
171
+ };
172
+ }
173
+
174
+ /**
175
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
176
+ * @returns {Promise<{ manifest: Record<string, unknown>; assetBaseUrl?: string }>}
177
+ */
178
+ async function loadSkycultureManifest(options) {
179
+ if (options.manifest && typeof options.manifest === 'object') {
180
+ return {
181
+ manifest: /** @type {Record<string, unknown>} */ (options.manifest),
182
+ assetBaseUrl: options.assetBaseUrl,
183
+ };
184
+ }
185
+ const manifestUrl = options.manifestUrl ?? WESTERN_SKYCULTURE_MANIFEST;
186
+ const response = await fetch(manifestUrl);
187
+ if (!response.ok) throw new Error(`Failed to load SkyKit constellation manifest: ${response.status} ${response.statusText}`);
188
+ return {
189
+ manifest: /** @type {Record<string, unknown>} */ (await response.json()),
190
+ assetBaseUrl: options.assetBaseUrl ?? new URL('./', manifestUrl).href,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {Record<string, unknown>} manifest
196
+ * @param {import('./browser.d.ts').SkykitBrowserConstellationsOptions} options
197
+ */
198
+ function createConstellationBoundaryObject(manifest, options) {
199
+ const root = new THREE.Group();
200
+ root.name = 'constellation-boundaries';
201
+ const radius = finiteNumber(options.boundaryRadius, DEFAULT_BOUNDARY_RADIUS);
202
+ const positions = boundaryPositions(manifest, radius);
203
+ const geometry = new THREE.BufferGeometry();
204
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
205
+ const material = new THREE.LineBasicMaterial({
206
+ color: options.boundaryColor ?? 0x7aa7ff,
207
+ transparent: true,
208
+ opacity: finiteNumber(options.boundaryOpacity, 0.35),
209
+ depthWrite: false,
210
+ });
211
+ const lines = new THREE.LineSegments(geometry, material);
212
+ lines.name = 'constellation-boundary-lines';
213
+ lines.renderOrder = finiteNumber(options.renderOrder, -2);
214
+ root.add(lines);
215
+ root.userData.lineCount = positions.length / 6;
216
+ return root;
217
+ }
218
+
219
+ /** @param {Record<string, unknown>} manifest @param {number} radius */
220
+ function boundaryPositions(manifest, radius) {
221
+ const boundaries = /** @type {{ edges?: unknown }} */ (manifest.boundaries ?? {});
222
+ const edges = Array.isArray(boundaries.edges) ? boundaries.edges : [];
223
+ /** @type {number[]} */
224
+ const positions = [];
225
+ for (const edge of edges) {
226
+ if (typeof edge !== 'string') continue;
227
+ const parts = edge.trim().split(/\s+/);
228
+ if (parts.length < 6) continue;
229
+ const start = directionFromRaDecText(parts[2], parts[3]);
230
+ const end = directionFromRaDecText(parts[4], parts[5]);
231
+ if (!start || !end) continue;
232
+ positions.push(
233
+ start.x * radius, start.y * radius, start.z * radius,
234
+ end.x * radius, end.y * radius, end.z * radius,
235
+ );
236
+ }
237
+ return positions;
238
+ }
239
+
240
+ /** @param {string} raText @param {string} decText */
241
+ function directionFromRaDecText(raText, decText) {
242
+ const raHours = parseSexagesimal(raText);
243
+ const decDeg = parseSexagesimal(decText);
244
+ if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) return null;
245
+ return raDecToIcrsDirection({ raHours, decDeg });
246
+ }
247
+
248
+ /** @param {string} value */
249
+ function parseSexagesimal(value) {
250
+ const sign = value.trim().startsWith('-') ? -1 : 1;
251
+ const clean = value.trim().replace(/^[+-]/, '');
252
+ const parts = clean.split(':').map(Number);
253
+ if (parts.some((part) => !Number.isFinite(part))) return Number.NaN;
254
+ return sign * (parts[0] + (parts[1] ?? 0) / 60 + (parts[2] ?? 0) / 3600);
255
+ }
256
+
257
+ /** @param {Record<string, unknown>} manifest @param {string | undefined} assetBaseUrl */
258
+ function toAnchoredImageManifest(manifest, assetBaseUrl) {
259
+ const constellations = Array.isArray(manifest.constellations) ? manifest.constellations : [];
260
+ return {
261
+ format: 'found-in-space/anchored-image-manifest@1',
262
+ id: String(manifest.id ?? 'constellations'),
263
+ label: String(manifest.id ?? 'Constellations'),
264
+ assetBaseUrl: assetBaseUrl ?? null,
265
+ images: constellations.map((constellation, index) => toAnchoredImage(constellation, assetBaseUrl, index)).filter(Boolean),
266
+ attribution: manifest.license,
267
+ metadata: {
268
+ sourceFormat: manifest.format,
269
+ sourcePackage: manifest.source,
270
+ astrometry: manifest.astrometry,
271
+ },
272
+ };
273
+ }
274
+
275
+ /** @param {unknown} input @param {string | undefined} assetBaseUrl @param {number} index */
276
+ function toAnchoredImage(input, assetBaseUrl, index) {
277
+ const constellation = /** @type {Record<string, unknown>} */ (input && typeof input === 'object' ? input : {});
278
+ const image = /** @type {Record<string, unknown>} */ (constellation.image && typeof constellation.image === 'object' ? constellation.image : {});
279
+ const imageFile = typeof image.file === 'string' ? image.file : '';
280
+ const anchors = Array.isArray(image.anchors) ? image.anchors.map(toAnchoredImageAnchor).filter(Boolean) : [];
281
+ if (!imageFile || anchors.length < 2) return null;
282
+ return {
283
+ id: String(constellation.id ?? constellation.iau ?? `constellation-${index}`),
284
+ label: resolveConstellationLabel(constellation),
285
+ groupId: typeof constellation.iau === 'string' ? constellation.iau : undefined,
286
+ image: {
287
+ src: typeof image.url === 'string'
288
+ ? image.url
289
+ : assetBaseUrl
290
+ ? new URL(imageFile, assetBaseUrl).href
291
+ : imageFile,
292
+ width: Array.isArray(image.size) ? Number(image.size[0]) || 512 : 512,
293
+ height: Array.isArray(image.size) ? Number(image.size[1]) || 512 : 512,
294
+ anchors,
295
+ },
296
+ metadata: {
297
+ sourceConstellationId: constellation.id ?? null,
298
+ iau: constellation.iau ?? null,
299
+ common_name: constellation.common_name ?? null,
300
+ },
301
+ };
302
+ }
303
+
304
+ /** @param {unknown} input */
305
+ function toAnchoredImageAnchor(input) {
306
+ const anchor = /** @type {Record<string, unknown>} */ (input && typeof input === 'object' ? input : {});
307
+ const icrs = /** @type {Record<string, unknown>} */ (anchor.icrs && typeof anchor.icrs === 'object' ? anchor.icrs : {});
308
+ const pixel = /** @type {Record<string, unknown>} */ (anchor.pixel && typeof anchor.pixel === 'object' ? anchor.pixel : {});
309
+ const target = {
310
+ x: Number(icrs.x),
311
+ y: Number(icrs.y),
312
+ z: Number(icrs.z),
313
+ };
314
+ const point = {
315
+ x: Number(pixel.x),
316
+ y: Number(pixel.y),
317
+ };
318
+ if (!Number.isFinite(target.x) || !Number.isFinite(target.y) || !Number.isFinite(target.z)) return null;
319
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
320
+ return {
321
+ pixel: point,
322
+ target: {
323
+ kind: 'direction',
324
+ frame: 'icrs',
325
+ ...target,
326
+ },
327
+ metadata: {
328
+ hip: anchor.hip,
329
+ raDeg: anchor.raDeg,
330
+ decDeg: anchor.decDeg,
331
+ },
332
+ };
333
+ }
334
+
335
+ /** @param {Record<string, unknown>} constellation */
336
+ function resolveConstellationLabel(constellation) {
337
+ const commonName = constellation.common_name;
338
+ if (commonName && typeof commonName === 'object') {
339
+ const source = /** @type {Record<string, unknown>} */ (commonName);
340
+ if (typeof source.native === 'string' && source.native.trim()) return source.native.trim();
341
+ if (typeof source.english === 'string' && source.english.trim()) return source.english.trim();
342
+ }
343
+ return String(constellation.iau ?? constellation.id ?? 'Constellation');
344
+ }
345
+
346
+ /**
347
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
348
+ * @param {THREE.Object3D} boundaryRoot
349
+ * @param {boolean} visible
350
+ */
351
+ function syncVisibility(browser, boundaryRoot, visible) {
352
+ boundaryRoot.visible = visible;
353
+ const artRoot = browser.viewer.roots.observerContentRoot.children.find((child) => child.name === 'constellation-art');
354
+ if (artRoot) artRoot.visible = visible;
355
+ }
356
+
357
+ /** @param {unknown} value */
358
+ function normalizeArtMode(value) {
359
+ const mode = String(value ?? 'off').trim().toLowerCase();
360
+ if (mode === 'preload') return 'preload';
361
+ if (mode === 'lazy' || mode === 'on' || mode === 'true') return 'lazy';
362
+ return 'off';
363
+ }
364
+
365
+ /** @param {unknown} value */
366
+ function looksLikeUrl(value) {
367
+ return typeof value === 'string' && (/^https?:\/\//.test(value) || value.endsWith('.json'));
368
+ }
369
+
370
+ /** @param {unknown} value @param {number} fallback */
371
+ function finiteNumber(value, fallback) {
372
+ const number = Number(value);
373
+ return Number.isFinite(number) ? number : fallback;
374
+ }
375
+
376
+ /** @param {THREE.Object3D} object */
377
+ function disposeObject(object) {
378
+ object.traverse((child) => {
379
+ const mesh = /** @type {THREE.Object3D & { geometry?: { dispose?: () => void }; material?: { dispose?: () => void } | Array<{ dispose?: () => void }> }} */ (child);
380
+ mesh.geometry?.dispose?.();
381
+ if (Array.isArray(mesh.material)) {
382
+ for (const material of mesh.material) material.dispose?.();
383
+ } else {
384
+ mesh.material?.dispose?.();
385
+ }
386
+ });
387
+ }
@@ -0,0 +1,8 @@
1
+ import type {
2
+ SkykitBrowser,
3
+ SkykitBrowserJourneyFacade,
4
+ } from './browser.js';
5
+
6
+ export declare function installSkykitJourneyBrowserCapability(
7
+ context: { browser: SkykitBrowser }
8
+ ): Promise<SkykitBrowserJourneyFacade>;
@@ -0,0 +1,240 @@
1
+ import { createJourney } from '@found-in-space/journey';
2
+ import { parseSpatialLookAtText } from '@found-in-space/spatial';
3
+
4
+ import { SKYKIT_ACTIONS } from './actions.js';
5
+ import {
6
+ createSkykitJourneyPlugin,
7
+ createSkykitNavigationPlugin,
8
+ } from './plugins.js';
9
+
10
+ const NAVIGATION_CAPABILITY = 'skykit:navigation';
11
+ const JOURNEY_CAPABILITY = 'skykit:browser.journey';
12
+ const SOURCE = 'browser.journey';
13
+
14
+ /**
15
+ * @param {{ browser: import('./browser.d.ts').SkykitBrowser }} context
16
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowserJourneyFacade>}
17
+ */
18
+ export async function installSkykitJourneyBrowserCapability({ browser }) {
19
+ browser.capabilities.add(JOURNEY_CAPABILITY);
20
+
21
+ return {
22
+ transitionTo(viewOrScene, options) {
23
+ return transitionTo(browser, viewOrScene, options);
24
+ },
25
+ applyScene(sceneSpec) {
26
+ return applyScene(browser, sceneSpec);
27
+ },
28
+ load(input, options) {
29
+ return loadJourney(browser, input, options);
30
+ },
31
+ getSnapshot() {
32
+ return {
33
+ capability: JOURNEY_CAPABILITY,
34
+ navigationInstalled: browser.capabilities.has(NAVIGATION_CAPABILITY),
35
+ };
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
42
+ */
43
+ async function ensureNavigation(browser) {
44
+ if (browser.capabilities.has(NAVIGATION_CAPABILITY)) return;
45
+ await browser.install(createSkykitNavigationPlugin());
46
+ browser.capabilities.add(NAVIGATION_CAPABILITY);
47
+ }
48
+
49
+ /**
50
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
51
+ * @param {unknown} viewOrScene
52
+ * @param {Record<string, unknown>} [options]
53
+ */
54
+ async function transitionTo(browser, viewOrScene, options = {}) {
55
+ await ensureNavigation(browser);
56
+ const payload = normalizeTransitionPayload(viewOrScene, options);
57
+ return browser.viewer.actions.invoke(SKYKIT_ACTIONS.navigation.transitionTo, payload, { source: SOURCE });
58
+ }
59
+
60
+ /**
61
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
62
+ * @param {unknown} sceneSpec
63
+ */
64
+ async function applyScene(browser, sceneSpec) {
65
+ const scene = /** @type {Record<string, unknown> | null} */ (
66
+ sceneSpec && typeof sceneSpec === 'object' ? sceneSpec : null
67
+ );
68
+ if (!scene) return null;
69
+ if (scene.view && typeof scene.view === 'object') {
70
+ browser.viewer.requestViewState(normalizeView(/** @type {Record<string, unknown>} */ (scene.view)), SOURCE);
71
+ }
72
+ const navigation = /** @type {Record<string, unknown> | null} */ (
73
+ scene.navigation && typeof scene.navigation === 'object' ? scene.navigation : null
74
+ );
75
+ if (navigation?.transitionTo) {
76
+ return transitionTo(browser, navigation.transitionTo);
77
+ }
78
+ return scene;
79
+ }
80
+
81
+ /**
82
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
83
+ * @param {unknown} input
84
+ * @param {Record<string, unknown>} [options]
85
+ * @returns {Promise<import('./browser.d.ts').SkykitBrowserJourneyInstance>}
86
+ */
87
+ async function loadJourney(browser, input, options = {}) {
88
+ await ensureNavigation(browser);
89
+ const definition = await loadJourneyDefinition(input);
90
+ const plugin = isTimedJourney(definition)
91
+ ? createSkykitJourneyPlugin({
92
+ ...options,
93
+ timedJourney: definition,
94
+ })
95
+ : createSkykitJourneyPlugin({
96
+ ...options,
97
+ journey: createJourney(/** @type {import('@found-in-space/journey').CreateJourneyOptions} */ (definition)),
98
+ });
99
+ const uninstall = await browser.install(plugin);
100
+ let disposed = false;
101
+
102
+ return {
103
+ goTo(sceneId) {
104
+ assertActive();
105
+ return plugin.goTo?.(sceneId) ?? Promise.resolve(null);
106
+ },
107
+ next() {
108
+ assertActive();
109
+ return plugin.next?.() ?? Promise.resolve(null);
110
+ },
111
+ previous() {
112
+ assertActive();
113
+ return plugin.previous?.() ?? Promise.resolve(null);
114
+ },
115
+ play(payload) {
116
+ assertActive();
117
+ return plugin.play?.(payload) ?? 0;
118
+ },
119
+ pause() {
120
+ assertActive();
121
+ return plugin.pause?.() ?? 0;
122
+ },
123
+ seek(timeSecs) {
124
+ assertActive();
125
+ return plugin.seek?.({ timeSecs }) ?? 0;
126
+ },
127
+ getSnapshot() {
128
+ return {
129
+ disposed,
130
+ plugin: plugin.getSnapshot?.() ?? null,
131
+ };
132
+ },
133
+ dispose() {
134
+ if (disposed) return;
135
+ disposed = true;
136
+ uninstall();
137
+ },
138
+ };
139
+
140
+ function assertActive() {
141
+ if (disposed) throw new Error('SkyKit journey instance has been disposed.');
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param {unknown} input
147
+ * @returns {Promise<Record<string, unknown>>}
148
+ */
149
+ async function loadJourneyDefinition(input) {
150
+ if (typeof input === 'string') {
151
+ const response = await fetch(input);
152
+ if (!response.ok) throw new Error(`Failed to load SkyKit journey: ${response.status} ${response.statusText}`);
153
+ return /** @type {Record<string, unknown>} */ (await response.json());
154
+ }
155
+ if (!input || typeof input !== 'object') {
156
+ throw new TypeError('browser.journey.load() requires a URL or journey definition.');
157
+ }
158
+ return /** @type {Record<string, unknown>} */ (input);
159
+ }
160
+
161
+ /** @param {Record<string, unknown>} definition */
162
+ function isTimedJourney(definition) {
163
+ return Number.isFinite(Number(definition.durationSecs))
164
+ && (Array.isArray(definition.locationWaypoints)
165
+ || Array.isArray(definition.cameraLookWaypoints)
166
+ || Array.isArray(definition.cameraWaypoints));
167
+ }
168
+
169
+ /**
170
+ * @param {unknown} input
171
+ * @param {Record<string, unknown>} [options]
172
+ */
173
+ function normalizeTransitionPayload(input, options = {}) {
174
+ const source = /** @type {Record<string, unknown>} */ (
175
+ input && typeof input === 'object' ? input : { lookAt: input }
176
+ );
177
+ if (source.navigation && typeof source.navigation === 'object') {
178
+ const navigation = /** @type {Record<string, unknown>} */ (source.navigation);
179
+ if (navigation.transitionTo) return normalizeTransitionPayload(navigation.transitionTo, options);
180
+ }
181
+ const viewSource = source.view && typeof source.view === 'object'
182
+ ? /** @type {Record<string, unknown>} */ (source.view)
183
+ : source.to && typeof source.to === 'object'
184
+ ? /** @type {Record<string, unknown>} */ (source.to)
185
+ : stripTransitionOptions(source);
186
+ return {
187
+ ...stripUndefined({
188
+ ...source,
189
+ ...options,
190
+ view: normalizeView(viewSource),
191
+ durationSecs: options.durationSecs ?? source.durationSecs,
192
+ }),
193
+ };
194
+ }
195
+
196
+ /** @param {Record<string, unknown>} input */
197
+ function normalizeView(input) {
198
+ const lookAt = input.lookAt ?? (
199
+ hasLookAtShape(input) ? input : undefined
200
+ );
201
+ return {
202
+ ...input,
203
+ ...(lookAt !== undefined ? { lookAt: normalizeLookAt(lookAt) } : {}),
204
+ };
205
+ }
206
+
207
+ /** @param {unknown} input */
208
+ function normalizeLookAt(input) {
209
+ if (typeof input !== 'string') return input;
210
+ return parseSpatialLookAtText(input) ?? { star: input };
211
+ }
212
+
213
+ /** @param {Record<string, unknown>} input */
214
+ function stripTransitionOptions(input) {
215
+ const {
216
+ durationSecs: _durationSecs,
217
+ movement: _movement,
218
+ orientation: _orientation,
219
+ orientationDurationSecs: _orientationDurationSecs,
220
+ movementDurationSecs: _movementDurationSecs,
221
+ onArrive: _onArrive,
222
+ ...view
223
+ } = input;
224
+ return view;
225
+ }
226
+
227
+ /** @param {Record<string, unknown>} input */
228
+ function hasLookAtShape(input) {
229
+ return 'raDeg' in input
230
+ || 'raHours' in input
231
+ || 'decDeg' in input
232
+ || 'targetPc' in input
233
+ || 'orientationIcrs' in input
234
+ || 'star' in input;
235
+ }
236
+
237
+ /** @param {Record<string, unknown>} input */
238
+ function stripUndefined(input) {
239
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
240
+ }