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.
- package/LICENSE +21 -0
- package/README.md +335 -0
- package/package.json +40 -0
- package/src/assets/.gitkeep +1 -0
- package/src/benchmark/benchmark-stats.js +99 -0
- package/src/benchmark/index.js +2 -0
- package/src/benchmark/renderer-state.js +62 -0
- package/src/core/asset-manager.js +25 -0
- package/src/core/culling.js +12 -0
- package/src/core/event-emitter.js +21 -0
- package/src/core/icon-registry.js +173 -0
- package/src/core/index.js +19 -0
- package/src/core/layout-engine.js +64 -0
- package/src/core/patch-batcher.js +29 -0
- package/src/core/scene.js +13 -0
- package/src/core/search-index.js +70 -0
- package/src/core/selection-manager.js +38 -0
- package/src/core/theme-manager.js +162 -0
- package/src/core/tree-column-model.js +151 -0
- package/src/core/tree-expansion-manager.js +67 -0
- package/src/core/tree-index.js +79 -0
- package/src/core/tree-model.js +80 -0
- package/src/core/tree-view-viewport.js +69 -0
- package/src/core/tree-worker-client.js +50 -0
- package/src/core/tree-worker-operations.js +152 -0
- package/src/core/types.js +44 -0
- package/src/core/viewport.js +66 -0
- package/src/core/visible-row-model.js +137 -0
- package/src/index.js +6 -0
- package/src/input/index.js +2 -0
- package/src/input/pointer-controller.js +66 -0
- package/src/input/tree-view-input-controller.js +235 -0
- package/src/inspector/cell-editor-manager.js +256 -0
- package/src/inspector/editor-resolver.js +33 -0
- package/src/inspector/index.js +4 -0
- package/src/inspector/model-inspector-builder.js +139 -0
- package/src/inspector/model-path.js +48 -0
- package/src/renderers/canvas2d-renderer.js +120 -0
- package/src/renderers/index.js +3 -0
- package/src/renderers/renderer.js +10 -0
- package/src/renderers/tree-row-renderer.js +443 -0
- package/src/tree-view-controller.js +825 -0
- 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,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
|
+
|