virtual-tree-canvas 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -0
  3. package/package.json +40 -0
  4. package/src/assets/.gitkeep +1 -0
  5. package/src/benchmark/benchmark-stats.js +99 -0
  6. package/src/benchmark/index.js +2 -0
  7. package/src/benchmark/renderer-state.js +62 -0
  8. package/src/core/asset-manager.js +25 -0
  9. package/src/core/culling.js +12 -0
  10. package/src/core/event-emitter.js +21 -0
  11. package/src/core/icon-registry.js +173 -0
  12. package/src/core/index.js +19 -0
  13. package/src/core/layout-engine.js +64 -0
  14. package/src/core/patch-batcher.js +29 -0
  15. package/src/core/scene.js +13 -0
  16. package/src/core/search-index.js +70 -0
  17. package/src/core/selection-manager.js +38 -0
  18. package/src/core/theme-manager.js +162 -0
  19. package/src/core/tree-column-model.js +151 -0
  20. package/src/core/tree-expansion-manager.js +67 -0
  21. package/src/core/tree-index.js +79 -0
  22. package/src/core/tree-model.js +80 -0
  23. package/src/core/tree-view-viewport.js +69 -0
  24. package/src/core/tree-worker-client.js +50 -0
  25. package/src/core/tree-worker-operations.js +152 -0
  26. package/src/core/types.js +44 -0
  27. package/src/core/viewport.js +66 -0
  28. package/src/core/visible-row-model.js +137 -0
  29. package/src/index.js +6 -0
  30. package/src/input/index.js +2 -0
  31. package/src/input/pointer-controller.js +66 -0
  32. package/src/input/tree-view-input-controller.js +235 -0
  33. package/src/inspector/cell-editor-manager.js +256 -0
  34. package/src/inspector/editor-resolver.js +33 -0
  35. package/src/inspector/index.js +4 -0
  36. package/src/inspector/model-inspector-builder.js +139 -0
  37. package/src/inspector/model-path.js +48 -0
  38. package/src/renderers/canvas2d-renderer.js +120 -0
  39. package/src/renderers/index.js +3 -0
  40. package/src/renderers/renderer.js +10 -0
  41. package/src/renderers/tree-row-renderer.js +443 -0
  42. package/src/tree-view-controller.js +825 -0
  43. package/src/workers/tree-worker.js +59 -0
