@found-in-space/skykit 0.2.0-dev.20260527.1 → 0.2.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/src/story.js DELETED
@@ -1,396 +0,0 @@
1
- import { createSkykitBrowser } from './browser.js';
2
-
3
- const DEFAULT_SELECTOR = '[data-skykit-story]';
4
- const started = new WeakSet();
5
-
6
- if (typeof document !== 'undefined') {
7
- ready(() => {
8
- for (const host of document.querySelectorAll(DEFAULT_SELECTOR)) {
9
- if (started.has(host)) continue;
10
- started.add(host);
11
- void createSkykitStory(host, readStoryOptions(host))
12
- .then((story) => reportReady(host, story))
13
- .catch((error) => reportError(host, error));
14
- }
15
- });
16
- }
17
-
18
- /**
19
- * Create an authored chapter tour around the beginner SkyKit browser.
20
- *
21
- * @param {import('./story.d.ts').SkykitStoryHost | import('./story.d.ts').SkykitStoryOptions} [input]
22
- * @param {import('./story.d.ts').SkykitStoryOptions} [options]
23
- * @returns {Promise<import('./story.d.ts').SkykitStory>}
24
- */
25
- export async function createSkykitStory(input = {}, options = {}) {
26
- const storyOptions = normalizeStoryOptions(input, options);
27
- const host = resolveTarget(storyOptions.host ?? '#viewer', 'SkyKit story host');
28
- const chapters = await resolveChapters(host, storyOptions);
29
- const browser = await createSkykitBrowser({
30
- ...storyOptions,
31
- host,
32
- });
33
- const controls = storyOptions.controls === false
34
- ? null
35
- : createControls(host, storyOptions);
36
- let currentIndex = clampIndex(resolveInitialIndex(chapters, storyOptions.initialChapter), chapters.length);
37
- let disposed = false;
38
-
39
- /** @type {import('./story.d.ts').SkykitStory} */
40
- const storyApi = {
41
- browser,
42
- viewer: browser.viewer,
43
- chapters,
44
- get currentIndex() {
45
- return currentIndex;
46
- },
47
- get currentChapter() {
48
- return chapters[currentIndex] ?? null;
49
- },
50
- next,
51
- previous,
52
- goTo,
53
- dispose,
54
- };
55
-
56
- hideInlineChapters(host);
57
- if (controls) bindControls(controls);
58
- if (chapters.length > 0) {
59
- goTo(currentIndex);
60
- } else {
61
- renderControls(controls, null, -1, 0);
62
- }
63
-
64
- return storyApi;
65
-
66
- function next() {
67
- return goTo(Math.min(currentIndex + 1, chapters.length - 1));
68
- }
69
-
70
- function previous() {
71
- return goTo(Math.max(currentIndex - 1, 0));
72
- }
73
-
74
- /**
75
- * @param {number | string} indexOrId
76
- */
77
- function goTo(indexOrId) {
78
- if (disposed || chapters.length === 0) return null;
79
- const nextIndex = typeof indexOrId === 'string'
80
- ? chapters.findIndex((chapter) => chapter.id === indexOrId)
81
- : indexOrId;
82
- if (!Number.isInteger(nextIndex) || nextIndex < 0 || nextIndex >= chapters.length) {
83
- throw new RangeError(`SkyKit story chapter not found: ${indexOrId}`);
84
- }
85
- currentIndex = nextIndex;
86
- const chapter = chapters[currentIndex];
87
- applyChapter(chapter);
88
- renderControls(controls, chapter, currentIndex, chapters.length);
89
- const detail = { story: storyApi, chapter, index: currentIndex };
90
- host.dispatchEvent(new CustomEvent('skykit-story-chapter-change', {
91
- detail,
92
- bubbles: true,
93
- }));
94
- browser.viewer.emit({
95
- type: 'story/chapter-change',
96
- story: storyApi,
97
- chapter,
98
- index: currentIndex,
99
- });
100
- return chapter;
101
- }
102
-
103
- async function dispose() {
104
- if (disposed) return;
105
- disposed = true;
106
- controls?.root.remove();
107
- await browser.dispose();
108
- }
109
-
110
- /**
111
- * @param {import('./story.d.ts').SkykitStoryChapter} chapter
112
- */
113
- function applyChapter(chapter) {
114
- const targetPc = chapter.targetPc ?? chapter.observerPc ?? null;
115
- browser.viewer.requestViewState({
116
- ...(targetPc ? { observerPc: targetPc, targetPc } : {}),
117
- ...(chapter.view ?? {}),
118
- }, 'story');
119
- }
120
-
121
- /**
122
- * @param {ReturnType<typeof createControls>} storyControls
123
- */
124
- function bindControls(storyControls) {
125
- storyControls.previous.addEventListener('click', () => {
126
- previous();
127
- });
128
- storyControls.next.addEventListener('click', () => {
129
- next();
130
- });
131
- }
132
- }
133
-
134
- /**
135
- * @param {Element} host
136
- * @param {import('./story.d.ts').SkykitStoryOptions} options
137
- * @returns {Promise<import('./story.d.ts').SkykitStoryChapter[]>}
138
- */
139
- async function resolveChapters(host, options) {
140
- if (options.chapters) return normalizeChapters(options.chapters);
141
- if (options.src) {
142
- const response = await fetch(options.src);
143
- if (!response.ok) {
144
- throw new Error(`SkyKit story could not load ${options.src}: ${response.status}`);
145
- }
146
- const json = await response.json();
147
- const chapters = Array.isArray(json) ? json : json?.chapters;
148
- return normalizeChapters(chapters ?? []);
149
- }
150
- return readInlineChapters(host);
151
- }
152
-
153
- /**
154
- * @param {Element} host
155
- * @returns {import('./story.d.ts').SkykitStoryChapter[]}
156
- */
157
- function readInlineChapters(host) {
158
- return Array.from(host.querySelectorAll('[data-skykit-chapter]'))
159
- .map((section, index) => {
160
- const element = /** @type {HTMLElement} */ (section);
161
- return normalizeChapter({
162
- id: element.dataset.id ?? element.id ?? `chapter-${index + 1}`,
163
- title: element.dataset.title ?? element.getAttribute('aria-label') ?? `Chapter ${index + 1}`,
164
- body: element.textContent?.trim() ?? '',
165
- bodyHtml: element.innerHTML.trim(),
166
- targetPc: parseVector3(element.dataset.targetPc),
167
- observerPc: parseVector3(element.dataset.observerPc),
168
- }, index);
169
- });
170
- }
171
-
172
- /**
173
- * @param {Iterable<import('./story.d.ts').SkykitStoryChapterInput>} chapters
174
- */
175
- function normalizeChapters(chapters) {
176
- return Array.from(chapters, normalizeChapter);
177
- }
178
-
179
- /**
180
- * @param {import('./story.d.ts').SkykitStoryChapterInput} input
181
- * @param {number} index
182
- * @returns {import('./story.d.ts').SkykitStoryChapter}
183
- */
184
- function normalizeChapter(input, index) {
185
- const chapter = input ?? {};
186
- return {
187
- id: String(chapter.id ?? `chapter-${index + 1}`),
188
- title: String(chapter.title ?? `Chapter ${index + 1}`),
189
- body: chapter.body == null ? '' : String(chapter.body),
190
- bodyHtml: typeof chapter.bodyHtml === 'string' ? chapter.bodyHtml : null,
191
- targetPc: normalizeOptionalVector3(chapter.targetPc),
192
- observerPc: normalizeOptionalVector3(chapter.observerPc),
193
- view: chapter.view ?? null,
194
- annotations: Array.isArray(chapter.annotations) ? chapter.annotations : [],
195
- };
196
- }
197
-
198
- /**
199
- * @param {Element} host
200
- */
201
- function hideInlineChapters(host) {
202
- for (const section of host.querySelectorAll('[data-skykit-chapter]')) {
203
- if (section instanceof HTMLElement) section.hidden = true;
204
- }
205
- }
206
-
207
- /**
208
- * @param {Element} host
209
- * @param {import('./story.d.ts').SkykitStoryOptions} options
210
- */
211
- function createControls(host, options) {
212
- const htmlHost = /** @type {HTMLElement} */ (host);
213
- if (htmlHost.style && getComputedStyle(htmlHost).position === 'static') {
214
- htmlHost.style.position = 'relative';
215
- }
216
- const root = document.createElement('div');
217
- const title = document.createElement('h2');
218
- const body = document.createElement('div');
219
- const nav = document.createElement('div');
220
- const previous = document.createElement('button');
221
- const next = document.createElement('button');
222
-
223
- root.dataset.skykitStoryControls = '';
224
- title.dataset.skykitStoryTitle = '';
225
- body.dataset.skykitStoryBody = '';
226
- previous.type = 'button';
227
- next.type = 'button';
228
- previous.textContent = options.previousLabel ?? 'Back';
229
- next.textContent = options.nextLabel ?? 'Next';
230
- nav.append(previous, next);
231
- root.append(title, body, nav);
232
- applyDefaultControlStyles(root, title, body, nav, previous, next);
233
- host.appendChild(root);
234
-
235
- return { root, title, body, previous, next };
236
- }
237
-
238
- /**
239
- * @param {ReturnType<typeof createControls> | null} controls
240
- * @param {import('./story.d.ts').SkykitStoryChapter | null} chapter
241
- * @param {number} index
242
- * @param {number} count
243
- */
244
- function renderControls(controls, chapter, index, count) {
245
- if (!controls) return;
246
- controls.title.textContent = chapter?.title ?? 'SkyKit story';
247
- if (chapter?.bodyHtml) {
248
- controls.body.innerHTML = chapter.bodyHtml;
249
- } else {
250
- controls.body.textContent = chapter?.body ?? '';
251
- }
252
- controls.previous.disabled = index <= 0;
253
- controls.next.disabled = index < 0 || index >= count - 1;
254
- }
255
-
256
- function applyDefaultControlStyles(root, title, body, nav, previous, next) {
257
- Object.assign(root.style, {
258
- position: 'absolute',
259
- left: '16px',
260
- right: '16px',
261
- bottom: '16px',
262
- zIndex: '2',
263
- display: 'grid',
264
- gap: '10px',
265
- maxWidth: '34rem',
266
- padding: '14px',
267
- border: '1px solid rgba(242, 200, 121, 0.28)',
268
- borderRadius: '8px',
269
- background: 'rgba(2, 4, 11, 0.78)',
270
- color: '#f6f1e8',
271
- font: '14px system-ui, sans-serif',
272
- pointerEvents: 'auto',
273
- });
274
- Object.assign(title.style, {
275
- margin: '0',
276
- color: '#f2c879',
277
- fontSize: '16px',
278
- lineHeight: '1.2',
279
- });
280
- Object.assign(body.style, {
281
- color: '#d8deea',
282
- lineHeight: '1.5',
283
- });
284
- Object.assign(nav.style, {
285
- display: 'flex',
286
- gap: '8px',
287
- });
288
- for (const button of [previous, next]) {
289
- Object.assign(button.style, {
290
- border: '1px solid rgba(242, 200, 121, 0.42)',
291
- borderRadius: '999px',
292
- background: 'rgba(242, 200, 121, 0.12)',
293
- color: '#f6f1e8',
294
- padding: '7px 12px',
295
- cursor: 'pointer',
296
- });
297
- }
298
- }
299
-
300
- /**
301
- * @param {import('./story.d.ts').SkykitStoryHost | import('./story.d.ts').SkykitStoryOptions} input
302
- * @param {import('./story.d.ts').SkykitStoryOptions} options
303
- */
304
- function normalizeStoryOptions(input, options) {
305
- if (typeof input === 'string' || isElementLike(input)) {
306
- return { ...options, host: input };
307
- }
308
- return { ...(input ?? {}), ...options };
309
- }
310
-
311
- /**
312
- * @param {Element} host
313
- */
314
- function readStoryOptions(host) {
315
- const data = host instanceof HTMLElement ? host.dataset : {};
316
- return {
317
- ...(data.skykitStorySrc ? { src: data.skykitStorySrc } : {}),
318
- ...(data.skykitMagnitude ? { limitingMagnitude: Number(data.skykitMagnitude) } : {}),
319
- ...(data.skykitSpeed ? { speedPcPerSec: Number(data.skykitSpeed) } : {}),
320
- ...(data.skykitExposure ? { exposure: Number(data.skykitExposure) } : {}),
321
- };
322
- }
323
-
324
- /**
325
- * @param {Element} host
326
- * @param {import('./story.d.ts').SkykitStory} story
327
- */
328
- function reportReady(host, story) {
329
- host.dispatchEvent(new CustomEvent('skykit-story-ready', {
330
- detail: { story, browser: story.browser, viewer: story.viewer },
331
- bubbles: true,
332
- }));
333
- }
334
-
335
- /**
336
- * @param {Element} host
337
- * @param {unknown} error
338
- */
339
- function reportError(host, error) {
340
- host.dispatchEvent(new CustomEvent('skykit-story-error', {
341
- detail: { error },
342
- bubbles: true,
343
- }));
344
- }
345
-
346
- function ready(callback) {
347
- if (document.readyState === 'loading') {
348
- document.addEventListener('DOMContentLoaded', callback, { once: true });
349
- return;
350
- }
351
- callback();
352
- }
353
-
354
- function resolveTarget(input, label) {
355
- if (typeof input !== 'string') {
356
- if (input) return input;
357
- throw new Error(`${label} is missing.`);
358
- }
359
- const target = document.querySelector(input);
360
- if (!target) throw new Error(`${label} not found: ${input}`);
361
- return target;
362
- }
363
-
364
- function resolveInitialIndex(chapters, initialChapter) {
365
- if (typeof initialChapter === 'string') {
366
- return chapters.findIndex((chapter) => chapter.id === initialChapter);
367
- }
368
- return Number.isInteger(initialChapter) ? Number(initialChapter) : 0;
369
- }
370
-
371
- function clampIndex(index, count) {
372
- if (count <= 0) return -1;
373
- if (!Number.isInteger(index) || index < 0) return 0;
374
- return Math.min(index, count - 1);
375
- }
376
-
377
- function parseVector3(value) {
378
- if (!value) return null;
379
- const parts = String(value).split(',').map((part) => Number(part.trim()));
380
- if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) return null;
381
- return { x: parts[0], y: parts[1], z: parts[2] };
382
- }
383
-
384
- function normalizeOptionalVector3(value) {
385
- if (!value || typeof value !== 'object') return null;
386
- const candidate = /** @type {{ x?: unknown; y?: unknown; z?: unknown }} */ (value);
387
- const x = Number(candidate.x);
388
- const y = Number(candidate.y);
389
- const z = Number(candidate.z);
390
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
391
- return { x, y, z };
392
- }
393
-
394
- function isElementLike(value) {
395
- return Boolean(value && typeof value === 'object' && 'appendChild' in value);
396
- }