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,490 @@
1
+ /**
2
+ * UIOverlay — isolated cyberpunk HUD.
3
+ *
4
+ * Lives in Shadow DOM so host pages cannot restyle it. The core pushes
5
+ * packets; this class only renders. Safe to skip entirely (headless core).
6
+ */
7
+
8
+ function formatSimTime(ms) {
9
+ const total = Math.max(0, ms) / 1000;
10
+ const m = Math.floor(total / 60);
11
+ const s = total - m * 60;
12
+ return `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`;
13
+ }
14
+
15
+ const HOST_TAG = 'bee-ladybug-overlay';
16
+
17
+ const STYLES = `
18
+ :host {
19
+ all: initial;
20
+ pointer-events: none;
21
+ }
22
+ .bl-root {
23
+ position: fixed;
24
+ inset: 0;
25
+ z-index: 2147483646;
26
+ font-family: ui-monospace, "Cascadia Code", "Fira Code", Consolas, monospace;
27
+ color: #e9edf5;
28
+ }
29
+ .bl-root[data-visible="false"] {
30
+ display: none;
31
+ }
32
+ .bl-panel {
33
+ pointer-events: auto;
34
+ position: absolute;
35
+ top: 12px;
36
+ left: 12px;
37
+ width: 360px;
38
+ background: rgba(7, 8, 12, 0.94);
39
+ border: 1px solid rgba(255, 59, 78, 0.55);
40
+ box-sizing: border-box;
41
+ overflow: hidden;
42
+ }
43
+ .bl-panel::before {
44
+ content: "";
45
+ position: absolute;
46
+ inset: 4px;
47
+ border: 1px solid rgba(255, 211, 106, 0.12);
48
+ pointer-events: none;
49
+ }
50
+ .bl-scan {
51
+ position: absolute;
52
+ inset: 0;
53
+ pointer-events: none;
54
+ background: repeating-linear-gradient(
55
+ to bottom,
56
+ transparent 0 2px,
57
+ rgba(0, 0, 0, 0.16) 2px 3px
58
+ );
59
+ opacity: 0.35;
60
+ }
61
+ .bl-head {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 8px;
65
+ padding: 10px 12px 8px;
66
+ border-bottom: 1px solid rgba(255, 59, 78, 0.28);
67
+ }
68
+ .bl-mark {
69
+ width: 14px;
70
+ height: 14px;
71
+ border-radius: 50%;
72
+ background: #ff3b4e;
73
+ position: relative;
74
+ flex: 0 0 auto;
75
+ box-shadow: 0 0 0 1px rgba(255, 211, 106, 0.35);
76
+ }
77
+ .bl-mark::before,
78
+ .bl-mark::after {
79
+ content: "";
80
+ position: absolute;
81
+ width: 3px;
82
+ height: 3px;
83
+ border-radius: 50%;
84
+ background: #12080a;
85
+ top: 4px;
86
+ }
87
+ .bl-mark::before { left: 3px; }
88
+ .bl-mark::after { right: 3px; }
89
+ .bl-title {
90
+ color: #ffd36a;
91
+ font-size: 12px;
92
+ font-weight: 700;
93
+ letter-spacing: 0.14em;
94
+ }
95
+ .bl-badge {
96
+ font-size: 9px;
97
+ letter-spacing: 0.12em;
98
+ color: #6ee7ff;
99
+ border: 1px solid rgba(110, 231, 255, 0.35);
100
+ padding: 1px 5px;
101
+ }
102
+ .bl-mode {
103
+ margin-left: auto;
104
+ font-size: 10px;
105
+ font-weight: 700;
106
+ letter-spacing: 0.08em;
107
+ color: #3eea7a;
108
+ }
109
+ .bl-mode[data-mode="FREEZE"] { color: #ff3b4e; }
110
+ .bl-mode[data-mode^="SLOW"] { color: #ffd36a; }
111
+ .bl-stats {
112
+ display: grid;
113
+ grid-template-columns: 1fr 1fr;
114
+ gap: 2px 12px;
115
+ padding: 8px 12px 6px;
116
+ font-size: 11px;
117
+ color: #c5cad6;
118
+ }
119
+ .bl-stats b {
120
+ color: #fff;
121
+ font-weight: 700;
122
+ }
123
+ .bl-graph-wrap {
124
+ padding: 0 12px 6px;
125
+ }
126
+ .bl-graph {
127
+ display: block;
128
+ width: 100%;
129
+ height: 52px;
130
+ background: #05060a;
131
+ border: 1px solid rgba(255, 211, 106, 0.16);
132
+ }
133
+ .bl-graph-label {
134
+ display: flex;
135
+ justify-content: space-between;
136
+ font-size: 9px;
137
+ color: #8b91a3;
138
+ margin-top: 3px;
139
+ letter-spacing: 0.06em;
140
+ }
141
+ .bl-hud {
142
+ display: grid;
143
+ grid-template-columns: 88px 1fr;
144
+ gap: 2px 8px;
145
+ padding: 4px 12px 8px;
146
+ font-size: 11px;
147
+ max-height: 88px;
148
+ overflow: auto;
149
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
150
+ }
151
+ .bl-k { color: #8b91a3; }
152
+ .bl-v { color: #e9edf5; }
153
+ .bl-console {
154
+ height: 168px;
155
+ overflow: auto;
156
+ padding: 6px 10px 8px;
157
+ background: #05060a;
158
+ border-top: 1px solid rgba(255, 59, 78, 0.22);
159
+ font-size: 11px;
160
+ line-height: 1.45;
161
+ }
162
+ .bl-line { white-space: normal; word-break: break-word; }
163
+ .bl-line .t { color: #8b91a3; margin-right: 8px; }
164
+ .bl-line .s { color: #6ee7ff; margin-right: 8px; }
165
+ .bl-line.info .m { color: #d8dce6; }
166
+ .bl-line.warn .m { color: #ffd36a; }
167
+ .bl-line.error .m { color: #ff6b7d; }
168
+ .bl-line.debug .m { color: #8b91a3; }
169
+ .bl-foot {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 6px;
173
+ padding: 8px 10px;
174
+ border-top: 1px solid rgba(255, 59, 78, 0.22);
175
+ }
176
+ .bl-btn {
177
+ appearance: none;
178
+ background: #16181f;
179
+ color: #fff6d8;
180
+ border: 1px solid #ffd36a;
181
+ font: 700 9px ui-monospace, Consolas, monospace;
182
+ letter-spacing: 0.06em;
183
+ padding: 5px 7px;
184
+ cursor: pointer;
185
+ }
186
+ .bl-btn[data-hot="true"] {
187
+ background: #ff3b4e;
188
+ border-color: #ff3b4e;
189
+ color: #fff;
190
+ }
191
+ .bl-hint {
192
+ margin-left: auto;
193
+ font-size: 9px;
194
+ color: #8b91a3;
195
+ }
196
+ @media (prefers-reduced-motion: reduce) {
197
+ .bl-scan { display: none; }
198
+ }
199
+ `;
200
+
201
+ export class UIOverlay {
202
+ /**
203
+ * @param {import('./BeeLadybugCore.js').BeeLadybugCore} core
204
+ * @param {object} [options]
205
+ */
206
+ constructor(core, options = {}) {
207
+ this.core = core;
208
+ this.#options = options;
209
+ this.#host = null;
210
+ this.#root = null;
211
+ this.#els = null;
212
+ this.#stickBottom = true;
213
+ this.#lastLogId = 0;
214
+ this.#mounted = false;
215
+ }
216
+
217
+ #options;
218
+ #host;
219
+ #root;
220
+ #els;
221
+ #stickBottom;
222
+ #lastLogId;
223
+ #mounted;
224
+
225
+ /** @param {'auto' | HTMLElement} [target] */
226
+ mount(target = 'auto') {
227
+ if (typeof document === 'undefined') return this;
228
+ const node = target === 'auto' || !target ? document.body : target;
229
+ if (!node) {
230
+ document.addEventListener('DOMContentLoaded', () => this.mount(target), { once: true });
231
+ return this;
232
+ }
233
+ if (this.#mounted) this.unmount();
234
+
235
+ if (typeof customElements !== 'undefined' && !customElements.get(HOST_TAG)) {
236
+ customElements.define(HOST_TAG, class extends HTMLElement {});
237
+ }
238
+
239
+ this.#host = document.createElement(HOST_TAG);
240
+ this.#root = this.#host.attachShadow({ mode: 'open' });
241
+ this.#root.innerHTML = `
242
+ <style>${STYLES}</style>
243
+ <div class="bl-root" data-visible="true">
244
+ <div class="bl-panel">
245
+ <div class="bl-scan"></div>
246
+ <div class="bl-head">
247
+ <span class="bl-mark" aria-hidden="true"></span>
248
+ <span class="bl-title">BEE LADYBUG</span>
249
+ <span class="bl-badge">CORE</span>
250
+ <span class="bl-mode" data-el="mode">LIVE</span>
251
+ </div>
252
+ <div class="bl-stats">
253
+ <div>HOST <b data-el="hostFps">—</b></div>
254
+ <div>CORE <b data-el="coreFps">—</b></div>
255
+ <div>FRAME <b data-el="frame">0</b></div>
256
+ <div>SIM <b data-el="sim">00:00.0</b></div>
257
+ </div>
258
+ <div class="bl-graph-wrap">
259
+ <canvas class="bl-graph" data-el="graph" width="336" height="52"></canvas>
260
+ <div class="bl-graph-label">
261
+ <span data-el="graphName">HISTORY</span>
262
+ <span data-el="graphStats">min — max —</span>
263
+ </div>
264
+ </div>
265
+ <div class="bl-hud" data-el="hud"></div>
266
+ <div class="bl-console" data-el="console" role="log" aria-live="polite"></div>
267
+ <div class="bl-foot">
268
+ <button class="bl-btn" data-act="slow" type="button">F3 SLOW</button>
269
+ <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
270
+ <button class="bl-btn" data-act="live" type="button">1x LIVE</button>
271
+ <span class="bl-hint">F2 overlay</span>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ `;
276
+
277
+ this.#els = {
278
+ root: this.#root.querySelector('.bl-root'),
279
+ mode: this.#root.querySelector('[data-el="mode"]'),
280
+ hostFps: this.#root.querySelector('[data-el="hostFps"]'),
281
+ coreFps: this.#root.querySelector('[data-el="coreFps"]'),
282
+ frame: this.#root.querySelector('[data-el="frame"]'),
283
+ sim: this.#root.querySelector('[data-el="sim"]'),
284
+ graph: this.#root.querySelector('[data-el="graph"]'),
285
+ graphName: this.#root.querySelector('[data-el="graphName"]'),
286
+ graphStats: this.#root.querySelector('[data-el="graphStats"]'),
287
+ hud: this.#root.querySelector('[data-el="hud"]'),
288
+ console: this.#root.querySelector('[data-el="console"]')
289
+ };
290
+
291
+ this.#root.querySelector('.bl-foot').addEventListener('click', (event) => {
292
+ const btn = event.target.closest('[data-act]');
293
+ if (!btn) return;
294
+ const act = btn.getAttribute('data-act');
295
+ if (act === 'slow') this.core.applySlowMo();
296
+ else if (act === 'freeze') this.core.toggleFreeze();
297
+ else if (act === 'live') this.core.restoreRealtime();
298
+ });
299
+
300
+ this.#els.console.addEventListener('scroll', () => {
301
+ const el = this.#els.console;
302
+ this.#stickBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
303
+ });
304
+
305
+ node.appendChild(this.#host);
306
+ this.#mounted = true;
307
+ this.setVisible(this.core.visible);
308
+ this.#replayLogs();
309
+ this.render();
310
+ return this;
311
+ }
312
+
313
+ unmount() {
314
+ if (this.#host?.parentNode) this.#host.parentNode.removeChild(this.#host);
315
+ this.#host = null;
316
+ this.#root = null;
317
+ this.#els = null;
318
+ this.#mounted = false;
319
+ return this;
320
+ }
321
+
322
+ destroy() {
323
+ this.unmount();
324
+ this.core = null;
325
+ return this;
326
+ }
327
+
328
+ setVisible(visible) {
329
+ if (!this.#els) return this;
330
+ this.#els.root.dataset.visible = visible ? 'true' : 'false';
331
+ return this;
332
+ }
333
+
334
+ onPacket(packet) {
335
+ if (!this.#els || !packet) return;
336
+ if (packet.type === 'sys') return;
337
+ if (packet.level || packet.type === 'log' || packet.type === 'warn' || packet.type === 'error') {
338
+ this.#appendLine(packet);
339
+ }
340
+ }
341
+
342
+ onClear(channel) {
343
+ if (!this.#els) return;
344
+ if (!channel || channel === 'logs') {
345
+ this.#els.console.innerHTML = '';
346
+ this.#lastLogId = 0;
347
+ }
348
+ if (!channel || channel === 'hud') this.#els.hud.innerHTML = '';
349
+ }
350
+
351
+ render() {
352
+ if (!this.#els || !this.core) return;
353
+ const state = this.core.getState();
354
+ const host = this.core.getHistory('fps');
355
+ const primary = this.core.getPrimaryHistory();
356
+
357
+ this.#els.mode.textContent = state.mode;
358
+ this.#els.mode.dataset.mode = state.mode;
359
+ this.#els.hostFps.textContent = host.values.length ? host.last.toFixed(1) : '—';
360
+ this.#els.coreFps.textContent = state.coreFps.toFixed(1);
361
+ this.#els.frame.textContent = String(state.frameCount);
362
+ this.#els.sim.textContent = formatSimTime(state.simTime);
363
+
364
+ this.#paintGraph(primary);
365
+ this.#renderHud(state.hud);
366
+ this.#syncButtons(state);
367
+
368
+ if (this.#stickBottom) {
369
+ this.#els.console.scrollTop = this.#els.console.scrollHeight;
370
+ }
371
+ }
372
+
373
+ #replayLogs() {
374
+ const logs = this.core.getLogs();
375
+ for (let i = 0; i < logs.length; i++) this.#appendLine(logs[i]);
376
+ }
377
+
378
+ #appendLine(packet) {
379
+ if (!this.#els || packet.id <= this.#lastLogId) return;
380
+ this.#lastLogId = packet.id;
381
+ const line = document.createElement('div');
382
+ const level = packet.level || 'info';
383
+ line.className = `bl-line ${level}`;
384
+ const msg = packet.payload.message ?? packet.payload.value ?? packet.type;
385
+ line.innerHTML = `<span class="t">${formatSimTime(packet.simTime)}</span><span class="s">${escapeHtml(packet.source)}</span><span class="m">${escapeHtml(String(msg))}</span>`;
386
+ this.#els.console.appendChild(line);
387
+
388
+ const max = this.core.maxLogs;
389
+ while (this.#els.console.childElementCount > max) {
390
+ this.#els.console.removeChild(this.#els.console.firstChild);
391
+ }
392
+ }
393
+
394
+ #renderHud(hud) {
395
+ const skip = new Set(['frame', 'sim', 'scale', 'fps']);
396
+ const keys = Object.keys(hud).filter((k) => !skip.has(k));
397
+ if (!keys.length) {
398
+ this.#els.hud.innerHTML = `<span class="bl-k">idle</span><span class="bl-v">no adapter data</span>`;
399
+ return;
400
+ }
401
+ let html = '';
402
+ for (let i = 0; i < keys.length; i++) {
403
+ const key = keys[i];
404
+ const entry = hud[key];
405
+ const unit = entry.unit ? ` ${entry.unit}` : '';
406
+ html += `<span class="bl-k">${escapeHtml(key)}</span><span class="bl-v">${escapeHtml(fmtHud(entry.value))}${escapeHtml(unit)}</span>`;
407
+ }
408
+ this.#els.hud.innerHTML = html;
409
+ }
410
+
411
+ #syncButtons(state) {
412
+ const slow = this.#root.querySelector('[data-act="slow"]');
413
+ const freeze = this.#root.querySelector('[data-act="freeze"]');
414
+ const live = this.#root.querySelector('[data-act="live"]');
415
+ freeze.dataset.hot = state.frozen ? 'true' : 'false';
416
+ slow.dataset.hot = !state.frozen && state.timeScale !== 1 ? 'true' : 'false';
417
+ live.dataset.hot = !state.frozen && state.timeScale === 1 ? 'true' : 'false';
418
+ }
419
+
420
+ #paintGraph(series) {
421
+ const canvas = this.#els.graph;
422
+ const ctx = canvas.getContext('2d');
423
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
424
+ const cssW = canvas.clientWidth || 336;
425
+ const cssH = canvas.clientHeight || 52;
426
+ const pixelW = Math.round(cssW * dpr);
427
+ const pixelH = Math.round(cssH * dpr);
428
+ if (canvas.width !== pixelW || canvas.height !== pixelH) {
429
+ canvas.width = pixelW;
430
+ canvas.height = pixelH;
431
+ }
432
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
433
+ const w = cssW;
434
+ const h = cssH;
435
+ ctx.clearRect(0, 0, w, h);
436
+ ctx.fillStyle = '#05060a';
437
+ ctx.fillRect(0, 0, w, h);
438
+
439
+ ctx.strokeStyle = 'rgba(255, 211, 106, 0.12)';
440
+ ctx.lineWidth = 1;
441
+ ctx.beginPath();
442
+ ctx.moveTo(0, Math.floor(h / 2) + 0.5);
443
+ ctx.lineTo(w, Math.floor(h / 2) + 0.5);
444
+ ctx.stroke();
445
+
446
+ const values = series.values;
447
+ this.#els.graphName.textContent = (series.name || 'HISTORY').toUpperCase();
448
+ if (!values.length) {
449
+ this.#els.graphStats.textContent = 'min — max —';
450
+ return;
451
+ }
452
+
453
+ const min = series.min;
454
+ const max = series.max;
455
+ const span = max - min || 1;
456
+ ctx.beginPath();
457
+ for (let i = 0; i < values.length; i++) {
458
+ const x = (i / Math.max(values.length - 1, 1)) * (w - 2) + 1;
459
+ const y = h - 3 - ((values[i] - min) / span) * (h - 6);
460
+ if (i === 0) ctx.moveTo(x, y);
461
+ else ctx.lineTo(x, y);
462
+ }
463
+ ctx.strokeStyle = '#ff3b4e';
464
+ ctx.lineWidth = 1.5;
465
+ ctx.stroke();
466
+
467
+ this.#els.graphStats.textContent = `min ${min.toFixed(1)} max ${max.toFixed(1)} avg ${series.avg.toFixed(1)}`;
468
+ }
469
+ }
470
+
471
+ function fmtHud(value) {
472
+ if (value == null) return '—';
473
+ if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
474
+ if (typeof value === 'object') {
475
+ try {
476
+ return JSON.stringify(value);
477
+ } catch {
478
+ return String(value);
479
+ }
480
+ }
481
+ return String(value);
482
+ }
483
+
484
+ function escapeHtml(value) {
485
+ return String(value)
486
+ .replace(/&/g, '&amp;')
487
+ .replace(/</g, '&lt;')
488
+ .replace(/>/g, '&gt;')
489
+ .replace(/"/g, '&quot;');
490
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { BeeLadybugCore, BEE_LADYBUG_VERSION, CORE_DEFAULTS, WELL_KNOWN_TYPES, formatSimTime } from './core/BeeLadybugCore.js';
2
+ export { UIOverlay } from './core/UIOverlay.js';
3
+ export { CanvasAdapter, CANVAS_ADAPTER_DEFAULTS } from './adapters/CanvasAdapter.js';
4
+ export { WebDOMAdapter, WEB_DOM_ADAPTER_DEFAULTS } from './adapters/WebDOMAdapter.js';