@rr0/ufoathome 0.1.0 → 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.
@@ -0,0 +1,10 @@
1
+ const e = {
2
+ play: "Lecture",
3
+ pause: "Pause",
4
+ autoReplay: "Lecture automatique",
5
+ currentPosition: "Position actuelle",
6
+ duration: "Durée"
7
+ };
8
+ export {
9
+ e as ufoMessages_fr
10
+ };
@@ -0,0 +1,471 @@
1
+ const k = `
2
+ <div class="stage">
3
+ <canvas id="canvas" width="640" height="360"></canvas>
4
+ <div class="toolbar" id="toolbar">
5
+ <button id="play-pause" type="button" title="Play" aria-label="Play">▶</button>
6
+ <span id="time-start" class="time-label" title="Current position">0:00</span>
7
+ <input id="seek" type="range" min="0" max="0" value="0" step="1"/>
8
+ <span id="time-end" class="time-label" title="Duration">0:00</span>
9
+ <button id="loop" type="button" title="Auto-replay" aria-label="Auto-replay" aria-pressed="true">↻</button>
10
+ </div>
11
+ </div>
12
+ `, x = `
13
+ :host {
14
+ display: block;
15
+ font-family: sans-serif;
16
+ }
17
+ .stage {
18
+ position: relative;
19
+ width: 100%;
20
+ }
21
+ canvas {
22
+ display: block;
23
+ width: 100%;
24
+ height: auto;
25
+ aspect-ratio: 640 / 360;
26
+ background: var(--ufo-canvas-background, #050510);
27
+ border: var(--ufo-canvas-border, 1px solid #333);
28
+ box-sizing: border-box;
29
+ }
30
+ .toolbar {
31
+ position: absolute;
32
+ left: 0;
33
+ right: 0;
34
+ bottom: 0;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5em;
38
+ padding: 0.4em 0.6em;
39
+ background: rgba(0, 0, 0, 0.55);
40
+ transition: opacity 0.15s ease;
41
+ }
42
+ /* While playing, the toolbar auto-hides and only reappears on hover — kept always visible while
43
+ paused/stopped, since that's when the user is most likely to want it. Deliberately hover-only,
44
+ not :focus-within: a clicked button/range input keeps keyboard focus after the pointer moves
45
+ away, which would otherwise keep the toolbar stuck visible indefinitely after any interaction. */
46
+ .toolbar.auto-hide {
47
+ opacity: 0;
48
+ pointer-events: none;
49
+ }
50
+ .stage:hover .toolbar.auto-hide {
51
+ opacity: 1;
52
+ pointer-events: auto;
53
+ }
54
+ input[type=range] {
55
+ flex: 1;
56
+ }
57
+ .toolbar button {
58
+ display: inline-flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ width: 1.8em;
62
+ height: 1.8em;
63
+ padding: 0;
64
+ border-radius: 3px;
65
+ cursor: pointer;
66
+ font-size: 1em;
67
+ line-height: 1;
68
+ }
69
+ .toolbar button[aria-pressed="true"] {
70
+ outline: 2px solid #39f;
71
+ }
72
+ .time-label {
73
+ color: #fff;
74
+ font-variant-numeric: tabular-nums;
75
+ font-size: 0.85em;
76
+ min-width: 3em;
77
+ text-align: center;
78
+ }
79
+ `;
80
+ function w(i, t, e) {
81
+ const { bounds: s } = i;
82
+ return t >= s.x && t <= s.x + s.width && e >= s.y && e <= s.y + s.height;
83
+ }
84
+ class h {
85
+ keyframes = [];
86
+ addKeyframe(t, e) {
87
+ const s = this.findInsertIndex(t);
88
+ this.keyframes[s]?.t === t ? this.keyframes[s] = { t, shapes: e } : this.keyframes.splice(s, 0, { t, shapes: e });
89
+ }
90
+ findInsertIndex(t) {
91
+ let e = 0, s = this.keyframes.length;
92
+ for (; e < s; ) {
93
+ const a = e + s >>> 1;
94
+ this.keyframes[a].t < t ? e = a + 1 : s = a;
95
+ }
96
+ return e;
97
+ }
98
+ getKeyframeAt(t) {
99
+ const e = this.findInsertIndex(t), s = this.keyframes[e];
100
+ return s?.t === t ? s : void 0;
101
+ }
102
+ getShapeAt(t, e) {
103
+ return this.getKeyframeAt(t)?.shapes.find((s) => s.sourceId === e)?.shape;
104
+ }
105
+ /**
106
+ * Finds the most recent shape recorded at-or-before t for that source (hold-last-value),
107
+ * which is what playback needs since not every source has a keyframe at every sampled tick.
108
+ */
109
+ getLatestShapeAt(t, e) {
110
+ let s = this.findInsertIndex(t);
111
+ for (this.keyframes[s]?.t !== t && (s -= 1); s >= 0; s--) {
112
+ const a = this.keyframes[s].shapes.find((n) => n.sourceId === e);
113
+ if (a) return a.shape;
114
+ }
115
+ }
116
+ hitTest(t, e, s) {
117
+ const a = this.getKeyframeAt(t);
118
+ if (a) {
119
+ for (let n = a.shapes.length - 1; n >= 0; n--)
120
+ if (w(a.shapes[n].shape, e, s))
121
+ return a.shapes[n];
122
+ }
123
+ }
124
+ get duration() {
125
+ return this.keyframes.length === 0 ? 0 : this.keyframes[this.keyframes.length - 1].t;
126
+ }
127
+ get sourceIds() {
128
+ const t = /* @__PURE__ */ new Set();
129
+ for (const e of this.keyframes)
130
+ for (const s of e.shapes)
131
+ t.add(s.sourceId);
132
+ return [...t];
133
+ }
134
+ get allKeyframes() {
135
+ return this.keyframes;
136
+ }
137
+ toJSON() {
138
+ return { keyframes: this.keyframes };
139
+ }
140
+ static fromJSON(t) {
141
+ const e = new h();
142
+ for (const s of t.keyframes)
143
+ e.addKeyframe(s.t, s.shapes);
144
+ return e;
145
+ }
146
+ }
147
+ function c(i) {
148
+ if (i.year !== void 0)
149
+ return Date.UTC(i.year, (i.month ?? 1) - 1, i.day ?? 1, i.hour ?? 0, i.minute ?? 0, i.second ?? 0);
150
+ }
151
+ function T(i) {
152
+ if (i.durationSeconds !== void 0) return i.durationSeconds * 1e3;
153
+ const t = i.time ? c(i.time) : void 0, e = i.endTime ? c(i.endTime) : void 0;
154
+ return t !== void 0 && e !== void 0 ? e - t : void 0;
155
+ }
156
+ class u {
157
+ constructor(t, e, s) {
158
+ this.event = t, this.timeline = e, this.witnessId = s;
159
+ }
160
+ static create(t, e, s) {
161
+ return new u({ eventType: "sighting", time: t, place: e }, new h(), s);
162
+ }
163
+ }
164
+ class P {
165
+ constructor(t, e) {
166
+ this.timeline = t, this.onFrame = e;
167
+ }
168
+ rafId = null;
169
+ state = "stopped";
170
+ currentT = 0;
171
+ lastWallTime = 0;
172
+ /**
173
+ * Multiplies elapsed wall-clock time before advancing currentT. 1 (default) means playback
174
+ * takes as long as `timeline.duration` itself; UfoElement sets this to
175
+ * `timeline.duration / sightingDurationMs(event)` when the sighting's real declared duration
176
+ * is known, so watching it takes as long as the observation was actually reported to last,
177
+ * rather than however long the recording itself took to author (e.g. a quick mouse drag).
178
+ * Manually dragging the seek bar always jumps directly regardless of this rate.
179
+ */
180
+ playbackRate = 1;
181
+ /** When true, playback restarts from 0 instead of stopping once it reaches the end. */
182
+ loop = !1;
183
+ play() {
184
+ if (this.state === "playing") return;
185
+ this.currentT >= this.timeline.duration && (this.currentT = 0, this.resolveFrame(0)), this.state = "playing", this.lastWallTime = performance.now();
186
+ const t = () => {
187
+ if (this.state !== "playing") return;
188
+ const e = performance.now();
189
+ if (this.currentT += (e - this.lastWallTime) * this.playbackRate, this.lastWallTime = e, this.currentT >= this.timeline.duration) {
190
+ if (this.loop && this.timeline.duration > 0) {
191
+ this.currentT %= this.timeline.duration, this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
192
+ return;
193
+ }
194
+ this.currentT = this.timeline.duration, this.stop(), this.resolveFrame(this.currentT);
195
+ return;
196
+ }
197
+ this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
198
+ };
199
+ this.rafId = requestAnimationFrame(t);
200
+ }
201
+ pause() {
202
+ this.state === "playing" && (this.state = "paused", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null));
203
+ }
204
+ stop() {
205
+ this.state = "stopped", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null);
206
+ }
207
+ seek(t) {
208
+ this.currentT = Math.max(0, Math.min(t, this.timeline.duration)), this.resolveFrame(this.currentT);
209
+ }
210
+ get playbackState() {
211
+ return this.state;
212
+ }
213
+ get time() {
214
+ return this.currentT;
215
+ }
216
+ resolveFrame(t) {
217
+ const e = /* @__PURE__ */ new Map();
218
+ for (const s of this.timeline.sourceIds) {
219
+ const a = this.timeline.getLatestShapeAt(t, s);
220
+ a && e.set(s, a);
221
+ }
222
+ this.onFrame(t, e);
223
+ }
224
+ }
225
+ const d = 6, p = d / 2, I = 20;
226
+ class E {
227
+ constructor(t) {
228
+ this.ctx = t;
229
+ }
230
+ clear(t, e) {
231
+ this.ctx.clearRect(0, 0, t, e);
232
+ }
233
+ paintShape(t) {
234
+ this.ctx.save(), this.ctx.globalAlpha = 1 - t.transparency, t.haloScale > 0 && this.paintHalo(t), this.paintBase(t), t.selected && this.paintSelectionHandles(t), this.ctx.restore();
235
+ }
236
+ paintBase(t) {
237
+ if (this.ctx.fillStyle = t.color, this.ctx.beginPath(), t.kind === "oval") {
238
+ const { x: e, y: s, width: a, height: n } = t.bounds, r = a / 2, o = n / 2;
239
+ this.ctx.ellipse(e + r, s + o, r, o, t.angle, 0, 2 * Math.PI);
240
+ } else {
241
+ const { x: e, y: s } = t.bounds;
242
+ this.ctx.save(), this.ctx.translate(e, s), this.ctx.rotate(t.angle), t.points.forEach((a, n) => {
243
+ n === 0 ? this.ctx.moveTo(a.x, a.y) : this.ctx.lineTo(a.x, a.y);
244
+ }), this.ctx.closePath(), this.ctx.restore();
245
+ }
246
+ this.ctx.fill();
247
+ }
248
+ paintHalo(t) {
249
+ this.ctx.save(), this.ctx.shadowColor = t.color, this.ctx.shadowBlur = I * t.haloScale, this.paintBase(t), this.ctx.restore();
250
+ }
251
+ paintSelectionHandles(t) {
252
+ const { x: e, y: s, width: a, height: n } = t.bounds, r = a / 2, o = n / 2;
253
+ this.ctx.strokeStyle = "lightgray", this.ctx.strokeRect(e, s, a, n), this.ctx.fillStyle = "lightgray";
254
+ const b = [
255
+ [e, s],
256
+ [e + r, s],
257
+ [e + a, s],
258
+ [e + a, s + o],
259
+ [e + a, s + n],
260
+ [e + r, s + n],
261
+ [e, s + n],
262
+ [e, s + o]
263
+ ];
264
+ for (const [v, S] of b)
265
+ this.ctx.fillRect(v - p, S - p, d, d);
266
+ }
267
+ }
268
+ function M(i) {
269
+ return {
270
+ version: 1,
271
+ time: i.event.time,
272
+ endTime: i.event.endTime,
273
+ durationSeconds: i.event.durationSeconds,
274
+ place: i.event.place,
275
+ witnessId: i.witnessId,
276
+ timeline: i.timeline.toJSON()
277
+ };
278
+ }
279
+ function L(i) {
280
+ return new u(
281
+ {
282
+ eventType: "sighting",
283
+ time: i.time,
284
+ endTime: i.endTime,
285
+ durationSeconds: i.durationSeconds,
286
+ place: i.place
287
+ },
288
+ h.fromJSON(i.timeline),
289
+ i.witnessId
290
+ );
291
+ }
292
+ function B(i, t) {
293
+ for (const e of i) {
294
+ const s = e.toLowerCase().split("-")[0];
295
+ if (t.includes(s)) return s;
296
+ }
297
+ return "en";
298
+ }
299
+ const A = ["en", "fr"], C = {
300
+ en: () => Promise.resolve().then(() => F).then((i) => i.ufoMessages_en),
301
+ fr: () => import("./UfoMessages_fr-DKnWFOyl.js").then((i) => i.ufoMessages_fr)
302
+ };
303
+ function D(i) {
304
+ return C[i]();
305
+ }
306
+ const g = {
307
+ play: "Play",
308
+ pause: "Pause",
309
+ autoReplay: "Auto-replay",
310
+ currentPosition: "Current position",
311
+ duration: "Duration"
312
+ }, F = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
313
+ __proto__: null,
314
+ ufoMessages_en: g
315
+ }, Symbol.toStringTag, { value: "Module" }));
316
+ class R extends HTMLElement {
317
+ static get observedAttributes() {
318
+ return ["src"];
319
+ }
320
+ shadow;
321
+ canvas;
322
+ canvasRenderer;
323
+ toolbar;
324
+ playPauseButton;
325
+ loopButton;
326
+ seekInput;
327
+ timeStartLabel;
328
+ timeEndLabel;
329
+ currentSighting = u.create();
330
+ player;
331
+ loopEnabled = !0;
332
+ /** Set to false by composing elements that need the canvas's own click for something else
333
+ * instead of toggling playback — see UfoRecorderElement, which uses pointerdown/pointermove on
334
+ * this same canvas to place shapes while recording. */
335
+ enableClickToPlay = !0;
336
+ /** Matches the template's baked-in English defaults until (if ever) loadLocaleMessages()
337
+ * resolves a better match — see its doc comment. */
338
+ messages = g;
339
+ /** The sighting's real reported duration/start, cached by updateTimeLabels() so onFrame doesn't
340
+ * recompute them every animation frame — see formatPosition, which turns a `Timeline` position
341
+ * (ms since recording start) into what's actually displayed. */
342
+ realDurationMs;
343
+ realStartMs;
344
+ constructor() {
345
+ super(), this.shadow = this.attachShadow({ mode: "open" });
346
+ const t = document.createElement("template");
347
+ t.innerHTML = `<style>${x}</style>${k}`, this.shadow.appendChild(t.content.cloneNode(!0)), this.canvas = this.shadow.getElementById("canvas"), this.canvasRenderer = new E(this.canvas.getContext("2d")), this.toolbar = this.shadow.getElementById("toolbar"), this.playPauseButton = this.shadow.getElementById("play-pause"), this.loopButton = this.shadow.getElementById("loop"), this.seekInput = this.shadow.getElementById("seek"), this.timeStartLabel = this.shadow.getElementById("time-start"), this.timeEndLabel = this.shadow.getElementById("time-end"), this.playPauseButton.addEventListener("click", () => this.togglePlayPause()), this.loopButton.addEventListener("click", () => this.toggleLoop()), this.seekInput.addEventListener("input", () => this.player.seek(Number(this.seekInput.value))), this.canvas.addEventListener("click", () => {
348
+ this.enableClickToPlay && this.togglePlayPause();
349
+ }), this.player = this.createPlayer(), this.updateTimeLabels(), this.updatePlayPauseButton(), this.refresh(), this.loadLocaleMessages();
350
+ }
351
+ connectedCallback() {
352
+ const t = this.getAttribute("src");
353
+ t && this.loadFromSrc(t);
354
+ }
355
+ attributeChangedCallback(t, e, s) {
356
+ t === "src" && s && s !== e && this.isConnected && this.loadFromSrc(s);
357
+ }
358
+ /** Fetches a SightingRecordingJson from `url` and loads it — what the `src` attribute uses. */
359
+ async loadFromSrc(t) {
360
+ const e = await fetch(t);
361
+ this.sightingData = await e.json();
362
+ }
363
+ get sightingData() {
364
+ return M(this.currentSighting);
365
+ }
366
+ set sightingData(t) {
367
+ this.currentSighting = L(t), this.player = this.createPlayer(), this.updateTimeLabels(), this.updatePlayPauseButton(), this.refresh();
368
+ }
369
+ /**
370
+ * The live Sighting/Timeline, exposed so UfoRecorderElement/SceneElement
371
+ * (which compose this element) can add keyframes to it directly as it
372
+ * records, or read its time/place for lighting.
373
+ */
374
+ get sighting() {
375
+ return this.currentSighting;
376
+ }
377
+ /** Exposed so UfoRecorderElement can paint a live drag preview on the same canvas. */
378
+ get canvasElement() {
379
+ return this.canvas;
380
+ }
381
+ get renderer() {
382
+ return this.canvasRenderer;
383
+ }
384
+ /**
385
+ * Re-reads the timeline's duration into the seek slider and repaints the
386
+ * current frame — call after externally mutating `sighting.timeline`
387
+ * (e.g. UfoRecorderElement adding keyframes while recording).
388
+ */
389
+ refresh() {
390
+ this.seekInput.max = String(this.currentSighting.timeline.duration), this.player.seek(this.player.time);
391
+ }
392
+ onFrame(t, e) {
393
+ this.canvasRenderer.clear(this.canvas.width, this.canvas.height);
394
+ for (const s of e.values())
395
+ this.canvasRenderer.paintShape(s);
396
+ this.seekInput.value = String(t), this.timeStartLabel.textContent = this.formatPosition(t), this.updatePlayPauseButton();
397
+ }
398
+ createPlayer() {
399
+ const t = new P(this.currentSighting.timeline, (e, s) => this.onFrame(e, s));
400
+ return t.loop = this.loopEnabled, t;
401
+ }
402
+ togglePlayPause() {
403
+ this.player.playbackState === "playing" ? this.player.pause() : this.player.play(), this.updatePlayPauseButton();
404
+ }
405
+ updatePlayPauseButton() {
406
+ const t = this.player.playbackState === "playing";
407
+ this.playPauseButton.textContent = t ? "⏸" : "▶", this.playPauseButton.title = t ? this.messages.pause : this.messages.play, this.playPauseButton.setAttribute("aria-label", t ? this.messages.pause : this.messages.play), this.toolbar.classList.toggle("auto-hide", t);
408
+ }
409
+ toggleLoop() {
410
+ this.loopEnabled = !this.loopEnabled, this.loopButton.setAttribute("aria-pressed", String(this.loopEnabled)), this.player.loop = this.loopEnabled;
411
+ }
412
+ /**
413
+ * Auto-detects the visitor's preferred UI language from `navigator.languages`, falling back to
414
+ * English (already baked into the template) when none of their preferences are supported —
415
+ * see selectLocale. There is deliberately no language-picker UI: this is the only mechanism.
416
+ */
417
+ async loadLocaleMessages() {
418
+ const t = B(navigator.languages, A);
419
+ t !== "en" && this.applyMessages(await D(t));
420
+ }
421
+ applyMessages(t) {
422
+ this.messages = t, this.timeStartLabel.title = t.currentPosition, this.timeEndLabel.title = t.duration, this.loopButton.title = t.autoReplay, this.loopButton.setAttribute("aria-label", t.autoReplay), this.updatePlayPauseButton();
423
+ }
424
+ /**
425
+ * Caches the sighting's real-world reported duration/start (see sightingDurationMs) and sets
426
+ * the player's playback rate and the seek bar's end label from them, rather than from
427
+ * `timeline.duration` (how long the recording itself took to author).
428
+ */
429
+ updateTimeLabels() {
430
+ const t = this.currentSighting.event, e = T(t);
431
+ this.realDurationMs = e !== void 0 && e > 0 ? e : void 0, this.realStartMs = t.time ? c(t.time) : void 0, this.player.playbackRate = this.realDurationMs !== void 0 ? this.currentSighting.timeline.duration / this.realDurationMs : 1, this.timeEndLabel.textContent = this.formatEndOfTimeline(), this.timeStartLabel.textContent = this.formatPosition(this.player.time);
432
+ }
433
+ /**
434
+ * Turns a `Timeline` position (ms since recording start, i.e. what Player deals in) into what's
435
+ * actually displayed: a real clock time (e.g. "02:47") when a real start/duration are both
436
+ * known, an elapsed real duration ("0:00" based) when only the duration is known, or the
437
+ * recording's own elapsed time when neither is known — see updateTimeLabels.
438
+ */
439
+ formatPosition(t) {
440
+ if (this.realDurationMs === void 0) return l(t);
441
+ const e = this.currentSighting.timeline.duration, s = e > 0 ? t / e * this.realDurationMs : 0;
442
+ return this.realStartMs !== void 0 ? f(m(this.realStartMs + s)) : l(s);
443
+ }
444
+ /**
445
+ * The fixed end-of-timeline label — always the *full* real declared duration (clock time or
446
+ * elapsed), or the recording's own length when no real duration is known. Unlike
447
+ * formatPosition, doesn't scale by `timeline.duration`: a single-keyframe/static recording
448
+ * (timeline.duration === 0) still has a full declared real duration to show as its end.
449
+ */
450
+ formatEndOfTimeline() {
451
+ return this.realDurationMs === void 0 ? l(this.currentSighting.timeline.duration) : this.realStartMs !== void 0 ? f(m(this.realStartMs + this.realDurationMs)) : l(this.realDurationMs);
452
+ }
453
+ }
454
+ function m(i) {
455
+ const t = new Date(i);
456
+ return { hour: t.getUTCHours(), minute: t.getUTCMinutes(), second: t.getUTCSeconds() };
457
+ }
458
+ function f(i) {
459
+ if (i.hour === void 0) return "0:00";
460
+ const t = (e) => String(e).padStart(2, "0");
461
+ return i.second ? `${t(i.hour)}:${t(i.minute ?? 0)}:${t(i.second)}` : `${t(i.hour)}:${t(i.minute ?? 0)}`;
462
+ }
463
+ function l(i) {
464
+ const t = Math.round(i / 1e3), e = Math.floor(t / 60), s = t % 60;
465
+ return `${e}:${String(s).padStart(2, "0")}`;
466
+ }
467
+ const y = "rr0-ufo";
468
+ function O() {
469
+ customElements.get(y) || customElements.define(y, R);
470
+ }
471
+ O();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rr0/ufoathome",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "UFO@home — record and replay a UFO sighting's shape, movement and appearance",
6
6
  "author": "Jérôme Beau <rr0@rr0.org> (https://rr0.org)",
7
7
  "license": "MIT",
@@ -14,20 +14,23 @@
14
14
  "node": ">=20"
15
15
  },
16
16
  "exports": {
17
- "./player": "./dist-embed-player/rr0-ufo-player.mjs",
18
- "./recorder": "./dist-embed/rr0-ufo-recorder.mjs"
17
+ "./ufo": "./dist-embed-ufo/rr0-ufo.mjs",
18
+ "./recorder": "./dist-embed/rr0-ufo-recorder.mjs",
19
+ "./scene": "./dist-embed-scene/rr0-scene.mjs"
19
20
  },
20
21
  "files": [
21
22
  "dist-embed",
22
- "dist-embed-player"
23
+ "dist-embed-ufo",
24
+ "dist-embed-scene"
23
25
  ],
24
26
  "scripts": {
25
27
  "dev": "vite",
26
28
  "build": "tsc --noEmit && vite build",
27
29
  "build:embed": "tsc --noEmit && vite build --config vite.embed.config.ts",
28
- "build:embed-player": "tsc --noEmit && vite build --config vite.embed-player.config.ts",
29
- "build:all": "npm run build && npm run build:embed && npm run build:embed-player",
30
- "prepublishOnly": "npm run build:embed && npm run build:embed-player && npm test",
30
+ "build:embed-ufo": "tsc --noEmit && vite build --config vite.embed-ufo.config.ts",
31
+ "build:embed-scene": "tsc --noEmit && vite build --config vite.embed-scene.config.ts",
32
+ "build:all": "npm run build && npm run build:embed && npm run build:embed-ufo && npm run build:embed-scene",
33
+ "prepublishOnly": "npm run build:embed && npm run build:embed-ufo && npm run build:embed-scene && npm test",
31
34
  "preview": "vite preview",
32
35
  "test": "vitest run",
33
36
  "test:watch": "vitest"
@@ -35,9 +38,11 @@
35
38
  "dependencies": {
36
39
  "@rr0/data": "^0.3.40",
37
40
  "@rr0/place": "^0.5.3",
38
- "@rr0/time": "^0.11.2"
41
+ "@rr0/time": "^0.11.2",
42
+ "three": "^0.185.1"
39
43
  },
40
44
  "devDependencies": {
45
+ "@types/three": "^0.185.3",
41
46
  "jsdom": "^25.0.0",
42
47
  "typescript": "^5.9.2",
43
48
  "vite": "^6.3.5",