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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 virtual-tree-canvas contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,335 @@
1
+ # virtual-tree-canvas
2
+
3
+ `virtual-tree-canvas` is a framework-agnostic Canvas2D virtual tree/table widget for large hierarchical datasets.
4
+
5
+ It behaves like a normal TreeView or tree-table component, but renders into a canvas instead of creating one DOM element per row.
6
+
7
+ ## Features
8
+
9
+ - Virtualized tree rows
10
+ - Tree-table columns
11
+ - Expand/collapse
12
+ - Single and multi-selection
13
+ - Search and focus
14
+ - Keyboard navigation
15
+ - Horizontal and vertical scrolling
16
+ - Themes and type-based styles
17
+ - Canvas vector icons and image icons
18
+ - Batched dynamic state updates
19
+ - Benchmark/demo mode
20
+
21
+ No React, Web Components, or frontend framework required.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install virtual-tree-canvas
27
+ ```
28
+
29
+ ## Performance Model
30
+
31
+ The renderer only draws visible rows plus a small overscan range.
32
+
33
+ Dynamic updates are handled as patches:
34
+
35
+ ```js
36
+ tree.setDynamicState([
37
+ { id: 'node-1', state: { status: 1, progress: 0.7, value: 42 } }
38
+ ]);
39
+ ```
40
+
41
+ Dynamic patches update node state without rebuilding:
42
+
43
+ - tree indexes
44
+ - visible rows
45
+ - expansion state
46
+ - layout
47
+
48
+ Cold-path operations such as `setData()`, expand/collapse, and search may rebuild the visible row list.
49
+
50
+ For large datasets, `enableWorkers()` moves search and filtered row rebuilds off the main thread when browser Workers are available. The synchronous APIs remain available for small datasets, tests, and custom integrations.
51
+
52
+ ## Basic Usage
53
+
54
+ ```js
55
+ import { TreeViewController } from 'virtual-tree-canvas';
56
+
57
+ const canvas = document.querySelector('canvas');
58
+ const tree = new TreeViewController({ canvas });
59
+
60
+ tree.setData([
61
+ { id: 'root', label: 'Root', type: 'root' },
62
+ { id: 'child-1', parentId: 'root', label: 'Child', type: 'sensor' }
63
+ ]);
64
+ ```
65
+
66
+ ## Public API
67
+
68
+ ```js
69
+ tree.setData(nodes);
70
+ tree.setModel(model, meta, { presentation: 'pane' });
71
+ tree.setDynamicState(patches);
72
+
73
+ tree.expand(nodeId);
74
+ tree.collapse(nodeId);
75
+ tree.toggle(nodeId);
76
+ tree.expandAll();
77
+ tree.collapseAll();
78
+
79
+ tree.search(query);
80
+ tree.searchAsync(query);
81
+ tree.clearSearch();
82
+ tree.getSearchState();
83
+ tree.nextSearchResult();
84
+ tree.previousSearchResult();
85
+ tree.setFilter(queryOrPredicate);
86
+ tree.setFilterAsync(query);
87
+ tree.clearFilter();
88
+
89
+ tree.focusNode(nodeId);
90
+ tree.scrollToNode(nodeId, 'center');
91
+
92
+ tree.getSelection();
93
+ tree.setSelection(['node-1', 'node-2']);
94
+ tree.clearSelection();
95
+
96
+ tree.setTheme(theme);
97
+ tree.setColumns(columns);
98
+ tree.resizeColumn(columnId, width);
99
+ tree.moveColumn(columnId, targetIndex);
100
+ tree.sortBy(columnId, 'asc');
101
+ tree.clearSort();
102
+ tree.registerIcon('custom', imageOrUrlOrDrawFunction);
103
+
104
+ tree.enableWorkers();
105
+ tree.disableWorkers();
106
+ ```
107
+
108
+ ## Model Inspector
109
+
110
+ `setModel(model, meta, options)` renders plain JSON-like objects as an editable inspector.
111
+
112
+ ```js
113
+ tree.setModel(
114
+ {
115
+ sensor: { enabled: true, range: 72, mode: 'track' },
116
+ tracks: [{ id: 'T-100', speed: 430 }]
117
+ },
118
+ {
119
+ 'sensor.range': { min: 0, max: 120, step: 1, integer: true },
120
+ 'sensor.mode': { options: { Search: 'search', Track: 'track' } },
121
+ 'tracks.*.speed': { min: 0, max: 900, step: 5 },
122
+ 'tracks.*.id': { readonly: true }
123
+ }
124
+ );
125
+ ```
126
+
127
+ Inspector options:
128
+
129
+ ```js
130
+ tree.setModel(model, meta, {
131
+ presentation: 'pane',
132
+ flatRoot: true, // render root properties directly
133
+ enforceMeta: true, // fields without metadata are readonly/disabled
134
+ filter: true // use the header as a filter input
135
+ });
136
+ ```
137
+
138
+ Presentations:
139
+
140
+ ```js
141
+ tree.setModel(model, meta, { presentation: 'pane' }); // compact folders + key/value controls
142
+ tree.setModel(model, meta, { presentation: 'table' }); // Property | Value | Type | Description
143
+ ```
144
+
145
+ Metadata is path-based. Dot paths target object properties, and array items use numeric indexes or `*` wildcards:
146
+
147
+ ```text
148
+ sensor.range
149
+ tracks.0.speed
150
+ tracks.*.speed
151
+ ```
152
+
153
+ Inspector editors are inferred from values and metadata: checkbox, range, number, text, select, color, button, object, and array.
154
+
155
+ Inspector events:
156
+
157
+ ```js
158
+ tree.on('valuechange', (event) => {});
159
+ tree.on('modelchange', (event) => {});
160
+ tree.on('action', (event) => {});
161
+ ```
162
+
163
+ `scrollToNode()` supports:
164
+
165
+ ```text
166
+ start | center | end | nearest
167
+ ```
168
+
169
+ ## Events
170
+
171
+ ```js
172
+ tree.on('nodehover', (event) => {});
173
+ tree.on('nodeclick', (event) => {});
174
+ tree.on('nodedblclick', (event) => {});
175
+ tree.on('selectionchange', (event) => {});
176
+ tree.on('expand', (event) => {});
177
+ tree.on('collapse', (event) => {});
178
+ tree.on('focuschange', (event) => {});
179
+ tree.on('searchchange', (event) => {});
180
+ tree.on('filterchange', (event) => {});
181
+ tree.on('sortchange', (event) => {});
182
+ tree.on('columnschange', (event) => {});
183
+ tree.on('viewportchange', (event) => {});
184
+ ```
185
+
186
+ The event payload is available as `event.detail`.
187
+
188
+ ## Columns
189
+
190
+ ```js
191
+ tree.setColumns([
192
+ {
193
+ id: 'name',
194
+ label: 'Name',
195
+ width: 340,
196
+ minWidth: 160,
197
+ align: 'left',
198
+ kind: 'tree',
199
+ value: (node) => node.label ?? node.id
200
+ },
201
+ {
202
+ id: 'status',
203
+ label: 'Status',
204
+ width: 84,
205
+ minWidth: 64,
206
+ align: 'center',
207
+ kind: 'status',
208
+ value: (_node, state) => state.status
209
+ }
210
+ ]);
211
+ ```
212
+
213
+ Column shape:
214
+
215
+ ```js
216
+ {
217
+ id: 'status',
218
+ label: 'Status',
219
+ width: 80,
220
+ minWidth: 40,
221
+ align: 'left' | 'center' | 'right',
222
+ kind: 'tree' | 'status' | 'value' | 'progress' | 'type' | 'updated' | 'text',
223
+ sortable: true,
224
+ value: (node, state) => string | number,
225
+ render: (ctx, cell) => {}
226
+ }
227
+ ```
228
+
229
+ Built-in helpers:
230
+
231
+ ```js
232
+ import { builtInColumns, defaultTreeTableColumns } from 'virtual-tree-canvas';
233
+ ```
234
+
235
+ The tree column renders indentation, chevron, icon, and label. Other columns render table cells and participate in horizontal scrolling.
236
+
237
+ Columns can be resized, reordered, and sorted through the controller API. The demo also supports header click sorting and drag-to-resize on column edges.
238
+
239
+ ## Themes
240
+
241
+ ```js
242
+ tree.setTheme({
243
+ rowHeight: 28,
244
+ indentWidth: 18,
245
+ font: '12px system-ui',
246
+ colors: {
247
+ background: '#0b1020',
248
+ row: '#0b1020',
249
+ rowHover: '#111827',
250
+ rowSelected: '#1e3a8a',
251
+ rowHighlighted: '#3b0764',
252
+ text: '#e5e7eb',
253
+ textMuted: '#94a3b8',
254
+ guide: '#1f2937',
255
+ chevron: '#94a3b8',
256
+ focus: '#38bdf8',
257
+ progressTrack: '#1f2937',
258
+ progressFill: '#22c55e',
259
+ badgeText: '#ffffff'
260
+ },
261
+ types: {
262
+ root: { icon: 'folder', color: '#38bdf8' },
263
+ platform: { icon: 'aircraft', color: '#60a5fa' },
264
+ sensor: { icon: 'radar', color: '#34d399' },
265
+ warning: { icon: 'warning', color: '#facc15' },
266
+ error: { icon: 'error', color: '#ef4444' }
267
+ },
268
+ statuses: {
269
+ 0: { label: 'OK', color: '#22c55e' },
270
+ 1: { label: 'WARN', color: '#facc15' },
271
+ 2: { label: 'ERR', color: '#ef4444' }
272
+ }
273
+ });
274
+ ```
275
+
276
+ Built-in theme exports:
277
+
278
+ ```js
279
+ import { themes, darkTheme, lightTheme, tacticalTheme } from 'virtual-tree-canvas';
280
+ ```
281
+
282
+ Style resolution order:
283
+
284
+ 1. Dynamic state override, such as `state.color`
285
+ 2. Node type rule, such as `theme.types.sensor`
286
+ 3. Default theme color
287
+
288
+ ## Icons
289
+
290
+ Built-in Canvas2D vector icons:
291
+
292
+ ```text
293
+ folder, aircraft, radar, warning, error, task, track, placeholder
294
+ ```
295
+
296
+ Register custom icons:
297
+
298
+ ```js
299
+ tree.registerIcon('camera', imageElement);
300
+ tree.registerIcon('camera-url', '/icons/camera.png');
301
+ tree.registerIcon('custom-vector', (ctx, x, y, size, color) => {
302
+ ctx.fillStyle = color;
303
+ ctx.fillRect(x, y, size, size);
304
+ });
305
+ ```
306
+
307
+ Icons are cached by name and drawn only for rendered rows.
308
+
309
+ ## Demo
310
+
311
+ ```bash
312
+ npm run demo
313
+ ```
314
+
315
+ Open:
316
+
317
+ ```text
318
+ http://localhost:4173/demo/
319
+ ```
320
+
321
+ Inspector demo:
322
+
323
+ ```text
324
+ http://localhost:4173/demo/inspector.html
325
+ ```
326
+
327
+ The demo includes dataset sizes, update rates, benchmark stats, search, filtering, selection, expand/collapse, themes, and tree-table columns.
328
+
329
+ Benchmark stats separate frame, patch, scene, render, search, filter, and worker timings. Use "Copy JSON" to export the current sample.
330
+
331
+ ## Tests
332
+
333
+ ```bash
334
+ npm test
335
+ ```
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "virtual-tree-canvas",
3
+ "version": "0.1.0",
4
+ "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./core": "./src/core/index.js",
11
+ "./inspector": "./src/inspector/index.js",
12
+ "./renderers": "./src/renderers/index.js",
13
+ "./input": "./src/input/index.js",
14
+ "./benchmark": "./src/benchmark/index.js",
15
+ "./workers/tree-worker.js": "./src/workers/tree-worker.js"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "private": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "demo": "python3 -m http.server 4173 --bind localhost",
28
+ "test": "node --test test/tree-view-controller.test.js test/theme-icons.test.js test/tree-columns.test.js test/benchmark.test.js test/tree-worker-operations.test.js test/model-inspector.test.js"
29
+ },
30
+ "keywords": [
31
+ "tree",
32
+ "visualization",
33
+ "canvas",
34
+ "canvas2d",
35
+ "virtual-list",
36
+ "tree-table",
37
+ "hierarchy"
38
+ ],
39
+ "license": "MIT"
40
+ }
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,99 @@
1
+ export class BenchmarkStats {
2
+ constructor({ maxSamples = 240 } = {}) {
3
+ this.maxSamples = maxSamples;
4
+ this.samples = {
5
+ frameMs: [],
6
+ patchMs: [],
7
+ sceneMs: [],
8
+ renderMs: [],
9
+ inputLatencyMs: [],
10
+ searchMs: [],
11
+ filterMs: [],
12
+ workerMs: [],
13
+ };
14
+ this.frameCount = 0;
15
+ this.patchCount = 0;
16
+ this.lastSecondAt = null;
17
+ this.fps = 0;
18
+ this.patchesPerSecond = 0;
19
+ this.lastPatchesFrame = 0;
20
+ }
21
+
22
+ recordFrame({ now, frameMs, patchMs = 0, sceneMs, renderMs, patchesFrame = 0, inputLatencyMs = 0 }) {
23
+ this.frameCount++;
24
+ this.patchCount += patchesFrame;
25
+ this.lastPatchesFrame = patchesFrame;
26
+ pushSample(this.samples.frameMs, frameMs, this.maxSamples);
27
+ pushSample(this.samples.patchMs, patchMs, this.maxSamples);
28
+ pushSample(this.samples.sceneMs, sceneMs, this.maxSamples);
29
+ pushSample(this.samples.renderMs, renderMs, this.maxSamples);
30
+ if (inputLatencyMs) pushSample(this.samples.inputLatencyMs, inputLatencyMs, this.maxSamples);
31
+
32
+ if (this.lastSecondAt === null) this.lastSecondAt = now;
33
+ const elapsed = now - this.lastSecondAt;
34
+ if (elapsed >= 1000) {
35
+ this.fps = (this.frameCount * 1000) / elapsed;
36
+ this.patchesPerSecond = (this.patchCount * 1000) / elapsed;
37
+ this.frameCount = 0;
38
+ this.patchCount = 0;
39
+ this.lastSecondAt = now;
40
+ }
41
+ }
42
+
43
+ recordOperation(type, durationMs) {
44
+ if (type === 'search') pushSample(this.samples.searchMs, durationMs, this.maxSamples);
45
+ else if (type === 'filter') pushSample(this.samples.filterMs, durationMs, this.maxSamples);
46
+ else if (type === 'worker') pushSample(this.samples.workerMs, durationMs, this.maxSamples);
47
+ }
48
+
49
+ snapshot() {
50
+ return {
51
+ fps: this.fps,
52
+ patchesFrame: this.lastPatchesFrame,
53
+ patchesPerSecond: this.patchesPerSecond,
54
+ frameMs: describe(this.samples.frameMs),
55
+ patchMs: describe(this.samples.patchMs),
56
+ sceneMs: describe(this.samples.sceneMs),
57
+ renderMs: describe(this.samples.renderMs),
58
+ inputLatencyMs: describe(this.samples.inputLatencyMs),
59
+ searchMs: describe(this.samples.searchMs),
60
+ filterMs: describe(this.samples.filterMs),
61
+ workerMs: describe(this.samples.workerMs),
62
+ sampleCount: this.samples.frameMs.length,
63
+
64
+ // Backward-compatible aliases for existing callers/tests.
65
+ avgFrameMs: average(this.samples.frameMs),
66
+ p95FrameMs: percentile(this.samples.frameMs, 0.95),
67
+ avgSceneMs: average(this.samples.sceneMs),
68
+ avgRenderMs: average(this.samples.renderMs),
69
+ p95RenderMs: percentile(this.samples.renderMs, 0.95),
70
+ avgInputLatencyMs: average(this.samples.inputLatencyMs),
71
+ };
72
+ }
73
+ }
74
+
75
+ function describe(samples) {
76
+ return {
77
+ avg: average(samples),
78
+ p50: percentile(samples, 0.5),
79
+ p95: percentile(samples, 0.95),
80
+ p99: percentile(samples, 0.99),
81
+ };
82
+ }
83
+
84
+ function pushSample(samples, value, maxSamples) {
85
+ samples.push(value);
86
+ if (samples.length > maxSamples) samples.shift();
87
+ }
88
+
89
+ function average(samples) {
90
+ if (!samples.length) return 0;
91
+ return samples.reduce((sum, value) => sum + value, 0) / samples.length;
92
+ }
93
+
94
+ function percentile(samples, p) {
95
+ if (!samples.length) return 0;
96
+ const sorted = samples.slice().sort((a, b) => a - b);
97
+ const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * p) - 1);
98
+ return sorted[index];
99
+ }
@@ -0,0 +1,2 @@
1
+ export * from './benchmark-stats.js';
2
+ export * from './renderer-state.js';
@@ -0,0 +1,62 @@
1
+ export function captureTreeViewState(controller) {
2
+ return {
3
+ scrollX: controller.viewport.scrollX,
4
+ scrollY: controller.viewport.scrollY,
5
+ selection: controller.getSelection(),
6
+ focusedId: controller.focusedId,
7
+ searchQuery: controller.searchIndex.lastQuery ?? '',
8
+ searchResults: controller.searchIndex.results.slice(),
9
+ searchCursor: controller.searchIndex.cursor,
10
+ searchMatches: new Set(controller.searchHighlights),
11
+ filterQuery: controller.filterQuery,
12
+ sort: { ...controller.columnModel.sort },
13
+ columns: controller.columnModel.columns.map((column) => ({ ...column })),
14
+ expanded: new Set(controller.expansion.model.expanded),
15
+ theme: controller.themeManager.get(),
16
+ };
17
+ }
18
+
19
+ export function restoreTreeViewState(controller, state) {
20
+ if (state.columns) controller.setColumns(state.columns);
21
+ controller.expansion.model.expanded = new Set(state.expanded);
22
+ if (state.sort?.columnId) controller.sortBy(state.sort.columnId, state.sort.direction);
23
+ if (state.filterQuery) controller.setFilter(state.filterQuery);
24
+ controller.rowModel.rebuild();
25
+ controller.viewport.setContentSize(controller.columnModel.contentWidth, controller.rowModel.contentHeight);
26
+ controller.viewport.scrollTo(state.scrollX, state.scrollY);
27
+ controller.setSelection(state.selection);
28
+ controller.focusedId = state.focusedId;
29
+ controller.selection.focused = state.focusedId;
30
+ controller.searchIndex.results = state.searchResults.slice();
31
+ controller.searchIndex.cursor = state.searchCursor;
32
+ controller.searchIndex.lastQuery = state.searchQuery;
33
+ controller.searchHighlights = new Set(state.searchMatches);
34
+ controller.setTheme(state.theme);
35
+ }
36
+
37
+ export function sceneSignature(scene) {
38
+ return {
39
+ visibleRange: { ...scene.visibleRange },
40
+ viewport: {
41
+ scrollX: scene.viewport.scrollX,
42
+ scrollY: scene.viewport.scrollY,
43
+ width: scene.viewport.viewportWidth,
44
+ height: scene.viewport.viewportHeight,
45
+ contentWidth: scene.viewport.contentWidth,
46
+ contentHeight: scene.viewport.contentHeight,
47
+ },
48
+ columns: scene.columns.map((column) => ({ id: column.id, x: column.x, width: column.width, kind: column.kind })),
49
+ rows: scene.rows.slice(scene.visibleRange.first, scene.visibleRange.last + 1).map((row) => ({
50
+ nodeId: row.nodeId,
51
+ rowIndex: row.rowIndex,
52
+ depth: row.depth,
53
+ expanded: row.expanded,
54
+ })),
55
+ selection: Array.from(scene.selection).sort(),
56
+ hoverNodeId: scene.hoverNodeId,
57
+ focusNodeId: scene.focusNodeId,
58
+ searchMatches: Array.from(scene.searchMatches).sort(),
59
+ sort: scene.sort,
60
+ filterQuery: scene.filterQuery,
61
+ };
62
+ }
@@ -0,0 +1,25 @@
1
+ export class AssetManager {
2
+ constructor() {
3
+ this.images = new Map();
4
+ this.iconIndices = new Map();
5
+ }
6
+
7
+ registerIcon(name) {
8
+ if (!this.iconIndices.has(name)) this.iconIndices.set(name, this.iconIndices.size);
9
+ return this.iconIndices.get(name);
10
+ }
11
+
12
+ async loadImage(name, url) {
13
+ const image = new Image();
14
+ image.decoding = 'async';
15
+ image.src = url;
16
+ await image.decode();
17
+ this.images.set(name, image);
18
+ return image;
19
+ }
20
+
21
+ getImage(name) {
22
+ return this.images.get(name) ?? null;
23
+ }
24
+ }
25
+
@@ -0,0 +1,12 @@
1
+ export function rectsIntersect(a, b) {
2
+ return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
3
+ }
4
+
5
+ export function cullLayoutNodes(layoutNodes, worldBounds) {
6
+ const visible = [];
7
+ for (const node of layoutNodes) {
8
+ if (rectsIntersect(node, worldBounds)) visible.push(node.index);
9
+ }
10
+ return visible;
11
+ }
12
+
@@ -0,0 +1,21 @@
1
+ export class EventEmitter {
2
+ constructor() {
3
+ this.listeners = new Map();
4
+ }
5
+
6
+ on(type, listener) {
7
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
8
+ this.listeners.get(type).add(listener);
9
+ return () => this.off(type, listener);
10
+ }
11
+
12
+ off(type, listener) {
13
+ this.listeners.get(type)?.delete(listener);
14
+ }
15
+
16
+ emit(type, detail = {}) {
17
+ const event = { type, detail };
18
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
19
+ }
20
+ }
21
+