@found-in-space/skykit 0.2.0-alpha.0 → 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.
- package/README.md +223 -8
- package/examples/custom-object-layer/custom-object-layer.js +1 -24
- package/examples/xr-free-roam/index.html +62 -4
- package/examples/xr-free-roam/xr-free-roam.css +249 -18
- package/examples/xr-free-roam/xr-free-roam.js +644 -217
- package/package.json +46 -5
- package/src/__tests__/skykit-anchored-images.test.js +32 -4
- package/src/__tests__/skykit-browser.test.js +442 -0
- package/src/__tests__/skykit-data.test.js +131 -0
- package/src/__tests__/skykit-parallax.test.js +4 -4
- package/src/__tests__/skykit-touch-os.test.js +71 -0
- package/src/__tests__/skykit-xr.test.js +123 -2
- package/src/__tests__/skykit.test.js +138 -1
- package/src/anchored-images.js +14 -15
- package/src/browser-addons.d.ts +16 -0
- package/src/browser-addons.js +155 -0
- package/src/browser-constellations.d.ts +13 -0
- package/src/browser-constellations.js +387 -0
- package/src/browser-journey.d.ts +8 -0
- package/src/browser-journey.js +240 -0
- package/src/browser.d.ts +170 -0
- package/src/browser.js +369 -0
- package/src/data.d.ts +133 -0
- package/src/data.js +447 -0
- package/src/embed.d.ts +6 -0
- package/src/embed.js +119 -0
- package/src/hr-diagram.js +23 -5
- package/src/index.d.ts +32 -7
- package/src/plugins.js +87 -43
- package/src/story.d.ts +57 -0
- package/src/story.js +396 -0
- package/src/three-shim.d.ts +32 -0
- package/src/touch-os.d.ts +70 -0
- package/src/touch-os.js +275 -0
- package/src/utils.js +96 -6
- package/src/viewer-entry.d.ts +10 -0
- package/src/viewer-entry.js +4 -0
- package/src/viewer.js +110 -12
- package/src/xr/plugins.js +224 -13
- package/src/xr/session.js +60 -14
- package/src/xr.d.ts +22 -0
- package/src/xr.js +1 -0
package/src/anchored-images.js
CHANGED
|
@@ -107,16 +107,16 @@ export async function createAnchoredImageCatalog(options = {}) {
|
|
|
107
107
|
}),
|
|
108
108
|
};
|
|
109
109
|
},
|
|
110
|
-
resolveNearest(
|
|
111
|
-
const matches = scoreEntries(
|
|
110
|
+
resolveNearest(lookDirection, nearestOptions = {}) {
|
|
111
|
+
const matches = scoreEntries(lookDirection, selectEntries(entries, nearestOptions.selection, catalog));
|
|
112
112
|
if (matches.length === 0) return null;
|
|
113
113
|
const maxAngleRad = optionalAngleRad(nearestOptions.maxAngleDeg);
|
|
114
114
|
return matches.find((match) => maxAngleRad == null || match.viewDistanceRad <= maxAngleRad) ?? null;
|
|
115
115
|
},
|
|
116
|
-
resolveWithinAngle(
|
|
116
|
+
resolveWithinAngle(lookDirection, withinOptions) {
|
|
117
117
|
const maxAngleRad = requiredAngleRad(withinOptions?.maxAngleDeg);
|
|
118
118
|
if (!Number.isFinite(maxAngleRad)) return [];
|
|
119
|
-
return scoreEntries(
|
|
119
|
+
return scoreEntries(lookDirection, selectEntries(entries, withinOptions?.selection, catalog))
|
|
120
120
|
.filter((match) => match.viewDistanceRad <= maxAngleRad);
|
|
121
121
|
},
|
|
122
122
|
};
|
|
@@ -308,7 +308,8 @@ export function createAnchoredImageSkyPlugin(options) {
|
|
|
308
308
|
id,
|
|
309
309
|
priority: options.priority,
|
|
310
310
|
object3d: root,
|
|
311
|
-
anchorMode: fixedAtInfinity ? 'observer-centric' : 'world-space',
|
|
311
|
+
anchorMode: options.anchorMode ?? (fixedAtInfinity ? 'observer-centric' : 'world-space'),
|
|
312
|
+
scaleBandId: options.scaleBandId,
|
|
312
313
|
});
|
|
313
314
|
pluginContext.addPart({
|
|
314
315
|
...layer,
|
|
@@ -661,26 +662,24 @@ function targetDirectionAt(solved, x, y) {
|
|
|
661
662
|
|
|
662
663
|
/** @param {SkykitViewState} view @returns {Vector3Like} */
|
|
663
664
|
function resolveViewDirection(view) {
|
|
664
|
-
const explicitDirection = normalizeVector3(view.directionIcrs, null);
|
|
665
|
-
if (explicitDirection) return explicitDirection;
|
|
666
|
-
const orientation = normalizeQuaternion(view.orientationIcrs);
|
|
667
|
-
if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
|
|
668
665
|
const targetPc = normalizeVector3(view.targetPc, null);
|
|
669
666
|
const observerPc = normalizeVector3(view.observerPc, { x: 0, y: 0, z: 0 }) ?? { x: 0, y: 0, z: 0 };
|
|
670
667
|
if (targetPc) {
|
|
671
668
|
const direction = normalizeVector3(subtractVectors(targetPc, observerPc), null);
|
|
672
669
|
if (direction) return direction;
|
|
673
670
|
}
|
|
671
|
+
const orientation = normalizeQuaternion(view.orientationIcrs);
|
|
672
|
+
if (orientation) return normalizeVector3(rotateVectorByQuaternion(LOCAL_FORWARD, orientation), LOCAL_FORWARD) ?? { ...LOCAL_FORWARD };
|
|
674
673
|
return { ...LOCAL_FORWARD };
|
|
675
674
|
}
|
|
676
675
|
|
|
677
676
|
/**
|
|
678
|
-
* @param {Vector3Like | [number, number, number]}
|
|
677
|
+
* @param {Vector3Like | [number, number, number]} lookDirection
|
|
679
678
|
* @param {AnchoredImageCatalogEntry[]} entries
|
|
680
679
|
* @returns {AnchoredImageMatch[]}
|
|
681
680
|
*/
|
|
682
|
-
function scoreEntries(
|
|
683
|
-
const direction = vector3FromArray(normalizeDirection(
|
|
681
|
+
function scoreEntries(lookDirection, entries) {
|
|
682
|
+
const direction = vector3FromArray(normalizeDirection(lookDirection));
|
|
684
683
|
if (!direction) return [];
|
|
685
684
|
return entries
|
|
686
685
|
.map((entry) => scoreEntry(direction, entry))
|
|
@@ -689,13 +688,13 @@ function scoreEntries(directionIcrs, entries) {
|
|
|
689
688
|
}
|
|
690
689
|
|
|
691
690
|
/**
|
|
692
|
-
* @param {Vector3Like | [number, number, number]}
|
|
691
|
+
* @param {Vector3Like | [number, number, number]} lookDirection
|
|
693
692
|
* @param {AnchoredImageCatalogEntry | null | undefined} entry
|
|
694
693
|
* @returns {AnchoredImageMatch | null}
|
|
695
694
|
*/
|
|
696
|
-
function scoreEntry(
|
|
695
|
+
function scoreEntry(lookDirection, entry) {
|
|
697
696
|
if (!entry) return null;
|
|
698
|
-
const direction = vector3FromArray(normalizeDirection(
|
|
697
|
+
const direction = vector3FromArray(normalizeDirection(lookDirection));
|
|
699
698
|
if (!direction) return null;
|
|
700
699
|
const angleRad = angularDistance(direction, entry.centroidIcrs);
|
|
701
700
|
const viewDistanceRad = Math.max(0, angleRad - entry.boundsConeRadiusRad);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SkykitBrowser,
|
|
3
|
+
SkykitBrowserAddon,
|
|
4
|
+
SkykitBrowserGlobal,
|
|
5
|
+
} from './browser.js';
|
|
6
|
+
|
|
7
|
+
export declare function installSkykitBrowserGlobal(target?: typeof globalThis): SkykitBrowserGlobal;
|
|
8
|
+
export declare function registerBrowserAddon(
|
|
9
|
+
service: SkykitBrowserGlobal,
|
|
10
|
+
addon: SkykitBrowserAddon
|
|
11
|
+
): () => void;
|
|
12
|
+
export declare function registerBrowserInstance(
|
|
13
|
+
service: SkykitBrowserGlobal,
|
|
14
|
+
host: unknown,
|
|
15
|
+
browser: SkykitBrowser
|
|
16
|
+
): () => void;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const STATE = Symbol.for('found-in-space.skykit.browserAddons');
|
|
2
|
+
const GLOBAL_NAME = 'Skykit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install or upgrade the small global service used by the noob embed path.
|
|
6
|
+
*
|
|
7
|
+
* @param {typeof globalThis} [target]
|
|
8
|
+
* @returns {import('./browser.d.ts').SkykitBrowserGlobal}
|
|
9
|
+
*/
|
|
10
|
+
export function installSkykitBrowserGlobal(target = globalThis) {
|
|
11
|
+
const globalTarget = /** @type {typeof globalThis & { Skykit?: Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown> }} */ (target);
|
|
12
|
+
const service = /** @type {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} */ (
|
|
13
|
+
globalTarget[GLOBAL_NAME] && typeof globalTarget[GLOBAL_NAME] === 'object'
|
|
14
|
+
? globalTarget[GLOBAL_NAME]
|
|
15
|
+
: {}
|
|
16
|
+
);
|
|
17
|
+
const state = getState(service);
|
|
18
|
+
const queuedAddons = Array.isArray(service.browserAddons)
|
|
19
|
+
? service.browserAddons.splice(0)
|
|
20
|
+
: [];
|
|
21
|
+
|
|
22
|
+
service.browserAddons = state.addons;
|
|
23
|
+
service.registerBrowserAddon = (addon) => registerBrowserAddon(service, addon);
|
|
24
|
+
service.whenReady = (targetOrSelector) => whenReady(service, targetOrSelector);
|
|
25
|
+
service.getBrowsers = () => state.records.map((record) => record.browser);
|
|
26
|
+
globalTarget[GLOBAL_NAME] = service;
|
|
27
|
+
|
|
28
|
+
for (const addon of queuedAddons) {
|
|
29
|
+
registerBrowserAddon(service, addon);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return /** @type {import('./browser.d.ts').SkykitBrowserGlobal} */ (service);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('./browser.d.ts').SkykitBrowserGlobal} service
|
|
37
|
+
* @param {import('./browser.d.ts').SkykitBrowserAddon} addon
|
|
38
|
+
*/
|
|
39
|
+
export function registerBrowserAddon(service, addon) {
|
|
40
|
+
if (!addon || typeof addon.install !== 'function') {
|
|
41
|
+
throw new TypeError('SkyKit browser add-ons must provide install(context).');
|
|
42
|
+
}
|
|
43
|
+
const state = getState(service);
|
|
44
|
+
if (state.addons.includes(addon)) return () => {};
|
|
45
|
+
state.addons.push(addon);
|
|
46
|
+
for (const record of state.records) {
|
|
47
|
+
void installAddonOnRecord(record, addon);
|
|
48
|
+
}
|
|
49
|
+
return () => {
|
|
50
|
+
const index = state.addons.indexOf(addon);
|
|
51
|
+
if (index >= 0) state.addons.splice(index, 1);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {import('./browser.d.ts').SkykitBrowserGlobal} service
|
|
57
|
+
* @param {unknown} host
|
|
58
|
+
* @param {import('./browser.d.ts').SkykitBrowser} browser
|
|
59
|
+
* @returns {() => void}
|
|
60
|
+
*/
|
|
61
|
+
export function registerBrowserInstance(service, host, browser) {
|
|
62
|
+
const state = getState(service);
|
|
63
|
+
const record = {
|
|
64
|
+
host,
|
|
65
|
+
browser,
|
|
66
|
+
installedAddonIds: new Set(),
|
|
67
|
+
};
|
|
68
|
+
state.records.push(record);
|
|
69
|
+
for (const addon of state.addons) {
|
|
70
|
+
void installAddonOnRecord(record, addon);
|
|
71
|
+
}
|
|
72
|
+
resolveWaiters(state, record);
|
|
73
|
+
return () => {
|
|
74
|
+
const index = state.records.indexOf(record);
|
|
75
|
+
if (index >= 0) state.records.splice(index, 1);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Partial<import('./browser.d.ts').SkykitBrowserGlobal> & Record<PropertyKey, unknown>} service
|
|
81
|
+
* @returns {{
|
|
82
|
+
* addons: import('./browser.d.ts').SkykitBrowserAddon[];
|
|
83
|
+
* records: Array<{ host: unknown; browser: import('./browser.d.ts').SkykitBrowser; installedAddonIds: Set<string> }>;
|
|
84
|
+
* waiters: Array<{ target: unknown; resolve: (browser: import('./browser.d.ts').SkykitBrowser) => void; reject: (error: unknown) => void }>;
|
|
85
|
+
* }}
|
|
86
|
+
*/
|
|
87
|
+
function getState(service) {
|
|
88
|
+
if (!service[STATE]) {
|
|
89
|
+
Object.defineProperty(service, STATE, {
|
|
90
|
+
configurable: false,
|
|
91
|
+
enumerable: false,
|
|
92
|
+
value: {
|
|
93
|
+
addons: [],
|
|
94
|
+
records: [],
|
|
95
|
+
waiters: [],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return /** @type {ReturnType<typeof getState>} */ (service[STATE]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {ReturnType<typeof getState>['records'][number]} record
|
|
104
|
+
* @param {import('./browser.d.ts').SkykitBrowserAddon} addon
|
|
105
|
+
*/
|
|
106
|
+
async function installAddonOnRecord(record, addon) {
|
|
107
|
+
const addonId = addon.id ?? addon.install;
|
|
108
|
+
const dedupeId = typeof addonId === 'string' ? addonId : String(record.installedAddonIds.size + 1);
|
|
109
|
+
if (record.installedAddonIds.has(dedupeId)) return;
|
|
110
|
+
record.installedAddonIds.add(dedupeId);
|
|
111
|
+
await record.browser.install(addon);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {import('./browser.d.ts').SkykitBrowserGlobal} service
|
|
116
|
+
* @param {unknown} target
|
|
117
|
+
* @returns {Promise<import('./browser.d.ts').SkykitBrowser>}
|
|
118
|
+
*/
|
|
119
|
+
function whenReady(service, target) {
|
|
120
|
+
const state = getState(service);
|
|
121
|
+
const existing = state.records.find((record) => matchesTarget(record.host, target));
|
|
122
|
+
if (existing) return Promise.resolve(existing.browser);
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
state.waiters.push({ target, resolve, reject });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {ReturnType<typeof getState>} state
|
|
130
|
+
* @param {ReturnType<typeof getState>['records'][number]} record
|
|
131
|
+
*/
|
|
132
|
+
function resolveWaiters(state, record) {
|
|
133
|
+
for (const waiter of [...state.waiters]) {
|
|
134
|
+
if (!matchesTarget(record.host, waiter.target)) continue;
|
|
135
|
+
const index = state.waiters.indexOf(waiter);
|
|
136
|
+
if (index >= 0) state.waiters.splice(index, 1);
|
|
137
|
+
waiter.resolve(record.browser);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @param {unknown} host @param {unknown} target */
|
|
142
|
+
function matchesTarget(host, target) {
|
|
143
|
+
if (target == null) return true;
|
|
144
|
+
if (target === host) return true;
|
|
145
|
+
if (typeof target !== 'string') return false;
|
|
146
|
+
const element = /** @type {{ matches?: (selector: string) => boolean }} */ (host);
|
|
147
|
+
if (typeof element.matches === 'function') {
|
|
148
|
+
try {
|
|
149
|
+
if (element.matches(target)) return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SkykitBrowser,
|
|
3
|
+
SkykitBrowserConstellationsFacade,
|
|
4
|
+
SkykitBrowserConstellationsOptions,
|
|
5
|
+
} from './browser.js';
|
|
6
|
+
|
|
7
|
+
export declare function installSkykitConstellationsBrowserCapability(
|
|
8
|
+
context: {
|
|
9
|
+
browser: SkykitBrowser;
|
|
10
|
+
host?: unknown;
|
|
11
|
+
options?: SkykitBrowserConstellationsOptions;
|
|
12
|
+
}
|
|
13
|
+
): Promise<SkykitBrowserConstellationsFacade>;
|
|
@@ -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
|
+
}
|