@@ -0,0 +1,443 @@
1
+ import { IconRegistry } from '../core/icon-registry.js';
2
+
3
+ export class TreeRowRenderer {
4
+ constructor({ iconRegistry } = {}) {
5
+ this.canvas = null;
6
+ this.ctx = null;
7
+ this.scene = null;
8
+ this.iconRegistry = iconRegistry ?? new IconRegistry();
9
+ this.renderedRows = 0;
10
+ }
11
+
12
+ /** @param {HTMLCanvasElement} canvas */
13
+ initialize(canvas) {
14
+ this.canvas = canvas;
15
+ this.ctx = canvas.getContext('2d', { alpha: false });
16
+ if (!this.ctx) throw new Error('Canvas2D context is not available');
17
+ }
18
+
19
+ setScene(scene) {
20
+ this.scene = scene;
21
+ }
22
+
23
+ updateDynamicState(_patches) {
24
+ // Canvas2D reads the state map directly; patches still stay on the hot path.
25
+ }
26
+
27
+ render(scene) {
28
+ if (scene?.rows) this.scene = scene;
29
+ if (!this.canvas || !this.ctx || !this.scene) return;
30
+ const { viewport, theme } = this.scene;
31
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
32
+ const width = Math.floor(this.canvas.clientWidth * dpr);
33
+ const height = Math.floor(this.canvas.clientHeight * dpr);
34
+ if (this.canvas.width !== width || this.canvas.height !== height) {
35
+ this.canvas.width = width;
36
+ this.canvas.height = height;
37
+ viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
38
+ }
39
+
40
+ const ctx = this.ctx;
41
+ const colors = theme.colors;
42
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
43
+ ctx.fillStyle = colors.background;
44
+ ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
45
+
46
+ ctx.save();
47
+ ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
48
+ this.#drawHeader(ctx);
49
+ this.#drawRows(ctx);
50
+ ctx.restore();
51
+ }
52
+
53
+ #drawHeader(ctx) {
54
+ const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
55
+ const colors = theme.colors;
56
+ ctx.save();
57
+ ctx.fillStyle = colors.row;
58
+ ctx.fillRect(0, 0, viewport.viewportWidth, viewport.headerHeight);
59
+ ctx.translate(-viewport.scrollX, 0);
60
+ ctx.font = theme.font;
61
+ ctx.textBaseline = 'middle';
62
+
63
+ for (const column of columns) {
64
+ if (headerFilter && column.kind === 'inspectorPane') {
65
+ this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
66
+ } else {
67
+ ctx.fillStyle = colors.textMuted;
68
+ ctx.fillText(column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
69
+ }
70
+ if (sort?.columnId === column.id && sort.direction) {
71
+ ctx.fillText(sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
72
+ }
73
+ ctx.strokeStyle = colors.border;
74
+ ctx.beginPath();
75
+ ctx.moveTo(column.x + column.width + 0.5, 0);
76
+ ctx.lineTo(column.x + column.width + 0.5, viewport.headerHeight);
77
+ ctx.stroke();
78
+ }
79
+ ctx.restore();
80
+
81
+ ctx.strokeStyle = colors.border;
82
+ ctx.beginPath();
83
+ ctx.moveTo(0, viewport.headerHeight + 0.5);
84
+ ctx.lineTo(viewport.viewportWidth, viewport.headerHeight + 0.5);
85
+ ctx.stroke();
86
+ }
87
+
88
+ #drawHeaderFilter(ctx, column, viewport, theme, filterQuery) {
89
+ const colors = theme.colors;
90
+ const x = column.x + 8;
91
+ const y = 5;
92
+ const width = Math.max(40, column.width - 16);
93
+ const height = Math.max(18, viewport.headerHeight - 10);
94
+ ctx.fillStyle = colors.progressTrack;
95
+ roundRect(ctx, x, y, width, height, 4);
96
+ ctx.fill();
97
+ ctx.strokeStyle = colors.border;
98
+ ctx.stroke();
99
+ ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
100
+ ctx.fillText(filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
101
+ }
102
+
103
+ #drawRows(ctx) {
104
+ const { rows, visibleRange, viewport } = this.scene;
105
+ this.renderedRows = visibleRange.count;
106
+ ctx.save();
107
+ ctx.translate(-viewport.scrollX, viewport.headerHeight - viewport.scrollY);
108
+ for (let i = visibleRange.first; i <= visibleRange.last; i++) {
109
+ const row = rows[i];
110
+ if (row) this.#drawRow(ctx, row);
111
+ }
112
+ ctx.restore();
113
+ }
114
+
115
+ #drawRow(ctx, row) {
116
+ const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
117
+ const node = nodes[row.nodeIndex];
118
+ const state = dynamicState.get(row.nodeId) ?? {};
119
+ const style = resolveNodeStyle(theme, node, state);
120
+ const colors = theme.colors;
121
+ const selected = selection.has(row.nodeId) || state.selected;
122
+ const highlighted = state.highlighted || searchMatches.has(row.nodeId);
123
+ const hovered = hoverNodeId === row.nodeId;
124
+ const focused = focusNodeId === row.nodeId;
125
+ const y = row.y;
126
+ const rowWidth = Math.max(viewport.contentWidth, viewport.scrollX + viewport.viewportWidth);
127
+
128
+ ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
129
+ ctx.fillRect(0, y, rowWidth, row.height);
130
+
131
+ this.#drawIndentGuides(ctx, row, colors);
132
+
133
+ for (const column of columns) {
134
+ const rect = { x: column.x, y, width: column.width, height: row.height };
135
+ this.#drawCell(ctx, { node, state, row, column, rect, theme, style });
136
+ ctx.strokeStyle = colors.border;
137
+ ctx.beginPath();
138
+ ctx.moveTo(column.x + column.width + 0.5, y);
139
+ ctx.lineTo(column.x + column.width + 0.5, y + row.height);
140
+ ctx.stroke();
141
+ }
142
+
143
+ ctx.strokeStyle = colors.border;
144
+ ctx.beginPath();
145
+ ctx.moveTo(0, y + row.height + 0.5);
146
+ ctx.lineTo(rowWidth, y + row.height + 0.5);
147
+ ctx.stroke();
148
+
149
+ if (state.updated && node.data?.inspector) {
150
+ ctx.fillStyle = theme.colors.focus;
151
+ ctx.beginPath();
152
+ ctx.arc(rowWidth - 10, y + row.height / 2, 3, 0, Math.PI * 2);
153
+ ctx.fill();
154
+ }
155
+
156
+ if (focused) {
157
+ ctx.strokeStyle = colors.focus;
158
+ ctx.strokeRect(1.5, y + 2.5, rowWidth - 3, row.height - 5);
159
+ }
160
+ }
161
+
162
+ #drawCell(ctx, cell) {
163
+ if (cell.column.render) {
164
+ cell.column.render(ctx, cell);
165
+ return;
166
+ }
167
+ if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
168
+ else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
169
+ else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
170
+ else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
171
+ else if (cell.column.kind === 'inspectorDescription') this.#drawInspectorDescriptionCell(ctx, cell);
172
+ else if (cell.column.kind === 'status') this.#drawStatusCell(ctx, cell);
173
+ else if (cell.column.kind === 'progress') this.#drawProgressCell(ctx, cell);
174
+ else this.#drawTextCell(ctx, cell);
175
+ }
176
+
177
+ #drawInspectorPaneCell(ctx, { node, row, rect, theme }) {
178
+ const data = node.data ?? {};
179
+ const colors = theme.colors;
180
+ const indentX = rect.x + row.depth * theme.indentWidth;
181
+ const labelX = indentX + 28;
182
+ const cy = rect.y + rect.height / 2;
183
+ this.#drawChevron(ctx, indentX + 10, cy, row, colors);
184
+ ctx.font = theme.font;
185
+ ctx.textBaseline = 'middle';
186
+ ctx.textAlign = 'left';
187
+ ctx.globalAlpha = data.disabled ? 0.45 : 1;
188
+ ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
189
+ ctx.fillText(node.label ?? node.id, labelX, cy, 180);
190
+
191
+ const editorX = rect.x + Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
192
+ const editorWidth = Math.max(80, rect.x + rect.width - editorX - 14);
193
+ if (data.valueType === 'array') {
194
+ ctx.fillStyle = colors.textMuted;
195
+ ctx.fillText(data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
196
+ this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
197
+ this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
198
+ } else if (data.valueType === 'object') {
199
+ // Pane mode uses object rows as folders; the label is enough.
200
+ } else {
201
+ this.#drawInspectorValueCell(ctx, {
202
+ node,
203
+ rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
204
+ theme,
205
+ });
206
+ }
207
+ ctx.globalAlpha = 1;
208
+ }
209
+
210
+ #drawInspectorValueCell(ctx, { node, rect, theme }) {
211
+ const data = node.data ?? {};
212
+ const meta = data.meta ?? {};
213
+ const disabled = data.disabled;
214
+ const readonly = data.readonly;
215
+ const x = rect.x + 10;
216
+ const y = rect.y + 5;
217
+ const width = Math.max(24, rect.width - 20);
218
+ const height = rect.height - 10;
219
+ ctx.font = theme.font;
220
+ ctx.textBaseline = 'middle';
221
+ ctx.textAlign = 'left';
222
+ ctx.globalAlpha = disabled ? 0.45 : 1;
223
+
224
+ if (data.editorType === 'checkbox') {
225
+ this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
226
+ } else if (data.editorType === 'range') {
227
+ this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme);
228
+ } else if (data.editorType === 'color') {
229
+ ctx.fillStyle = String(data.value || '#000000');
230
+ ctx.fillRect(x, y + 2, 28, height - 4);
231
+ ctx.strokeStyle = theme.colors.border;
232
+ ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
233
+ this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme);
234
+ } else if (data.editorType === 'button') {
235
+ ctx.fillStyle = readonly || disabled ? theme.colors.progressTrack : theme.colors.rowHover;
236
+ roundRect(ctx, x, y, meta.fullWidthButton ? width : Math.min(width, 140), height, 4);
237
+ ctx.fill();
238
+ ctx.fillStyle = theme.colors.text;
239
+ ctx.textAlign = 'center';
240
+ ctx.fillText(meta.button ?? node.label, x + (meta.fullWidthButton ? width : Math.min(width, 140)) / 2, rect.y + rect.height / 2);
241
+ ctx.textAlign = 'left';
242
+ } else if (data.editorType === 'select') {
243
+ ctx.fillStyle = theme.colors.rowHover;
244
+ roundRect(ctx, x, y, Math.min(width, 180), height, 4);
245
+ ctx.fill();
246
+ this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, Math.min(width, 180) - 22, theme);
247
+ ctx.fillStyle = theme.colors.chevron;
248
+ ctx.fillText('v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2);
249
+ } else {
250
+ this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
251
+ }
252
+
253
+ if (meta.updated) {
254
+ ctx.fillStyle = theme.colors.focus;
255
+ ctx.beginPath();
256
+ ctx.arc(rect.x + rect.width - 10, rect.y + rect.height / 2, 3, 0, Math.PI * 2);
257
+ ctx.fill();
258
+ }
259
+ ctx.globalAlpha = 1;
260
+ }
261
+
262
+ #drawInspectorTypeCell(ctx, { node, rect, theme }) {
263
+ this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
264
+ }
265
+
266
+ #drawInspectorDescriptionCell(ctx, { node, rect, theme }) {
267
+ this.#drawMutedText(ctx, node.data?.meta?.description ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
268
+ }
269
+
270
+ #drawCheckbox(ctx, x, y, checked, theme) {
271
+ ctx.strokeStyle = theme.colors.textMuted;
272
+ ctx.strokeRect(x + 0.5, y + 0.5, 14, 14);
273
+ if (!checked) return;
274
+ ctx.strokeStyle = theme.colors.progressFill;
275
+ ctx.lineWidth = 2;
276
+ ctx.beginPath();
277
+ ctx.moveTo(x + 3, y + 7);
278
+ ctx.lineTo(x + 6, y + 11);
279
+ ctx.lineTo(x + 12, y + 3);
280
+ ctx.stroke();
281
+ ctx.lineWidth = 1;
282
+ }
283
+
284
+ #drawInspectorRange(ctx, x, y, width, data, theme) {
285
+ const meta = data.meta ?? {};
286
+ const min = meta.min ?? 0;
287
+ const max = meta.max ?? 100;
288
+ const value = typeof data.value === 'number' ? data.value : min;
289
+ const ratio = max === min ? 0 : clamp01((value - min) / (max - min));
290
+ const valueWidth = Math.min(64, Math.max(42, width * 0.28));
291
+ const gap = 8;
292
+ const barWidth = Math.max(24, width - valueWidth - gap);
293
+ ctx.fillStyle = theme.colors.progressTrack;
294
+ ctx.fillRect(x, y, barWidth, 8);
295
+ ctx.fillStyle = theme.colors.progressFill;
296
+ ctx.fillRect(x, y, barWidth * ratio, 8);
297
+ ctx.fillStyle = theme.colors.rowHover;
298
+ roundRect(ctx, x + barWidth + gap, y - 6, valueWidth, 20, 3);
299
+ ctx.fill();
300
+ ctx.fillStyle = theme.colors.text;
301
+ ctx.textAlign = 'right';
302
+ ctx.fillText(String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
303
+ ctx.textAlign = 'left';
304
+ }
305
+
306
+ #drawSmallButton(ctx, x, y, width, height, label, theme) {
307
+ ctx.fillStyle = theme.colors.rowHover;
308
+ roundRect(ctx, x, y, width, height, 3);
309
+ ctx.fill();
310
+ ctx.fillStyle = theme.colors.text;
311
+ ctx.textAlign = 'center';
312
+ ctx.textBaseline = 'middle';
313
+ ctx.fillText(label, x + width / 2, y + height / 2, width - 4);
314
+ ctx.textAlign = 'left';
315
+ }
316
+
317
+ #drawMutedText(ctx, text, x, y, width, theme, readonly = false) {
318
+ ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
319
+ ctx.font = theme.font;
320
+ ctx.textBaseline = 'middle';
321
+ ctx.textAlign = 'left';
322
+ ctx.fillText(String(text ?? ''), x, y, Math.max(10, width));
323
+ }
324
+
325
+ #drawTreeCell(ctx, { node, row, rect, theme, style }) {
326
+ const colors = theme.colors;
327
+ const x = rect.x + row.depth * theme.indentWidth;
328
+ const cy = rect.y + rect.height / 2;
329
+ this.#drawChevron(ctx, x + 10, cy, row, colors);
330
+ this.iconRegistry.draw(ctx, style.icon, x + 27, rect.y + 6, 15, style.color);
331
+ ctx.fillStyle = colors.text;
332
+ ctx.font = theme.font;
333
+ ctx.textBaseline = 'middle';
334
+ ctx.textAlign = 'left';
335
+ ctx.fillText(node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
336
+ }
337
+
338
+ #drawStatusCell(ctx, { rect, style, theme }) {
339
+ const badgeWidth = Math.min(58, rect.width - 12);
340
+ const x = rect.x + (rect.width - badgeWidth) / 2;
341
+ const y = rect.y + (rect.height - 16) / 2;
342
+ ctx.fillStyle = style.status.color;
343
+ roundRect(ctx, x, y, badgeWidth, 16, 8);
344
+ ctx.fill();
345
+ ctx.fillStyle = theme.colors.badgeText;
346
+ ctx.font = '10px system-ui, sans-serif';
347
+ ctx.textAlign = 'center';
348
+ ctx.textBaseline = 'middle';
349
+ ctx.fillText(style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
350
+ ctx.textAlign = 'left';
351
+ }
352
+
353
+ #drawProgressCell(ctx, { state, rect, theme, style }) {
354
+ if (state.progress === undefined) return this.#drawTextCell(ctx, { state, rect, theme, style, column: { align: 'right', value: () => '' }, node: {} });
355
+ const barX = rect.x + 10;
356
+ const barY = rect.y + (rect.height - 8) / 2;
357
+ const barWidth = Math.max(20, rect.width - 20);
358
+ ctx.fillStyle = theme.colors.progressTrack;
359
+ ctx.fillRect(barX, barY, barWidth, 8);
360
+ ctx.fillStyle = state.color ?? theme.colors.progressFill;
361
+ ctx.fillRect(barX, barY, barWidth * clamp01(state.progress), 8);
362
+ }
363
+
364
+ #drawTextCell(ctx, { node, state, rect, column, theme }) {
365
+ let value = column.value(node, state);
366
+ if (column.kind === 'updated' && typeof value === 'number') value = formatTime(value);
367
+ if (typeof value === 'number') value = Math.round(value).toString();
368
+ ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : theme.colors.textMuted;
369
+ ctx.font = theme.font;
370
+ ctx.textBaseline = 'middle';
371
+ ctx.textAlign = column.align;
372
+ const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
373
+ ctx.fillText(String(value ?? ''), x, rect.y + rect.height / 2, rect.width - 20);
374
+ ctx.textAlign = 'left';
375
+ }
376
+
377
+ #drawIndentGuides(ctx, row, colors) {
378
+ if (!row.depth) return;
379
+ ctx.strokeStyle = colors.guide;
380
+ ctx.lineWidth = 1;
381
+ ctx.beginPath();
382
+ for (let depth = 0; depth < row.depth; depth++) {
383
+ const guideX = depth * this.scene.theme.indentWidth + 10.5;
384
+ ctx.moveTo(guideX, row.y);
385
+ ctx.lineTo(guideX, row.y + row.height);
386
+ }
387
+ ctx.stroke();
388
+ }
389
+
390
+ #drawChevron(ctx, x, y, row, colors) {
391
+ ctx.fillStyle = row.hasChildren ? colors.chevron : colors.textMuted;
392
+ if (!row.hasChildren) {
393
+ ctx.beginPath();
394
+ ctx.arc(x, y, 1.5, 0, Math.PI * 2);
395
+ ctx.fill();
396
+ return;
397
+ }
398
+ ctx.beginPath();
399
+ if (row.expanded) {
400
+ ctx.moveTo(x - 5, y - 2);
401
+ ctx.lineTo(x + 5, y - 2);
402
+ ctx.lineTo(x, y + 4);
403
+ } else {
404
+ ctx.moveTo(x - 2, y - 5);
405
+ ctx.lineTo(x - 2, y + 5);
406
+ ctx.lineTo(x + 4, y);
407
+ }
408
+ ctx.closePath();
409
+ ctx.fill();
410
+ }
411
+ }
412
+
413
+ function resolveNodeStyle(theme, node, state = {}) {
414
+ const typeRule = theme.types[node?.type ?? ''] ?? {};
415
+ const status = theme.statuses[state.status ?? 0] ?? { label: String(state.status ?? ''), color: theme.colors.textMuted };
416
+ return {
417
+ icon: state.icon ?? node?.icon ?? typeRule.icon ?? 'placeholder',
418
+ color: state.color ?? typeRule.color ?? theme.colors.progressFill,
419
+ typeColor: typeRule.color ?? theme.colors.progressFill,
420
+ status,
421
+ };
422
+ }
423
+
424
+ function clamp01(value) {
425
+ return Math.max(0, Math.min(1, value));
426
+ }
427
+
428
+ function roundRect(ctx, x, y, width, height, radius) {
429
+ ctx.beginPath();
430
+ ctx.moveTo(x + radius, y);
431
+ ctx.lineTo(x + width - radius, y);
432
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
433
+ ctx.lineTo(x + width, y + height - radius);
434
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
435
+ ctx.lineTo(x + radius, y + height);
436
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
437
+ ctx.lineTo(x, y + radius);
438
+ ctx.quadraticCurveTo(x, y, x + radius, y);
439
+ }
440
+
441
+ function formatTime(value) {
442
+ return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
443
+ }