@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.
Files changed (42) hide show
  1. package/README.md +223 -8
  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 +46 -5
  7. package/src/__tests__/skykit-anchored-images.test.js +32 -4
  8. package/src/__tests__/skykit-browser.test.js +442 -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 +170 -0
  22. package/src/browser.js +369 -0
  23. package/src/data.d.ts +133 -0
  24. package/src/data.js +447 -0
  25. package/src/embed.d.ts +6 -0
  26. package/src/embed.js +119 -0
  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
package/src/data.js ADDED
@@ -0,0 +1,447 @@
1
+ import {
2
+ OCTREE_DEFAULT,
3
+ createStarOctreeProviderService,
4
+ } from '@found-in-space/star-octree-provider';
5
+ import {
6
+ createObserverShellStrategy,
7
+ createSphereVolumeStrategy,
8
+ createStarCellKey,
9
+ decodeTemperatureK,
10
+ temperatureToRgb,
11
+ } from '@found-in-space/star-trees';
12
+ import {
13
+ createMetaSidecarProviderService,
14
+ deriveMetaSidecarUrlFromRenderUrl,
15
+ metaSidecarEntryDisplayFields,
16
+ } from '@found-in-space/meta-sidecar-provider';
17
+
18
+ const DEFAULT_CENTER_PC = Object.freeze({ x: 0, y: 0, z: 0 });
19
+ const DEFAULT_LIMITING_MAGNITUDE = 6.5;
20
+ const DEFAULT_STAR_ATTRIBUTES = Object.freeze(['position', 'magAbs', 'teffLog8', 'objectRef']);
21
+
22
+ export {
23
+ OCTREE_DEFAULT,
24
+ createMetaSidecarProviderService,
25
+ createObserverShellStrategy,
26
+ createSphereVolumeStrategy,
27
+ createStarCellKey,
28
+ createStarOctreeProviderService,
29
+ decodeTemperatureK,
30
+ deriveMetaSidecarUrlFromRenderUrl,
31
+ metaSidecarEntryDisplayFields,
32
+ temperatureToRgb,
33
+ };
34
+
35
+ /**
36
+ * Alias for the row-batch stream used by beginner examples.
37
+ *
38
+ * @param {import('./data.d.ts').SkykitStarDataOptions} [options]
39
+ * @returns {AsyncIterable<import('./data.d.ts').SkykitStarRow[]>}
40
+ */
41
+ export function createStarStream(options = {}) {
42
+ return streamStarRows(options);
43
+ }
44
+
45
+ /**
46
+ * Load star rows for a list, map, game, or custom renderer.
47
+ *
48
+ * @param {import('./data.d.ts').SkykitStarDataOptions} [options]
49
+ * @returns {Promise<import('./data.d.ts').SkykitStarRow[]>}
50
+ */
51
+ export async function loadStarRows(options = {}) {
52
+ /** @type {import('./data.d.ts').SkykitStarRow[]} */
53
+ const rows = [];
54
+ for await (const batch of streamStarRows({
55
+ ...options,
56
+ maxStars: undefined,
57
+ sortBy: null,
58
+ })) {
59
+ rows.push(...batch);
60
+ }
61
+ return selectRows(rows, options);
62
+ }
63
+
64
+ /**
65
+ * Stream star rows as plain JavaScript batches. The stream completes when the
66
+ * provider reports that the requested current cell set is loaded.
67
+ *
68
+ * @param {import('./data.d.ts').SkykitStarDataOptions} [options]
69
+ * @returns {AsyncIterable<import('./data.d.ts').SkykitStarRow[]>}
70
+ */
71
+ export async function* streamStarRows(options = {}) {
72
+ const { provider, disposeProvider } = createProvider(options);
73
+ const observerPc = resolveObserverPc(options);
74
+ const filterVisible = options.filterVisible !== false;
75
+
76
+ try {
77
+ for await (const delta of provider.streamCells(createCellStreamOptions(options, observerPc))) {
78
+ if (delta.type === 'stars/cells-upsert') {
79
+ const rows = rowsFromStarCells(delta.cells, {
80
+ observerPc,
81
+ limitingMagnitude: options.limitingMagnitude,
82
+ filterVisible,
83
+ });
84
+ if (rows.length > 0) yield rows;
85
+ }
86
+ if (delta.type === 'stars/error') {
87
+ throw new Error(delta.error?.message ?? 'SkyKit star stream failed.');
88
+ }
89
+ if (delta.type === 'stars/current') {
90
+ break;
91
+ }
92
+ }
93
+ } finally {
94
+ if (disposeProvider) await provider.dispose?.();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Convert provider star cells into plain rows without owning any renderer.
100
+ *
101
+ * @param {Iterable<import('@found-in-space/star-trees').StarCellData>} cells
102
+ * @param {import('./data.d.ts').SkykitRowsFromCellsOptions} [options]
103
+ * @returns {import('./data.d.ts').SkykitStarRow[]}
104
+ */
105
+ export function rowsFromStarCells(cells, options = {}) {
106
+ /** @type {import('./data.d.ts').SkykitStarRow[]} */
107
+ const rows = [];
108
+ const observerPc = normalizePoint(options.observerPc, DEFAULT_CENTER_PC);
109
+ const limitingMagnitude = finiteNumber(options.limitingMagnitude, Number.POSITIVE_INFINITY);
110
+ const filterVisible = options.filterVisible === true;
111
+
112
+ for (const cell of cells) {
113
+ rows.push(...rowsFromStarCell(cell, { observerPc, limitingMagnitude, filterVisible }));
114
+ }
115
+ return rows;
116
+ }
117
+
118
+ /**
119
+ * Load display labels for rows or refs from the metadata sidecar.
120
+ *
121
+ * @param {Iterable<import('./data.d.ts').SkykitStarLabelInput> | import('./data.d.ts').SkykitStarDataOptions} input
122
+ * @param {import('./data.d.ts').SkykitStarLabelOptions} [options]
123
+ * @returns {Promise<import('./data.d.ts').SkykitStarLabel[]>}
124
+ */
125
+ export async function loadStarLabels(input, options = {}) {
126
+ const stars = isIterable(input)
127
+ ? Array.from(input)
128
+ : await loadStarRows(/** @type {import('./data.d.ts').SkykitStarDataOptions} */ (input ?? {}));
129
+ const refs = stars.map(resolveStarRef);
130
+ const { metaProvider, disposeMetaProvider } = await createLabelProvider(refs, {
131
+ ...options,
132
+ provider: options.provider ?? (!isIterable(input) ? input?.provider : undefined),
133
+ octreeUrl: options.octreeUrl ?? (!isIterable(input) ? input?.octreeUrl : undefined),
134
+ });
135
+
136
+ try {
137
+ return await Promise.all(stars.map(async (star, index) => {
138
+ const ref = refs[index] ?? null;
139
+ const entry = ref ? await metaProvider.getMeta(ref) : null;
140
+ const fallback = ref ? formatStarRef(ref) : 'Unnamed star';
141
+ const fields = metaSidecarEntryDisplayFields(entry);
142
+ return {
143
+ star,
144
+ ref,
145
+ entry,
146
+ fields,
147
+ label: fields.primaryLabel || formatStarLabel(entry, fallback),
148
+ };
149
+ }));
150
+ } finally {
151
+ if (disposeMetaProvider) metaProvider.dispose?.();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {import('@found-in-space/meta-sidecar-provider').MetaSidecarEntry | null | undefined} entry
157
+ * @param {string} [fallback]
158
+ * @returns {string}
159
+ */
160
+ export function formatStarLabel(entry, fallback = 'Unnamed star') {
161
+ const fields = metaSidecarEntryDisplayFields(entry);
162
+ return fields.primaryLabel || fields.properName || fields.bayer || fields.hd || fields.hip || fields.gaia || fallback;
163
+ }
164
+
165
+ /**
166
+ * @param {import('@found-in-space/star-trees').StarCellData} cell
167
+ * @param {{
168
+ * observerPc: { x: number; y: number; z: number };
169
+ * limitingMagnitude: number;
170
+ * filterVisible: boolean;
171
+ * }} options
172
+ * @returns {import('./data.d.ts').SkykitStarRow[]}
173
+ */
174
+ function rowsFromStarCell(cell, options) {
175
+ const positions = cell.coordinates?.components;
176
+ if (!positions) return [];
177
+ const magAbs = cell.attributes?.magAbs ?? null;
178
+ const teffLog8 = cell.attributes?.teffLog8 ?? null;
179
+ const refs = cell.refs ?? [];
180
+ /** @type {import('./data.d.ts').SkykitStarRow[]} */
181
+ const rows = [];
182
+
183
+ for (let index = 0; index < cell.count; index += 1) {
184
+ const positionPc = {
185
+ x: positions[index * 3],
186
+ y: positions[index * 3 + 1],
187
+ z: positions[index * 3 + 2],
188
+ };
189
+ const distancePc = distanceBetween(positionPc, options.observerPc);
190
+ const absoluteMagnitude = magAbs?.[index] ?? null;
191
+ const apparentMagnitude = absoluteMagnitude == null
192
+ ? null
193
+ : apparentMagnitudeFromAbsolute(absoluteMagnitude, distancePc);
194
+ if (
195
+ options.filterVisible &&
196
+ apparentMagnitude != null &&
197
+ apparentMagnitude > options.limitingMagnitude
198
+ ) {
199
+ continue;
200
+ }
201
+ const temperatureByte = teffLog8?.[index] ?? null;
202
+ const ref = refs[index] ?? null;
203
+ rows.push({
204
+ ref,
205
+ cellKey: cell.cellKey ?? createStarCellKey(cell.cell),
206
+ level: cell.cell?.level ?? ref?.level ?? 0,
207
+ mortonCode: cell.cell?.mortonCode ?? ref?.mortonCode ?? '',
208
+ ordinal: ref?.ordinal ?? index,
209
+ positionPc,
210
+ xPc: positionPc.x,
211
+ yPc: positionPc.y,
212
+ zPc: positionPc.z,
213
+ distancePc,
214
+ magAbs: absoluteMagnitude,
215
+ absoluteMagnitude,
216
+ apparentMagnitude,
217
+ teffLog8: temperatureByte,
218
+ temperatureK: temperatureByte == null ? null : decodeTemperatureK(temperatureByte),
219
+ });
220
+ }
221
+
222
+ return rows;
223
+ }
224
+
225
+ /**
226
+ * @param {import('./data.d.ts').SkykitStarDataOptions} options
227
+ * @param {{ x: number; y: number; z: number }} observerPc
228
+ * @returns {import('@found-in-space/star-octree-provider').StarOctreeCellStreamOptions}
229
+ */
230
+ function createCellStreamOptions(options, observerPc) {
231
+ const centerPc = normalizePoint(options.centerPc, observerPc);
232
+ const limitingMagnitude = finiteNumber(options.limitingMagnitude, DEFAULT_LIMITING_MAGNITUDE);
233
+ const strategy = options.strategy ?? (
234
+ positiveNumber(options.radiusPc, 0) > 0
235
+ ? createSphereVolumeStrategy({
236
+ centerPc,
237
+ radiusPc: Number(options.radiusPc),
238
+ })
239
+ : createObserverShellStrategy()
240
+ );
241
+ return {
242
+ id: options.id,
243
+ sessionId: options.sessionId,
244
+ strategy,
245
+ view: {
246
+ observerPc,
247
+ limitingMagnitude,
248
+ ...(options.view ?? {}),
249
+ },
250
+ viewRevision: options.viewRevision,
251
+ demandRevision: options.demandRevision,
252
+ attributes: Array.from(options.attributes ?? DEFAULT_STAR_ATTRIBUTES),
253
+ coordinates: options.coordinates,
254
+ streaming: options.streaming,
255
+ memory: options.memory,
256
+ cache: options.cache,
257
+ signal: options.signal,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * @param {import('./data.d.ts').SkykitStarDataOptions} options
263
+ */
264
+ function createProvider(options) {
265
+ if (options.provider) {
266
+ return { provider: options.provider, disposeProvider: false };
267
+ }
268
+ return {
269
+ provider: createStarOctreeProviderService({
270
+ id: options.providerId,
271
+ url: options.octreeUrl ?? OCTREE_DEFAULT,
272
+ persistentCache: options.persistentCache,
273
+ limits: options.limits,
274
+ }),
275
+ disposeProvider: true,
276
+ };
277
+ }
278
+
279
+ /**
280
+ * @param {Array<import('@found-in-space/star-trees').StarObjectRef | null | undefined>} refs
281
+ * @param {import('./data.d.ts').SkykitStarLabelOptions & {
282
+ * provider?: import('@found-in-space/star-octree-provider').StarOctreeProviderService;
283
+ * octreeUrl?: string;
284
+ * }} options
285
+ */
286
+ async function createLabelProvider(refs, options) {
287
+ if (options.metaProvider) {
288
+ return { metaProvider: options.metaProvider, disposeMetaProvider: false };
289
+ }
290
+ const parentDatasetId = options.parentDatasetId
291
+ ?? options.datasetId
292
+ ?? refs.find((ref) => ref?.datasetId)?.datasetId
293
+ ?? await resolveDatasetId(options);
294
+ if (!parentDatasetId) {
295
+ throw new Error('loadStarLabels() needs a dataset id. Pass rows from loadStarRows(), parentDatasetId, or a provider.');
296
+ }
297
+ return {
298
+ metaProvider: createMetaSidecarProviderService({
299
+ id: options.metaProviderId,
300
+ parentDatasetId,
301
+ url: options.metaUrl ?? deriveMetaSidecarUrlFromRenderUrl(options.octreeUrl ?? OCTREE_DEFAULT),
302
+ persistentCache: options.persistentCache,
303
+ limits: options.metaLimits,
304
+ }),
305
+ disposeMetaProvider: true,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * @param {{ provider?: import('@found-in-space/star-octree-provider').StarOctreeProviderService; octreeUrl?: string }} options
311
+ */
312
+ async function resolveDatasetId(options) {
313
+ if (options.provider) {
314
+ const bootstrap = await options.provider.ensureBootstrap?.();
315
+ return bootstrap?.datasetId ?? options.provider.describe?.().datasetId ?? null;
316
+ }
317
+ const provider = createStarOctreeProviderService({
318
+ url: options.octreeUrl ?? OCTREE_DEFAULT,
319
+ });
320
+ try {
321
+ const bootstrap = await provider.ensureBootstrap();
322
+ return bootstrap.datasetId ?? provider.describe().datasetId ?? null;
323
+ } finally {
324
+ await provider.dispose?.();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * @param {import('./data.d.ts').SkykitStarRow[]} rows
330
+ * @param {import('./data.d.ts').SkykitStarDataOptions} options
331
+ */
332
+ function selectRows(rows, options) {
333
+ const selected = rows.slice();
334
+ const sortBy = options.sortBy === undefined ? 'apparentMagnitude' : options.sortBy;
335
+ if (typeof sortBy === 'function') {
336
+ selected.sort(sortBy);
337
+ } else if (sortBy === 'apparentMagnitude') {
338
+ selected.sort(nullableNumberSort('apparentMagnitude'));
339
+ } else if (sortBy === 'distancePc') {
340
+ selected.sort(nullableNumberSort('distancePc'));
341
+ } else if (sortBy === 'magAbs' || sortBy === 'absoluteMagnitude') {
342
+ selected.sort(nullableNumberSort('absoluteMagnitude'));
343
+ }
344
+
345
+ const maxStars = positiveInteger(options.maxStars, 0);
346
+ return maxStars > 0 ? selected.slice(0, maxStars) : selected;
347
+ }
348
+
349
+ /**
350
+ * @param {'apparentMagnitude' | 'distancePc' | 'absoluteMagnitude'} key
351
+ */
352
+ function nullableNumberSort(key) {
353
+ /**
354
+ * @param {import('./data.d.ts').SkykitStarRow} left
355
+ * @param {import('./data.d.ts').SkykitStarRow} right
356
+ */
357
+ return (left, right) => {
358
+ const leftValue = left[key];
359
+ const rightValue = right[key];
360
+ if (leftValue == null && rightValue == null) return 0;
361
+ if (leftValue == null) return 1;
362
+ if (rightValue == null) return -1;
363
+ return leftValue - rightValue;
364
+ };
365
+ }
366
+
367
+ /**
368
+ * @param {unknown} value
369
+ * @returns {import('@found-in-space/star-trees').StarObjectRef | null}
370
+ */
371
+ function resolveStarRef(value) {
372
+ if (!value || typeof value !== 'object') return null;
373
+ const candidate = /** @type {{ ref?: unknown; level?: unknown; mortonCode?: unknown; ordinal?: unknown; datasetId?: unknown }} */ (value);
374
+ if (candidate.ref) return resolveStarRef(candidate.ref);
375
+ if (
376
+ Number.isInteger(candidate.level) &&
377
+ typeof candidate.mortonCode === 'string' &&
378
+ Number.isInteger(candidate.ordinal)
379
+ ) {
380
+ return {
381
+ datasetId: typeof candidate.datasetId === 'string' ? candidate.datasetId : null,
382
+ level: candidate.level,
383
+ mortonCode: candidate.mortonCode,
384
+ ordinal: candidate.ordinal,
385
+ };
386
+ }
387
+ return null;
388
+ }
389
+
390
+ /**
391
+ * @param {import('@found-in-space/star-trees').StarObjectRef} ref
392
+ */
393
+ function formatStarRef(ref) {
394
+ return `${ref.datasetId ?? 'dataset'}:${ref.level}:${ref.mortonCode}:${ref.ordinal}`;
395
+ }
396
+
397
+ /**
398
+ * @param {import('./data.d.ts').SkykitStarDataOptions} options
399
+ */
400
+ function resolveObserverPc(options) {
401
+ return normalizePoint(options.observerPc ?? options.centerPc, DEFAULT_CENTER_PC);
402
+ }
403
+
404
+ /**
405
+ * @param {unknown} point
406
+ * @param {{ x: number; y: number; z: number }} fallback
407
+ */
408
+ function normalizePoint(point, fallback) {
409
+ if (!point || typeof point !== 'object') return { ...fallback };
410
+ const candidate = /** @type {{ x?: unknown; y?: unknown; z?: unknown }} */ (point);
411
+ return {
412
+ x: finiteNumber(candidate.x, fallback.x),
413
+ y: finiteNumber(candidate.y, fallback.y),
414
+ z: finiteNumber(candidate.z, fallback.z),
415
+ };
416
+ }
417
+
418
+ function apparentMagnitudeFromAbsolute(magAbs, distancePc) {
419
+ return magAbs + 5 * (Math.log10(Math.max(distancePc, 1e-6)) - 1);
420
+ }
421
+
422
+ function distanceBetween(left, right) {
423
+ return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
424
+ }
425
+
426
+ function finiteNumber(value, fallback) {
427
+ const number = Number(value);
428
+ return Number.isFinite(number) ? number : fallback;
429
+ }
430
+
431
+ function positiveNumber(value, fallback) {
432
+ const number = Number(value);
433
+ return Number.isFinite(number) && number > 0 ? number : fallback;
434
+ }
435
+
436
+ function positiveInteger(value, fallback) {
437
+ const number = Number(value);
438
+ return Number.isInteger(number) && number > 0 ? number : fallback;
439
+ }
440
+
441
+ /**
442
+ * @param {unknown} value
443
+ * @returns {value is Iterable<unknown>}
444
+ */
445
+ function isIterable(value) {
446
+ return Boolean(value && typeof value === 'object' && Symbol.iterator in value);
447
+ }
package/src/embed.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createSkykitBrowser } from './browser.js';
2
+ export {
3
+ installSkykitBrowserGlobal,
4
+ registerBrowserAddon,
5
+ registerBrowserInstance,
6
+ } from './browser-addons.js';
package/src/embed.js ADDED
@@ -0,0 +1,119 @@
1
+ import { parseSpatialLookAtText } from '@found-in-space/spatial';
2
+
3
+ import {
4
+ installSkykitBrowserGlobal,
5
+ registerBrowserAddon,
6
+ registerBrowserInstance,
7
+ } from './browser-addons.js';
8
+ import { createSkykitBrowser } from './browser.js';
9
+
10
+ const DEFAULT_SELECTOR = '[data-skykit-browser]';
11
+ const started = new WeakSet();
12
+ const skykitGlobal = typeof globalThis !== 'undefined'
13
+ ? installSkykitBrowserGlobal(globalThis)
14
+ : null;
15
+
16
+ if (typeof document !== 'undefined') {
17
+ ready(() => {
18
+ for (const host of document.querySelectorAll(DEFAULT_SELECTOR)) {
19
+ if (started.has(host)) continue;
20
+ started.add(host);
21
+ void createSkykitBrowser(readOptions(host))
22
+ .then(async (browser) => {
23
+ await installRequestedCapabilities(host, browser);
24
+ if (skykitGlobal) {
25
+ const unregister = registerBrowserInstance(skykitGlobal, host, browser);
26
+ await browser.install({
27
+ id: 'skykit-browser-global-record',
28
+ install: () => unregister,
29
+ });
30
+ }
31
+ return browser;
32
+ })
33
+ .then((browser) => {
34
+ reportReady(host, browser);
35
+ })
36
+ .catch((error) => {
37
+ reportError(host, error);
38
+ });
39
+ }
40
+ });
41
+ }
42
+
43
+ /**
44
+ * @param {Element} host
45
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
46
+ */
47
+ async function installRequestedCapabilities(host, browser) {
48
+ const data = isHtmlElement(host) ? host.dataset : {};
49
+ if (data.skykitConstellations != null || data.skykitConstellationManifest != null) {
50
+ await browser.constellations.load({
51
+ skyculture: data.skykitConstellations,
52
+ manifestUrl: data.skykitConstellationManifest,
53
+ assetBaseUrl: data.skykitConstellationAssets,
54
+ art: data.skykitConstellationArt,
55
+ });
56
+ }
57
+ }
58
+
59
+ /** @param {Element} host */
60
+ function readOptions(host) {
61
+ const data = isHtmlElement(host) ? host.dataset : {};
62
+ const lookAt = data.skykitLookAt ? parseSpatialLookAtText(data.skykitLookAt) : null;
63
+ return {
64
+ host,
65
+ ...(data.skykitStatus ? { status: data.skykitStatus } : {}),
66
+ ...(data.skykitMagnitude ? { limitingMagnitude: Number(data.skykitMagnitude) } : {}),
67
+ ...(data.skykitSpeed ? { speedPcPerSec: Number(data.skykitSpeed) } : {}),
68
+ ...(data.skykitExposure ? { exposure: Number(data.skykitExposure) } : {}),
69
+ ...(data.skykitMouseMode ? { mouseMode: data.skykitMouseMode } : {}),
70
+ ...(lookAt ? { view: { lookAt } } : {}),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * @param {Element} host
76
+ * @param {import('./browser.d.ts').SkykitBrowser} browser
77
+ */
78
+ function reportReady(host, browser) {
79
+ host.dispatchEvent(new CustomEvent('skykit-browser-ready', {
80
+ detail: { browser, viewer: browser.viewer },
81
+ bubbles: true,
82
+ }));
83
+ }
84
+
85
+ /**
86
+ * @param {Element} host
87
+ * @param {unknown} error
88
+ */
89
+ function reportError(host, error) {
90
+ host.dispatchEvent(new CustomEvent('skykit-browser-error', {
91
+ detail: { error },
92
+ bubbles: true,
93
+ }));
94
+ const data = isHtmlElement(host) ? host.dataset : {};
95
+ if (!data.skykitStatus) return;
96
+ const status = document.querySelector(data.skykitStatus);
97
+ if (status) status.textContent = error instanceof Error ? error.stack ?? error.message : String(error);
98
+ }
99
+
100
+ /** @param {Element} host */
101
+ function isHtmlElement(host) {
102
+ return typeof HTMLElement !== 'undefined' && host instanceof HTMLElement;
103
+ }
104
+
105
+ /** @param {() => void} callback */
106
+ function ready(callback) {
107
+ if (document.readyState === 'loading') {
108
+ document.addEventListener('DOMContentLoaded', callback, { once: true });
109
+ return;
110
+ }
111
+ callback();
112
+ }
113
+
114
+ export { createSkykitBrowser } from './browser.js';
115
+ export {
116
+ installSkykitBrowserGlobal,
117
+ registerBrowserAddon,
118
+ registerBrowserInstance,
119
+ } from './browser-addons.js';
package/src/hr-diagram.js CHANGED
@@ -79,6 +79,8 @@ export function createSkykitHrDiagramPlugin(options) {
79
79
  let renderedFrames = 0;
80
80
  let publishedFrames = 0;
81
81
  let surfaceDirty = true;
82
+ /** @type {import('@found-in-space/touch-os').EmbeddedSurfaceService | null} */
83
+ let lastPublishedSurfaces = null;
82
84
  /** @type {string | null} */
83
85
  let lastFrameSurfaceViewKey = null;
84
86
  /** @type {SkykitViewState | null} */
@@ -121,9 +123,15 @@ export function createSkykitHrDiagramPlugin(options) {
121
123
  surfaceDirty = false;
122
124
  renderedThisFrame = true;
123
125
  }
124
- if (renderedThisFrame && options.touchOs?.surfaces) {
125
- surfaceSource.publish(options.touchOs.surfaces, frame.elapsedSeconds);
126
+ const surfaces = resolveTouchOsSurfaces(options.touchOs?.surfaces);
127
+ if (surfaces !== lastPublishedSurfaces && lastPublishedSurfaces) {
128
+ surfaceSource.unpublish(lastPublishedSurfaces);
129
+ lastPublishedSurfaces = null;
130
+ }
131
+ if (renderedThisFrame && surfaces) {
132
+ surfaceSource.publish(surfaces, frame.elapsedSeconds);
126
133
  publishedFrames += 1;
134
+ lastPublishedSurfaces = surfaces;
127
135
  }
128
136
  },
129
137
  dispose() {
@@ -131,8 +139,9 @@ export function createSkykitHrDiagramPlugin(options) {
131
139
  disposed = true;
132
140
  unsubscribeSource?.();
133
141
  unregisterDemand?.();
134
- if (options.touchOs?.surfaces) {
135
- surfaceSource.unpublish(options.touchOs.surfaces);
142
+ const surfaces = lastPublishedSurfaces ?? resolveTouchOsSurfaces(options.touchOs?.surfaces);
143
+ if (surfaces) {
144
+ surfaceSource.unpublish(surfaces);
136
145
  }
137
146
  surfaceSource.dispose();
138
147
  },
@@ -340,6 +349,15 @@ function normalizeDemandStrategy(value) {
340
349
  : null;
341
350
  }
342
351
 
352
+ /**
353
+ * @param {import('./index.d.ts').SkykitHrDiagramTouchOsOptions['surfaces']} surfaces
354
+ * @returns {import('@found-in-space/touch-os').EmbeddedSurfaceService | null}
355
+ */
356
+ function resolveTouchOsSurfaces(surfaces) {
357
+ const resolved = typeof surfaces === 'function' ? surfaces() : surfaces;
358
+ return resolved ?? null;
359
+ }
360
+
343
361
  /** @param {import('@found-in-space/hr-diagram').HrDiagramMode} mode */
344
362
  function hrModeHasDemand(mode) {
345
363
  return mode === HR_DIAGRAM_MODE_VOLUME;
@@ -375,8 +393,8 @@ function cloneViewState(view) {
375
393
  ...view,
376
394
  observerPc: clonePoint(view.observerPc),
377
395
  renderObserverPosition: clonePoint(view.renderObserverPosition),
396
+ ...(view.lookAt ? { lookAt: { ...view.lookAt } } : {}),
378
397
  ...(view.targetPc ? { targetPc: clonePoint(view.targetPc) } : {}),
379
- ...(view.directionIcrs ? { directionIcrs: clonePoint(view.directionIcrs) } : {}),
380
398
  ...(view.motion
381
399
  ? {
382
400
  motion: {