bee-ladybug 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.
@@ -0,0 +1,524 @@
1
+ /**
2
+ * BeeLadybugCore — universal telemetry brain.
3
+ *
4
+ * Knows nothing about Canvas, DOM, Python, or models. Adapters push raw
5
+ * packets through sendData(type, payload). The core stamps them, stores
6
+ * history, owns an independent clock, and forwards snapshots to the UI.
7
+ */
8
+
9
+ import { UIOverlay } from './UIOverlay.js';
10
+
11
+ export const BEE_LADYBUG_VERSION = '0.1.0';
12
+
13
+ export const CORE_DEFAULTS = Object.freeze({
14
+ toggleKey: 'F2',
15
+ slowKey: 'F3',
16
+ freezeKey: 'F4',
17
+ slowScale: 0.25,
18
+ maxLogs: 250,
19
+ historySize: 120,
20
+ /** @type {'auto' | false | HTMLElement} */
21
+ mount: 'auto',
22
+ ui: true,
23
+ autoAttach: true,
24
+ autoStart: true
25
+ });
26
+
27
+ /** Packet types the overlay treats as first-class. Anything else is still stored. */
28
+ export const WELL_KNOWN_TYPES = Object.freeze([
29
+ 'fps',
30
+ 'metric',
31
+ 'coord',
32
+ 'log',
33
+ 'warn',
34
+ 'error',
35
+ 'state',
36
+ 'sys',
37
+ 'clear'
38
+ ]);
39
+
40
+ /**
41
+ * @typedef {object} BeePacket
42
+ * @property {number} id
43
+ * @property {string} type
44
+ * @property {Record<string, unknown>} payload
45
+ * @property {number} wallTime
46
+ * @property {number} simTime
47
+ * @property {number} frame
48
+ * @property {string} source
49
+ * @property {'debug'|'info'|'warn'|'error'|null} level
50
+ */
51
+
52
+ export class BeeLadybugCore {
53
+ /**
54
+ * @param {Partial<typeof CORE_DEFAULTS> & { overlay?: object }} [options]
55
+ */
56
+ constructor(options = {}) {
57
+ const cfg = { ...CORE_DEFAULTS, ...options };
58
+ this.version = BEE_LADYBUG_VERSION;
59
+ this.toggleKey = cfg.toggleKey;
60
+ this.slowKey = cfg.slowKey;
61
+ this.freezeKey = cfg.freezeKey;
62
+ this.slowScale = cfg.slowScale;
63
+ this.maxLogs = cfg.maxLogs;
64
+ this.historySize = cfg.historySize;
65
+
66
+ this.visible = true;
67
+ this.frozen = false;
68
+ this.timeScale = 1;
69
+
70
+ /** Independent simulated clock (ms). Does not freeze the overlay RAF. */
71
+ this.simTime = 0;
72
+ /** Independent frame counter. Increments only while unfrozen. */
73
+ this.frameCount = 0;
74
+ /** Smoothed overlay tick rate — not the host FPS. */
75
+ this.coreFps = 0;
76
+
77
+ /** @type {BeePacket[]} */
78
+ this.#logs = [];
79
+ /** @type {Map<string, number[]>} */
80
+ this.#history = new Map();
81
+ /** @type {Map<string, { value: unknown, unit?: string, type: string }>} */
82
+ this.#hud = new Map();
83
+ /** @type {Map<string, BeePacket>} */
84
+ this.#latest = new Map();
85
+ /** @type {Set<(packet: BeePacket) => void>} */
86
+ this.#listeners = new Set();
87
+
88
+ this.#seq = 0;
89
+ this.#lastRaf = 0;
90
+ this.#rafId = 0;
91
+ this.#bound = false;
92
+ this.#onKeyDown = (event) => this.#handleKey(event);
93
+ this.#onTick = (now) => this.#tick(now);
94
+ this.#started = false;
95
+
96
+ this.ui = null;
97
+ if (cfg.ui !== false && typeof document !== 'undefined') {
98
+ this.ui = new UIOverlay(this, options.overlay);
99
+ }
100
+
101
+ if (cfg.autoAttach) this.attach();
102
+ if (cfg.mount !== false && this.ui) this.ui.mount(cfg.mount);
103
+ if (cfg.autoStart) this.start();
104
+
105
+ this.sendData('sys', { op: 'boot', version: this.version });
106
+ this.sendData('log', {
107
+ source: 'core',
108
+ message: `BeeLadybug ${this.version} online. Waiting for adapters.`
109
+ });
110
+ }
111
+
112
+ #logs;
113
+ #history;
114
+ #hud;
115
+ #latest;
116
+ #listeners;
117
+ #seq;
118
+ #lastRaf;
119
+ #rafId;
120
+ #bound;
121
+ #onKeyDown;
122
+ #onTick;
123
+ #started;
124
+
125
+ /**
126
+ * Public adapter contract. `type` is a channel name; `payload` is raw data.
127
+ * Scalars are wrapped as `{ value }`.
128
+ *
129
+ * @param {string} type
130
+ * @param {unknown} [payload]
131
+ * @returns {BeePacket | null}
132
+ */
133
+ sendData(type, payload) {
134
+ if (typeof type !== 'string' || !type) return null;
135
+
136
+ if (type === 'clear') {
137
+ this.#clear(payload && typeof payload === 'object' ? payload : {});
138
+ return this.#emit(this.#makePacket('sys', { op: 'clear', ...(asObject(payload)) }));
139
+ }
140
+
141
+ const packet = this.#makePacket(type, payload);
142
+ this.#latest.set(type, packet);
143
+ this.#ingest(packet);
144
+ return this.#emit(packet);
145
+ }
146
+
147
+ /**
148
+ * @param {(packet: BeePacket) => void} handler
149
+ * @returns {() => void} unsubscribe
150
+ */
151
+ subscribe(handler) {
152
+ this.#listeners.add(handler);
153
+ return () => this.#listeners.delete(handler);
154
+ }
155
+
156
+ start() {
157
+ if (this.#started) return this;
158
+ this.#started = true;
159
+ this.#lastRaf = 0;
160
+ if (typeof requestAnimationFrame === 'function') {
161
+ this.#rafId = requestAnimationFrame(this.#onTick);
162
+ }
163
+ return this;
164
+ }
165
+
166
+ stop() {
167
+ this.#started = false;
168
+ if (this.#rafId && typeof cancelAnimationFrame === 'function') {
169
+ cancelAnimationFrame(this.#rafId);
170
+ }
171
+ this.#rafId = 0;
172
+ return this;
173
+ }
174
+
175
+ attach() {
176
+ if (this.#bound || typeof window === 'undefined') return this;
177
+ window.addEventListener('keydown', this.#onKeyDown);
178
+ this.#bound = true;
179
+ return this;
180
+ }
181
+
182
+ detach() {
183
+ if (!this.#bound || typeof window === 'undefined') return this;
184
+ window.removeEventListener('keydown', this.#onKeyDown);
185
+ this.#bound = false;
186
+ return this;
187
+ }
188
+
189
+ destroy() {
190
+ this.stop();
191
+ this.detach();
192
+ this.#listeners.clear();
193
+ this.#logs.length = 0;
194
+ this.#history.clear();
195
+ this.#hud.clear();
196
+ this.#latest.clear();
197
+ this.ui?.destroy();
198
+ this.ui = null;
199
+ return this;
200
+ }
201
+
202
+ show() {
203
+ if (this.visible) return this;
204
+ this.visible = true;
205
+ this.ui?.setVisible(true);
206
+ this.sendData('sys', { op: 'visibility', visible: true });
207
+ return this;
208
+ }
209
+
210
+ hide() {
211
+ if (!this.visible) return this;
212
+ this.visible = false;
213
+ this.ui?.setVisible(false);
214
+ this.sendData('sys', { op: 'visibility', visible: false });
215
+ return this;
216
+ }
217
+
218
+ toggle() {
219
+ return this.visible ? this.hide() : this.show();
220
+ }
221
+
222
+ freeze() {
223
+ if (this.frozen) return this;
224
+ this.frozen = true;
225
+ this.sendData('sys', { op: 'freeze', frozen: true });
226
+ this.sendData('log', { source: 'core', level: 'warn', message: 'Sim clock FREEZE' });
227
+ return this;
228
+ }
229
+
230
+ unfreeze() {
231
+ if (!this.frozen) return this;
232
+ this.frozen = false;
233
+ this.sendData('sys', { op: 'freeze', frozen: false });
234
+ this.sendData('log', { source: 'core', message: 'Sim clock RUN' });
235
+ return this;
236
+ }
237
+
238
+ toggleFreeze() {
239
+ return this.frozen ? this.unfreeze() : this.freeze();
240
+ }
241
+
242
+ setTimeScale(scale) {
243
+ const next = Number(scale);
244
+ if (!Number.isFinite(next) || next < 0) return this;
245
+ this.timeScale = next;
246
+ this.sendData('sys', { op: 'timescale', scale: next });
247
+ return this;
248
+ }
249
+
250
+ applySlowMo() {
251
+ if (this.frozen) this.unfreeze();
252
+ const next = this.timeScale === this.slowScale ? 1 : this.slowScale;
253
+ this.setTimeScale(next);
254
+ this.sendData('log', {
255
+ source: 'core',
256
+ message: next === 1 ? 'Sim clock 1x LIVE' : `Sim clock SLOW ${next}x`
257
+ });
258
+ return this;
259
+ }
260
+
261
+ restoreRealtime() {
262
+ if (this.frozen) this.unfreeze();
263
+ this.setTimeScale(1);
264
+ this.sendData('log', { source: 'core', message: 'Sim clock 1x LIVE' });
265
+ return this;
266
+ }
267
+
268
+ getState() {
269
+ return {
270
+ version: this.version,
271
+ visible: this.visible,
272
+ frozen: this.frozen,
273
+ timeScale: this.timeScale,
274
+ simTime: this.simTime,
275
+ frameCount: this.frameCount,
276
+ coreFps: this.coreFps,
277
+ logCount: this.#logs.length,
278
+ hud: this.getHud(),
279
+ mode: this.#modeLabel()
280
+ };
281
+ }
282
+
283
+ /** @returns {BeePacket[]} */
284
+ getLogs() {
285
+ return this.#logs;
286
+ }
287
+
288
+ /**
289
+ * @param {string} name
290
+ * @returns {{ name: string, values: number[], min: number, max: number, avg: number, last: number }}
291
+ */
292
+ getHistory(name) {
293
+ const values = this.#history.get(name) ?? [];
294
+ return summarize(name, values);
295
+ }
296
+
297
+ /**
298
+ * Prefer host `fps` history; fall back to the core tick rate.
299
+ * @returns {{ name: string, values: number[], min: number, max: number, avg: number, last: number }}
300
+ */
301
+ getPrimaryHistory() {
302
+ const host = this.#history.get('fps');
303
+ if (host && host.length) return summarize('fps', host);
304
+ const tick = this.#history.get('core.fps');
305
+ return summarize('core.fps', tick ?? []);
306
+ }
307
+
308
+ getHud() {
309
+ const out = {};
310
+ for (const [key, entry] of this.#hud) out[key] = entry;
311
+ return out;
312
+ }
313
+
314
+ /** @param {string} type */
315
+ getLatest(type) {
316
+ return this.#latest.get(type) ?? null;
317
+ }
318
+
319
+ #modeLabel() {
320
+ if (this.frozen) return 'FREEZE';
321
+ if (this.timeScale !== 1) return `SLOW ${this.timeScale}x`;
322
+ return 'LIVE';
323
+ }
324
+
325
+ #makePacket(type, payload) {
326
+ const body = asObject(payload);
327
+ const source = typeof body.source === 'string' && body.source ? body.source : 'adapter';
328
+ return {
329
+ id: ++this.#seq,
330
+ type,
331
+ payload: body,
332
+ wallTime: Date.now(),
333
+ simTime: this.simTime,
334
+ frame: this.frameCount,
335
+ source,
336
+ level: inferLevel(type, body)
337
+ };
338
+ }
339
+
340
+ #emit(packet) {
341
+ for (const handler of this.#listeners) {
342
+ try {
343
+ handler(packet);
344
+ } catch (err) {
345
+ console.warn('[BeeLadybug] subscriber error', err);
346
+ }
347
+ }
348
+ this.ui?.onPacket(packet);
349
+ return packet;
350
+ }
351
+
352
+ #ingest(packet) {
353
+ const { type, payload } = packet;
354
+
355
+ if (type === 'sys') return;
356
+
357
+ if (type === 'fps') {
358
+ const value = numeric(payload.value ?? payload.fps);
359
+ if (value != null) {
360
+ this.#pushHistory('fps', value);
361
+ this.#hud.set('fps', { value, unit: 'fps', type });
362
+ }
363
+ return;
364
+ }
365
+
366
+ if (type === 'metric') {
367
+ const name = String(payload.name ?? payload.key ?? 'metric');
368
+ const value = numeric(payload.value);
369
+ if (value != null) this.#pushHistory(name, value);
370
+ this.#hud.set(name, { value: payload.value, unit: payload.unit, type });
371
+ return;
372
+ }
373
+
374
+ if (type === 'coord') {
375
+ const label = String(payload.label ?? payload.name ?? 'coord');
376
+ const x = numeric(payload.x);
377
+ const y = numeric(payload.y);
378
+ this.#hud.set(label, {
379
+ value: x != null && y != null ? `${formatNum(x)}, ${formatNum(y)}` : payload,
380
+ type
381
+ });
382
+ return;
383
+ }
384
+
385
+ if (type === 'state') {
386
+ const key = String(payload.key ?? payload.name ?? 'state');
387
+ this.#hud.set(key, { value: payload.value, unit: payload.unit, type });
388
+ return;
389
+ }
390
+
391
+ if (isConsoleType(type) || packet.level) {
392
+ this.#pushLog(packet);
393
+ }
394
+ }
395
+
396
+ #pushLog(packet) {
397
+ this.#logs.push(packet);
398
+ if (this.#logs.length > this.maxLogs) {
399
+ this.#logs.splice(0, this.#logs.length - this.maxLogs);
400
+ }
401
+ }
402
+
403
+ #pushHistory(name, value) {
404
+ let series = this.#history.get(name);
405
+ if (!series) {
406
+ series = [];
407
+ this.#history.set(name, series);
408
+ }
409
+ series.push(value);
410
+ if (series.length > this.historySize) series.shift();
411
+ }
412
+
413
+ #clear(payload) {
414
+ const channel = payload.channel ?? payload.type;
415
+ if (!channel || channel === 'logs') this.#logs.length = 0;
416
+ if (!channel || channel === 'history') this.#history.clear();
417
+ if (!channel || channel === 'hud') this.#hud.clear();
418
+ this.ui?.onClear(channel);
419
+ }
420
+
421
+ #tick(now) {
422
+ if (!this.#started) return;
423
+ this.#rafId = requestAnimationFrame(this.#onTick);
424
+
425
+ const dt = this.#lastRaf ? now - this.#lastRaf : 16.67;
426
+ this.#lastRaf = now;
427
+ const clamped = Math.min(Math.max(dt, 0), 100);
428
+ const instant = clamped > 0 ? 1000 / clamped : 0;
429
+ this.coreFps = this.coreFps ? this.coreFps * 0.9 + instant * 0.1 : instant;
430
+ this.#pushHistory('core.fps', this.coreFps);
431
+
432
+ if (!this.frozen) {
433
+ this.simTime += clamped * this.timeScale;
434
+ this.frameCount += 1;
435
+ }
436
+
437
+ this.#hud.set('frame', { value: this.frameCount, type: 'sys' });
438
+ this.#hud.set('sim', { value: formatSimTime(this.simTime), type: 'sys' });
439
+ this.#hud.set('scale', { value: `${this.timeScale.toFixed(2)}x`, type: 'sys' });
440
+
441
+ if (this.visible) this.ui?.render();
442
+ }
443
+
444
+ #handleKey(event) {
445
+ if (event.repeat) return;
446
+ const code = event.code;
447
+
448
+ if (code === this.toggleKey) {
449
+ event.preventDefault();
450
+ this.toggle();
451
+ return;
452
+ }
453
+
454
+ if (!this.visible) return;
455
+
456
+ if (code === this.slowKey) {
457
+ event.preventDefault();
458
+ this.applySlowMo();
459
+ return;
460
+ }
461
+
462
+ if (code === this.freezeKey) {
463
+ event.preventDefault();
464
+ this.toggleFreeze();
465
+ }
466
+ }
467
+ }
468
+
469
+ function asObject(payload) {
470
+ if (payload == null) return {};
471
+ if (typeof payload !== 'object') return { value: payload, message: String(payload) };
472
+ return payload;
473
+ }
474
+
475
+ function numeric(value) {
476
+ const n = Number(value);
477
+ return Number.isFinite(n) ? n : null;
478
+ }
479
+
480
+ function inferLevel(type, payload) {
481
+ if (payload.level) return String(payload.level);
482
+ if (type === 'error') return 'error';
483
+ if (type === 'warn') return 'warn';
484
+ if (type === 'log' || type === 'debug') return payload.level ?? 'info';
485
+ return null;
486
+ }
487
+
488
+ function isConsoleType(type) {
489
+ return type === 'log' || type === 'warn' || type === 'error' || type === 'debug';
490
+ }
491
+
492
+ function formatNum(n) {
493
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
494
+ }
495
+
496
+ export function formatSimTime(ms) {
497
+ const total = Math.max(0, ms) / 1000;
498
+ const m = Math.floor(total / 60);
499
+ const s = total - m * 60;
500
+ return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`;
501
+ }
502
+
503
+ function summarize(name, values) {
504
+ if (!values.length) {
505
+ return { name, values, min: 0, max: 0, avg: 0, last: 0 };
506
+ }
507
+ let min = values[0];
508
+ let max = values[0];
509
+ let sum = 0;
510
+ for (let i = 0; i < values.length; i++) {
511
+ const v = values[i];
512
+ if (v < min) min = v;
513
+ if (v > max) max = v;
514
+ sum += v;
515
+ }
516
+ return {
517
+ name,
518
+ values,
519
+ min,
520
+ max,
521
+ avg: sum / values.length,
522
+ last: values[values.length - 1]
523
+ };
524
+ }