@rr0/ufoathome 0.1.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 ADDED
@@ -0,0 +1,5 @@
1
+ <img src="doc/web/ufoathome/UFOAtHome.png" align=right alt="UFO@home logo">
2
+
3
+ The UFO@home project aims to provide a standardized and efficient way to report and replay UFO sightings.
4
+
5
+ See its [Wiki](https://github.com/RR0/UfoAtHome/wiki).
@@ -0,0 +1,494 @@
1
+ const S = `
2
+ <div class="appearance">
3
+ <div class="presets" role="group" aria-label="UFO shape">
4
+ <button class="preset" id="preset-oval" type="button" data-preset="oval">Oval</button>
5
+ <button class="preset" id="preset-saucer" type="button" data-preset="saucer">Saucer</button>
6
+ <button class="preset" id="preset-triangle" type="button" data-preset="triangle">Triangle</button>
7
+ </div>
8
+ <label>Color <input id="color" type="color" value="#39ff14"/></label>
9
+ <label>Transparency <input id="transparency" type="range" min="0" max="1" step="0.05" value="0"/></label>
10
+ <label>Halo <input id="haloScale" type="range" min="0" max="3" step="0.1" value="1.5"/></label>
11
+ </div>
12
+ <div class="toolbar">
13
+ <button id="record" type="button">Record</button>
14
+ <label>Sampling rate (ms) <input id="samplingRate" type="number" min="16" step="16" value="100"/></label>
15
+ </div>
16
+ <div id="player-slot"></div>
17
+ `, b = `
18
+ :host {
19
+ display: block;
20
+ font-family: sans-serif;
21
+ }
22
+ .appearance, .toolbar {
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 0.5em;
26
+ margin-bottom: 0.5em;
27
+ flex-wrap: wrap;
28
+ }
29
+ .presets {
30
+ display: flex;
31
+ gap: 0.25em;
32
+ }
33
+ button.preset {
34
+ cursor: pointer;
35
+ }
36
+ button.preset[aria-pressed="true"] {
37
+ outline: 2px solid #39f;
38
+ font-weight: bold;
39
+ }
40
+ `, k = `
41
+ <div class="toolbar">
42
+ <button id="play" type="button">Play</button>
43
+ <button id="pause" type="button">Pause</button>
44
+ <input id="seek" type="range" min="0" max="0" value="0" step="1"/>
45
+ </div>
46
+ <canvas id="canvas" width="640" height="360"></canvas>
47
+ `, A = `
48
+ :host {
49
+ display: block;
50
+ font-family: sans-serif;
51
+ }
52
+ .toolbar {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 0.5em;
56
+ margin-bottom: 0.5em;
57
+ }
58
+ canvas {
59
+ background: #050510;
60
+ border: 1px solid #333;
61
+ }
62
+ input[type=range] {
63
+ flex: 1;
64
+ }
65
+ `;
66
+ function P(i, t = "#39ff14") {
67
+ return { kind: "oval", bounds: i, color: t, angle: 0, transparency: 0, haloScale: 0, selected: !1 };
68
+ }
69
+ function g(i, t, e = "#39ff14") {
70
+ return { kind: "polygon", bounds: i, points: t, color: e, angle: 0, transparency: 0, haloScale: 0, selected: !1 };
71
+ }
72
+ function R(i, t = "#39ff14") {
73
+ const { width: e, height: s } = i, n = [
74
+ { x: 0.5 * e, y: 0 },
75
+ { x: 0.8 * e, y: 0.25 * s },
76
+ { x: e, y: 0.5 * s },
77
+ { x: 0.8 * e, y: 0.75 * s },
78
+ { x: 0.5 * e, y: s },
79
+ { x: 0.2 * e, y: 0.75 * s },
80
+ { x: 0, y: 0.5 * s },
81
+ { x: 0.2 * e, y: 0.25 * s }
82
+ ];
83
+ return g(i, n, t);
84
+ }
85
+ function T(i, t = "#39ff14") {
86
+ const { width: e, height: s } = i, n = [
87
+ { x: 0, y: 0 },
88
+ { x: e, y: s / 2 },
89
+ { x: 0, y: s }
90
+ ];
91
+ return g(i, n, t);
92
+ }
93
+ const B = {
94
+ oval: P,
95
+ saucer: R,
96
+ triangle: T
97
+ };
98
+ function L(i, t) {
99
+ return {
100
+ ...B[t.presetId](i, t.color),
101
+ transparency: t.transparency,
102
+ haloScale: t.haloScale
103
+ };
104
+ }
105
+ function F(i, t, e) {
106
+ const { bounds: s } = i;
107
+ return t >= s.x && t <= s.x + s.width && e >= s.y && e <= s.y + s.height;
108
+ }
109
+ function v(i, t, e) {
110
+ const s = i.bounds.width, n = i.bounds.height;
111
+ return {
112
+ ...i,
113
+ bounds: { x: t - s / 2, y: e - n / 2, width: s, height: n }
114
+ };
115
+ }
116
+ class h {
117
+ keyframes = [];
118
+ addKeyframe(t, e) {
119
+ const s = this.findInsertIndex(t);
120
+ this.keyframes[s]?.t === t ? this.keyframes[s] = { t, shapes: e } : this.keyframes.splice(s, 0, { t, shapes: e });
121
+ }
122
+ findInsertIndex(t) {
123
+ let e = 0, s = this.keyframes.length;
124
+ for (; e < s; ) {
125
+ const n = e + s >>> 1;
126
+ this.keyframes[n].t < t ? e = n + 1 : s = n;
127
+ }
128
+ return e;
129
+ }
130
+ getKeyframeAt(t) {
131
+ const e = this.findInsertIndex(t), s = this.keyframes[e];
132
+ return s?.t === t ? s : void 0;
133
+ }
134
+ getShapeAt(t, e) {
135
+ return this.getKeyframeAt(t)?.shapes.find((s) => s.sourceId === e)?.shape;
136
+ }
137
+ /**
138
+ * Finds the most recent shape recorded at-or-before t for that source (hold-last-value),
139
+ * which is what playback needs since not every source has a keyframe at every sampled tick.
140
+ */
141
+ getLatestShapeAt(t, e) {
142
+ let s = this.findInsertIndex(t);
143
+ for (this.keyframes[s]?.t !== t && (s -= 1); s >= 0; s--) {
144
+ const n = this.keyframes[s].shapes.find((r) => r.sourceId === e);
145
+ if (n) return n.shape;
146
+ }
147
+ }
148
+ hitTest(t, e, s) {
149
+ const n = this.getKeyframeAt(t);
150
+ if (n) {
151
+ for (let r = n.shapes.length - 1; r >= 0; r--)
152
+ if (F(n.shapes[r].shape, e, s))
153
+ return n.shapes[r];
154
+ }
155
+ }
156
+ get duration() {
157
+ return this.keyframes.length === 0 ? 0 : this.keyframes[this.keyframes.length - 1].t;
158
+ }
159
+ get sourceIds() {
160
+ const t = /* @__PURE__ */ new Set();
161
+ for (const e of this.keyframes)
162
+ for (const s of e.shapes)
163
+ t.add(s.sourceId);
164
+ return [...t];
165
+ }
166
+ get allKeyframes() {
167
+ return this.keyframes;
168
+ }
169
+ toJSON() {
170
+ return { keyframes: this.keyframes };
171
+ }
172
+ static fromJSON(t) {
173
+ const e = new h();
174
+ for (const s of t.keyframes)
175
+ e.addKeyframe(s.t, s.shapes);
176
+ return e;
177
+ }
178
+ }
179
+ class c {
180
+ constructor(t, e, s) {
181
+ this.event = t, this.timeline = e, this.witnessId = s;
182
+ }
183
+ static create(t, e, s) {
184
+ return new c({ eventType: "sighting", time: t, place: e }, new h(), s);
185
+ }
186
+ }
187
+ class u {
188
+ constructor(t, e) {
189
+ this.timeline = t, this.onFrame = e;
190
+ }
191
+ rafId = null;
192
+ state = "stopped";
193
+ currentT = 0;
194
+ lastWallTime = 0;
195
+ play() {
196
+ if (this.state === "playing") return;
197
+ this.state = "playing", this.lastWallTime = performance.now();
198
+ const t = () => {
199
+ if (this.state !== "playing") return;
200
+ const e = performance.now();
201
+ if (this.currentT += e - this.lastWallTime, this.lastWallTime = e, this.currentT >= this.timeline.duration) {
202
+ this.currentT = this.timeline.duration, this.resolveFrame(this.currentT), this.stop();
203
+ return;
204
+ }
205
+ this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
206
+ };
207
+ this.rafId = requestAnimationFrame(t);
208
+ }
209
+ pause() {
210
+ this.state === "playing" && (this.state = "paused", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null));
211
+ }
212
+ stop() {
213
+ this.state = "stopped", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null);
214
+ }
215
+ seek(t) {
216
+ this.currentT = Math.max(0, Math.min(t, this.timeline.duration)), this.resolveFrame(this.currentT);
217
+ }
218
+ get playbackState() {
219
+ return this.state;
220
+ }
221
+ get time() {
222
+ return this.currentT;
223
+ }
224
+ resolveFrame(t) {
225
+ const e = /* @__PURE__ */ new Map();
226
+ for (const s of this.timeline.sourceIds) {
227
+ const n = this.timeline.getLatestShapeAt(t, s);
228
+ n && e.set(s, n);
229
+ }
230
+ this.onFrame(t, e);
231
+ }
232
+ }
233
+ const d = 6, m = d / 2, C = 20;
234
+ class M {
235
+ constructor(t) {
236
+ this.ctx = t;
237
+ }
238
+ clear(t, e) {
239
+ this.ctx.clearRect(0, 0, t, e);
240
+ }
241
+ paintShape(t) {
242
+ 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();
243
+ }
244
+ paintBase(t) {
245
+ if (this.ctx.fillStyle = t.color, this.ctx.beginPath(), t.kind === "oval") {
246
+ const { x: e, y: s, width: n, height: r } = t.bounds, o = n / 2, l = r / 2;
247
+ this.ctx.ellipse(e + o, s + l, o, l, t.angle, 0, 2 * Math.PI);
248
+ } else {
249
+ const { x: e, y: s } = t.bounds;
250
+ this.ctx.save(), this.ctx.translate(e, s), this.ctx.rotate(t.angle), t.points.forEach((n, r) => {
251
+ r === 0 ? this.ctx.moveTo(n.x, n.y) : this.ctx.lineTo(n.x, n.y);
252
+ }), this.ctx.closePath(), this.ctx.restore();
253
+ }
254
+ this.ctx.fill();
255
+ }
256
+ paintHalo(t) {
257
+ this.ctx.save(), this.ctx.shadowColor = t.color, this.ctx.shadowBlur = C * t.haloScale, this.paintBase(t), this.ctx.restore();
258
+ }
259
+ paintSelectionHandles(t) {
260
+ const { x: e, y: s, width: n, height: r } = t.bounds, o = n / 2, l = r / 2;
261
+ this.ctx.strokeStyle = "lightgray", this.ctx.strokeRect(e, s, n, r), this.ctx.fillStyle = "lightgray";
262
+ const I = [
263
+ [e, s],
264
+ [e + o, s],
265
+ [e + n, s],
266
+ [e + n, s + l],
267
+ [e + n, s + r],
268
+ [e + o, s + r],
269
+ [e, s + r],
270
+ [e, s + l]
271
+ ];
272
+ for (const [w, x] of I)
273
+ this.ctx.fillRect(w - m, x - m, d, d);
274
+ }
275
+ }
276
+ function N(i) {
277
+ return {
278
+ version: 1,
279
+ time: i.event.time,
280
+ place: i.event.place,
281
+ witnessId: i.witnessId,
282
+ timeline: i.timeline.toJSON()
283
+ };
284
+ }
285
+ function D(i) {
286
+ return new c(
287
+ { eventType: "sighting", time: i.time, place: i.place },
288
+ h.fromJSON(i.timeline),
289
+ i.witnessId
290
+ );
291
+ }
292
+ class H extends HTMLElement {
293
+ static get observedAttributes() {
294
+ return ["src"];
295
+ }
296
+ shadow;
297
+ canvas;
298
+ canvasRenderer;
299
+ playButton;
300
+ pauseButton;
301
+ seekInput;
302
+ currentSighting = c.create();
303
+ player;
304
+ constructor() {
305
+ super(), this.shadow = this.attachShadow({ mode: "open" });
306
+ const t = document.createElement("template");
307
+ t.innerHTML = `<style>${A}</style>${k}`, this.shadow.appendChild(t.content.cloneNode(!0)), this.canvas = this.shadow.getElementById("canvas"), this.canvasRenderer = new M(this.canvas.getContext("2d")), this.playButton = this.shadow.getElementById("play"), this.pauseButton = this.shadow.getElementById("pause"), this.seekInput = this.shadow.getElementById("seek"), this.playButton.addEventListener("click", () => this.player.play()), this.pauseButton.addEventListener("click", () => this.player.pause()), this.seekInput.addEventListener("input", () => this.player.seek(Number(this.seekInput.value))), this.player = new u(this.currentSighting.timeline, (e, s) => this.onFrame(e, s)), this.refresh();
308
+ }
309
+ connectedCallback() {
310
+ const t = this.getAttribute("src");
311
+ t && this.loadFromSrc(t);
312
+ }
313
+ attributeChangedCallback(t, e, s) {
314
+ t === "src" && s && s !== e && this.isConnected && this.loadFromSrc(s);
315
+ }
316
+ /** Fetches a SightingRecordingJson from `url` and loads it — what the `src` attribute uses. */
317
+ async loadFromSrc(t) {
318
+ const e = await fetch(t);
319
+ this.sightingData = await e.json();
320
+ }
321
+ get sightingData() {
322
+ return N(this.currentSighting);
323
+ }
324
+ set sightingData(t) {
325
+ this.currentSighting = D(t), this.player = new u(this.currentSighting.timeline, (e, s) => this.onFrame(e, s)), this.refresh();
326
+ }
327
+ /**
328
+ * The live Sighting/Timeline, exposed so UfoRecorderElement (which composes
329
+ * this element) can add keyframes to it directly as it records.
330
+ */
331
+ get sighting() {
332
+ return this.currentSighting;
333
+ }
334
+ /** Exposed so UfoRecorderElement can paint a live drag preview on the same canvas. */
335
+ get canvasElement() {
336
+ return this.canvas;
337
+ }
338
+ get renderer() {
339
+ return this.canvasRenderer;
340
+ }
341
+ /**
342
+ * Re-reads the timeline's duration into the seek slider and repaints the
343
+ * current frame — call after externally mutating `sighting.timeline`
344
+ * (e.g. UfoRecorderElement adding keyframes while recording).
345
+ */
346
+ refresh() {
347
+ this.seekInput.max = String(this.currentSighting.timeline.duration), this.player.seek(this.player.time);
348
+ }
349
+ onFrame(t, e) {
350
+ this.canvasRenderer.clear(this.canvas.width, this.canvas.height);
351
+ for (const s of e.values())
352
+ this.canvasRenderer.paintShape(s);
353
+ this.seekInput.value = String(t);
354
+ }
355
+ }
356
+ const p = "rr0-ufo-player";
357
+ function E() {
358
+ customElements.get(p) || customElements.define(p, H);
359
+ }
360
+ class _ {
361
+ constructor(t, e) {
362
+ this.timeline = t, this.clock = e;
363
+ }
364
+ currentPointer = null;
365
+ shapePrototype = null;
366
+ sourceId = "";
367
+ start(t, e) {
368
+ this.sourceId = t, this.shapePrototype = e, this.currentPointer = null, this.clock.start((s) => this.recordSample(s));
369
+ }
370
+ onPointerMove(t, e) {
371
+ this.currentPointer = { x: t, y: e };
372
+ }
373
+ stop() {
374
+ this.clock.stop();
375
+ }
376
+ recordSample(t) {
377
+ if (!this.currentPointer || !this.shapePrototype) return;
378
+ const e = v(this.shapePrototype, this.currentPointer.x, this.currentPointer.y);
379
+ this.timeline.addKeyframe(t, [{ sourceId: this.sourceId, shape: e }]);
380
+ }
381
+ }
382
+ class O {
383
+ constructor(t) {
384
+ this.intervalMs = t;
385
+ }
386
+ rafId = null;
387
+ startTime = 0;
388
+ lastTick = 0;
389
+ start(t) {
390
+ this.startTime = performance.now(), this.lastTick = 0;
391
+ const e = () => {
392
+ const s = performance.now() - this.startTime;
393
+ s - this.lastTick >= this.intervalMs && (this.lastTick = s, t(s)), this.rafId = requestAnimationFrame(e);
394
+ };
395
+ this.rafId = requestAnimationFrame(e);
396
+ }
397
+ stop() {
398
+ this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null);
399
+ }
400
+ }
401
+ E();
402
+ const a = { width: 48, height: 28 }, U = "ufo-1", y = ["oval", "saucer", "triangle"], K = { presetId: "oval", color: "#39ff14", transparency: 0, haloScale: 1.5 };
403
+ class J extends HTMLElement {
404
+ shadow;
405
+ playerElement;
406
+ recordButton;
407
+ samplingRateInput;
408
+ presetButtons;
409
+ colorInput;
410
+ transparencyInput;
411
+ haloScaleInput;
412
+ recorder;
413
+ isRecording = !1;
414
+ currentAppearance = { ...K };
415
+ constructor() {
416
+ super(), this.shadow = this.attachShadow({ mode: "open" });
417
+ const t = document.createElement("template");
418
+ t.innerHTML = `<style>${b}</style>${S}`, this.shadow.appendChild(t.content.cloneNode(!0)), this.playerElement = document.createElement(p), this.shadow.getElementById("player-slot").replaceWith(this.playerElement), this.recordButton = this.shadow.getElementById("record"), this.samplingRateInput = this.shadow.getElementById("samplingRate"), this.presetButtons = {
419
+ oval: this.shadow.getElementById("preset-oval"),
420
+ saucer: this.shadow.getElementById("preset-saucer"),
421
+ triangle: this.shadow.getElementById("preset-triangle")
422
+ }, this.colorInput = this.shadow.getElementById("color"), this.transparencyInput = this.shadow.getElementById("transparency"), this.haloScaleInput = this.shadow.getElementById("haloScale"), this.playerElement.canvasElement.addEventListener("pointerdown", (e) => this.onPointerDown(e)), this.playerElement.canvasElement.addEventListener("pointermove", (e) => this.onPointerMove(e)), this.recordButton.addEventListener("click", () => this.toggleRecording());
423
+ for (const e of y)
424
+ this.presetButtons[e].addEventListener("click", () => this.setAppearance({ presetId: e }));
425
+ this.colorInput.addEventListener("input", () => this.setAppearance({ color: this.colorInput.value })), this.transparencyInput.addEventListener(
426
+ "input",
427
+ () => this.setAppearance({ transparency: Number(this.transparencyInput.value) })
428
+ ), this.haloScaleInput.addEventListener(
429
+ "input",
430
+ () => this.setAppearance({ haloScale: Number(this.haloScaleInput.value) })
431
+ ), this.updatePresetButtons(), this.renderPreview();
432
+ }
433
+ get sightingData() {
434
+ return this.playerElement.sightingData;
435
+ }
436
+ set sightingData(t) {
437
+ this.playerElement.sightingData = t;
438
+ }
439
+ /** The UFO's appearance (shape preset, color, transparency, halo) used for the next recording. */
440
+ get appearance() {
441
+ return { ...this.currentAppearance };
442
+ }
443
+ set appearance(t) {
444
+ this.setAppearance(t);
445
+ }
446
+ setAppearance(t) {
447
+ this.currentAppearance = { ...this.currentAppearance, ...t }, this.updatePresetButtons(), this.isRecording || this.renderPreview();
448
+ }
449
+ updatePresetButtons() {
450
+ for (const t of y)
451
+ this.presetButtons[t]?.setAttribute(
452
+ "aria-pressed",
453
+ String(t === this.currentAppearance.presetId)
454
+ );
455
+ }
456
+ get samplingRate() {
457
+ return Number(this.samplingRateInput.value);
458
+ }
459
+ buildPrototype(t = this.defaultBounds()) {
460
+ return L(t, this.currentAppearance);
461
+ }
462
+ defaultBounds() {
463
+ const t = this.playerElement.canvasElement;
464
+ return {
465
+ x: t.width / 2 - a.width / 2,
466
+ y: t.height / 2 - a.height / 2,
467
+ width: a.width,
468
+ height: a.height
469
+ };
470
+ }
471
+ renderPreview() {
472
+ const t = this.playerElement.canvasElement;
473
+ this.playerElement.renderer.clear(t.width, t.height), this.playerElement.renderer.paintShape(this.buildPrototype());
474
+ }
475
+ toggleRecording() {
476
+ const t = this.playerElement.canvasElement;
477
+ this.isRecording ? (this.recorder?.stop(), this.isRecording = !1, this.recordButton.textContent = "Record", t.style.cursor = "", t.style.touchAction = "", this.playerElement.refresh()) : (this.recorder = new _(this.playerElement.sighting.timeline, new O(this.samplingRate)), this.recorder.start(U, this.buildPrototype()), this.isRecording = !0, this.recordButton.textContent = "Stop", t.style.cursor = "crosshair", t.style.touchAction = "none");
478
+ }
479
+ onPointerDown(t) {
480
+ this.isRecording && this.onPointerMove(t);
481
+ }
482
+ onPointerMove(t) {
483
+ if (!this.isRecording) return;
484
+ const e = this.playerElement.canvasElement, s = e.getBoundingClientRect(), n = t.clientX - s.left, r = t.clientY - s.top;
485
+ this.recorder?.onPointerMove(n, r), this.playerElement.renderer.clear(e.width, e.height), this.playerElement.renderer.paintShape(
486
+ v(this.buildPrototype({ x: 0, y: 0, width: a.width, height: a.height }), n, r)
487
+ );
488
+ }
489
+ }
490
+ const f = "rr0-ufo-recorder";
491
+ function W() {
492
+ E(), customElements.get(f) || customElements.define(f, J);
493
+ }
494
+ W();
@@ -0,0 +1,275 @@
1
+ const g = `
2
+ <div class="toolbar">
3
+ <button id="play" type="button">Play</button>
4
+ <button id="pause" type="button">Pause</button>
5
+ <input id="seek" type="range" min="0" max="0" value="0" step="1"/>
6
+ </div>
7
+ <canvas id="canvas" width="640" height="360"></canvas>
8
+ `, v = `
9
+ :host {
10
+ display: block;
11
+ font-family: sans-serif;
12
+ }
13
+ .toolbar {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 0.5em;
17
+ margin-bottom: 0.5em;
18
+ }
19
+ canvas {
20
+ background: #050510;
21
+ border: 1px solid #333;
22
+ }
23
+ input[type=range] {
24
+ flex: 1;
25
+ }
26
+ `;
27
+ function x(a, t, s) {
28
+ const { bounds: e } = a;
29
+ return t >= e.x && t <= e.x + e.width && s >= e.y && s <= e.y + e.height;
30
+ }
31
+ class o {
32
+ keyframes = [];
33
+ addKeyframe(t, s) {
34
+ const e = this.findInsertIndex(t);
35
+ this.keyframes[e]?.t === t ? this.keyframes[e] = { t, shapes: s } : this.keyframes.splice(e, 0, { t, shapes: s });
36
+ }
37
+ findInsertIndex(t) {
38
+ let s = 0, e = this.keyframes.length;
39
+ for (; s < e; ) {
40
+ const i = s + e >>> 1;
41
+ this.keyframes[i].t < t ? s = i + 1 : e = i;
42
+ }
43
+ return s;
44
+ }
45
+ getKeyframeAt(t) {
46
+ const s = this.findInsertIndex(t), e = this.keyframes[s];
47
+ return e?.t === t ? e : void 0;
48
+ }
49
+ getShapeAt(t, s) {
50
+ return this.getKeyframeAt(t)?.shapes.find((e) => e.sourceId === s)?.shape;
51
+ }
52
+ /**
53
+ * Finds the most recent shape recorded at-or-before t for that source (hold-last-value),
54
+ * which is what playback needs since not every source has a keyframe at every sampled tick.
55
+ */
56
+ getLatestShapeAt(t, s) {
57
+ let e = this.findInsertIndex(t);
58
+ for (this.keyframes[e]?.t !== t && (e -= 1); e >= 0; e--) {
59
+ const i = this.keyframes[e].shapes.find((n) => n.sourceId === s);
60
+ if (i) return i.shape;
61
+ }
62
+ }
63
+ hitTest(t, s, e) {
64
+ const i = this.getKeyframeAt(t);
65
+ if (i) {
66
+ for (let n = i.shapes.length - 1; n >= 0; n--)
67
+ if (x(i.shapes[n].shape, s, e))
68
+ return i.shapes[n];
69
+ }
70
+ }
71
+ get duration() {
72
+ return this.keyframes.length === 0 ? 0 : this.keyframes[this.keyframes.length - 1].t;
73
+ }
74
+ get sourceIds() {
75
+ const t = /* @__PURE__ */ new Set();
76
+ for (const s of this.keyframes)
77
+ for (const e of s.shapes)
78
+ t.add(e.sourceId);
79
+ return [...t];
80
+ }
81
+ get allKeyframes() {
82
+ return this.keyframes;
83
+ }
84
+ toJSON() {
85
+ return { keyframes: this.keyframes };
86
+ }
87
+ static fromJSON(t) {
88
+ const s = new o();
89
+ for (const e of t.keyframes)
90
+ s.addKeyframe(e.t, e.shapes);
91
+ return s;
92
+ }
93
+ }
94
+ class c {
95
+ constructor(t, s, e) {
96
+ this.event = t, this.timeline = s, this.witnessId = e;
97
+ }
98
+ static create(t, s, e) {
99
+ return new c({ eventType: "sighting", time: t, place: s }, new o(), e);
100
+ }
101
+ }
102
+ class d {
103
+ constructor(t, s) {
104
+ this.timeline = t, this.onFrame = s;
105
+ }
106
+ rafId = null;
107
+ state = "stopped";
108
+ currentT = 0;
109
+ lastWallTime = 0;
110
+ play() {
111
+ if (this.state === "playing") return;
112
+ this.state = "playing", this.lastWallTime = performance.now();
113
+ const t = () => {
114
+ if (this.state !== "playing") return;
115
+ const s = performance.now();
116
+ if (this.currentT += s - this.lastWallTime, this.lastWallTime = s, this.currentT >= this.timeline.duration) {
117
+ this.currentT = this.timeline.duration, this.resolveFrame(this.currentT), this.stop();
118
+ return;
119
+ }
120
+ this.resolveFrame(this.currentT), this.rafId = requestAnimationFrame(t);
121
+ };
122
+ this.rafId = requestAnimationFrame(t);
123
+ }
124
+ pause() {
125
+ this.state === "playing" && (this.state = "paused", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null));
126
+ }
127
+ stop() {
128
+ this.state = "stopped", this.rafId !== null && (cancelAnimationFrame(this.rafId), this.rafId = null);
129
+ }
130
+ seek(t) {
131
+ this.currentT = Math.max(0, Math.min(t, this.timeline.duration)), this.resolveFrame(this.currentT);
132
+ }
133
+ get playbackState() {
134
+ return this.state;
135
+ }
136
+ get time() {
137
+ return this.currentT;
138
+ }
139
+ resolveFrame(t) {
140
+ const s = /* @__PURE__ */ new Map();
141
+ for (const e of this.timeline.sourceIds) {
142
+ const i = this.timeline.getLatestShapeAt(t, e);
143
+ i && s.set(e, i);
144
+ }
145
+ this.onFrame(t, s);
146
+ }
147
+ }
148
+ const l = 6, u = l / 2, k = 20;
149
+ class I {
150
+ constructor(t) {
151
+ this.ctx = t;
152
+ }
153
+ clear(t, s) {
154
+ this.ctx.clearRect(0, 0, t, s);
155
+ }
156
+ paintShape(t) {
157
+ 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();
158
+ }
159
+ paintBase(t) {
160
+ if (this.ctx.fillStyle = t.color, this.ctx.beginPath(), t.kind === "oval") {
161
+ const { x: s, y: e, width: i, height: n } = t.bounds, r = i / 2, h = n / 2;
162
+ this.ctx.ellipse(s + r, e + h, r, h, t.angle, 0, 2 * Math.PI);
163
+ } else {
164
+ const { x: s, y: e } = t.bounds;
165
+ this.ctx.save(), this.ctx.translate(s, e), this.ctx.rotate(t.angle), t.points.forEach((i, n) => {
166
+ n === 0 ? this.ctx.moveTo(i.x, i.y) : this.ctx.lineTo(i.x, i.y);
167
+ }), this.ctx.closePath(), this.ctx.restore();
168
+ }
169
+ this.ctx.fill();
170
+ }
171
+ paintHalo(t) {
172
+ this.ctx.save(), this.ctx.shadowColor = t.color, this.ctx.shadowBlur = k * t.haloScale, this.paintBase(t), this.ctx.restore();
173
+ }
174
+ paintSelectionHandles(t) {
175
+ const { x: s, y: e, width: i, height: n } = t.bounds, r = i / 2, h = n / 2;
176
+ this.ctx.strokeStyle = "lightgray", this.ctx.strokeRect(s, e, i, n), this.ctx.fillStyle = "lightgray";
177
+ const f = [
178
+ [s, e],
179
+ [s + r, e],
180
+ [s + i, e],
181
+ [s + i, e + h],
182
+ [s + i, e + n],
183
+ [s + r, e + n],
184
+ [s, e + n],
185
+ [s, e + h]
186
+ ];
187
+ for (const [p, y] of f)
188
+ this.ctx.fillRect(p - u, y - u, l, l);
189
+ }
190
+ }
191
+ function S(a) {
192
+ return {
193
+ version: 1,
194
+ time: a.event.time,
195
+ place: a.event.place,
196
+ witnessId: a.witnessId,
197
+ timeline: a.timeline.toJSON()
198
+ };
199
+ }
200
+ function w(a) {
201
+ return new c(
202
+ { eventType: "sighting", time: a.time, place: a.place },
203
+ o.fromJSON(a.timeline),
204
+ a.witnessId
205
+ );
206
+ }
207
+ class b extends HTMLElement {
208
+ static get observedAttributes() {
209
+ return ["src"];
210
+ }
211
+ shadow;
212
+ canvas;
213
+ canvasRenderer;
214
+ playButton;
215
+ pauseButton;
216
+ seekInput;
217
+ currentSighting = c.create();
218
+ player;
219
+ constructor() {
220
+ super(), this.shadow = this.attachShadow({ mode: "open" });
221
+ const t = document.createElement("template");
222
+ t.innerHTML = `<style>${v}</style>${g}`, this.shadow.appendChild(t.content.cloneNode(!0)), this.canvas = this.shadow.getElementById("canvas"), this.canvasRenderer = new I(this.canvas.getContext("2d")), this.playButton = this.shadow.getElementById("play"), this.pauseButton = this.shadow.getElementById("pause"), this.seekInput = this.shadow.getElementById("seek"), this.playButton.addEventListener("click", () => this.player.play()), this.pauseButton.addEventListener("click", () => this.player.pause()), this.seekInput.addEventListener("input", () => this.player.seek(Number(this.seekInput.value))), this.player = new d(this.currentSighting.timeline, (s, e) => this.onFrame(s, e)), this.refresh();
223
+ }
224
+ connectedCallback() {
225
+ const t = this.getAttribute("src");
226
+ t && this.loadFromSrc(t);
227
+ }
228
+ attributeChangedCallback(t, s, e) {
229
+ t === "src" && e && e !== s && this.isConnected && this.loadFromSrc(e);
230
+ }
231
+ /** Fetches a SightingRecordingJson from `url` and loads it — what the `src` attribute uses. */
232
+ async loadFromSrc(t) {
233
+ const s = await fetch(t);
234
+ this.sightingData = await s.json();
235
+ }
236
+ get sightingData() {
237
+ return S(this.currentSighting);
238
+ }
239
+ set sightingData(t) {
240
+ this.currentSighting = w(t), this.player = new d(this.currentSighting.timeline, (s, e) => this.onFrame(s, e)), this.refresh();
241
+ }
242
+ /**
243
+ * The live Sighting/Timeline, exposed so UfoRecorderElement (which composes
244
+ * this element) can add keyframes to it directly as it records.
245
+ */
246
+ get sighting() {
247
+ return this.currentSighting;
248
+ }
249
+ /** Exposed so UfoRecorderElement can paint a live drag preview on the same canvas. */
250
+ get canvasElement() {
251
+ return this.canvas;
252
+ }
253
+ get renderer() {
254
+ return this.canvasRenderer;
255
+ }
256
+ /**
257
+ * Re-reads the timeline's duration into the seek slider and repaints the
258
+ * current frame — call after externally mutating `sighting.timeline`
259
+ * (e.g. UfoRecorderElement adding keyframes while recording).
260
+ */
261
+ refresh() {
262
+ this.seekInput.max = String(this.currentSighting.timeline.duration), this.player.seek(this.player.time);
263
+ }
264
+ onFrame(t, s) {
265
+ this.canvasRenderer.clear(this.canvas.width, this.canvas.height);
266
+ for (const e of s.values())
267
+ this.canvasRenderer.paintShape(e);
268
+ this.seekInput.value = String(t);
269
+ }
270
+ }
271
+ const m = "rr0-ufo-player";
272
+ function E() {
273
+ customElements.get(m) || customElements.define(m, b);
274
+ }
275
+ E();
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@rr0/ufoathome",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "UFO@home — record and replay a UFO sighting's shape, movement and appearance",
6
+ "author": "Jérôme Beau <rr0@rr0.org> (https://rr0.org)",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/RR0/UfoAtHome#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RR0/UfoAtHome.git"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "exports": {
17
+ "./player": "./dist-embed-player/rr0-ufo-player.mjs",
18
+ "./recorder": "./dist-embed/rr0-ufo-recorder.mjs"
19
+ },
20
+ "files": [
21
+ "dist-embed",
22
+ "dist-embed-player"
23
+ ],
24
+ "scripts": {
25
+ "dev": "vite",
26
+ "build": "tsc --noEmit && vite build",
27
+ "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",
31
+ "preview": "vite preview",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest"
34
+ },
35
+ "dependencies": {
36
+ "@rr0/data": "^0.3.40",
37
+ "@rr0/place": "^0.5.3",
38
+ "@rr0/time": "^0.11.2"
39
+ },
40
+ "devDependencies": {
41
+ "jsdom": "^25.0.0",
42
+ "typescript": "^5.9.2",
43
+ "vite": "^6.3.5",
44
+ "vitest": "^3.0.0"
45
+ },
46
+ "keywords": [
47
+ "ufo",
48
+ "sighting",
49
+ "web-component",
50
+ "rr0"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }