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,256 @@
1
+ export class CellEditorManager {
2
+ constructor({ controller, canvas, host = null }) {
3
+ this.controller = controller;
4
+ this.canvas = canvas;
5
+ this.host = host ?? canvas.parentElement ?? document.body;
6
+ this.overlay = null;
7
+ this.rangeDrag = null;
8
+ this.onMouseMove = this.#onMouseMove.bind(this);
9
+ this.onMouseUp = this.#onMouseUp.bind(this);
10
+ }
11
+
12
+ destroy() {
13
+ this.#removeOverlay();
14
+ window.removeEventListener('mousemove', this.onMouseMove);
15
+ window.removeEventListener('mouseup', this.onMouseUp);
16
+ }
17
+
18
+ handlePointerDown(event, hit) {
19
+ if (!this.#isEditableHit(hit)) return false;
20
+ const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
21
+ if (!data || data.readonly || data.disabled) return false;
22
+ if (data.editorType === 'range' && hit.part !== 'number') {
23
+ this.rangeDrag = { hit, data };
24
+ this.#updateRangeFromEvent(event);
25
+ window.addEventListener('mousemove', this.onMouseMove);
26
+ window.addEventListener('mouseup', this.onMouseUp);
27
+ return true;
28
+ }
29
+ if (shouldOpenOverlayOnPointerDown(data, hit)) {
30
+ const node = this.controller.model.nodes[hit.row.nodeIndex];
31
+ this.#showOverlay(node, hit, { showPicker: true });
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ handleClick(event, hit) {
38
+ if (!this.#isEditableHit(hit)) return false;
39
+ const node = this.controller.model.nodes[hit.row.nodeIndex];
40
+ const data = node?.data;
41
+ if (!data || data.disabled) return false;
42
+ if (hit.part === 'arrayAdd') return this.controller.addInspectorArrayItem(node.id);
43
+ if (hit.part === 'arrayRemove') return this.controller.removeInspectorArrayItem(node.id);
44
+ if (data.editorType === 'button') return this.controller.triggerInspectorAction(node.id);
45
+ if (data.readonly) return false;
46
+ if (data.editorType === 'checkbox') {
47
+ this.controller.updateInspectorValue(node.id, !data.value, 'checkbox');
48
+ return true;
49
+ }
50
+ if (data.editorType === 'range' && hit.part !== 'number') return true;
51
+ if (this.overlay) return true;
52
+ this.#showOverlay(node, hit);
53
+ return true;
54
+ }
55
+
56
+ handleHeaderClick(_event, hit) {
57
+ if (hit?.area !== 'header' || hit.part !== 'filter') return false;
58
+ this.#showHeaderFilterOverlay(hit);
59
+ return true;
60
+ }
61
+
62
+ #showOverlay(node, hit, options = {}) {
63
+ this.#removeOverlay();
64
+ const data = node.data;
65
+ const rect = this.#overlayRect(hit);
66
+ const hostRect = this.host.getBoundingClientRect();
67
+ const element = createEditorElement(data);
68
+ Object.assign(element.style, {
69
+ position: 'absolute',
70
+ left: `${rect.x - hostRect.left}px`,
71
+ top: `${rect.y - hostRect.top}px`,
72
+ width: `${Math.max(24, rect.width)}px`,
73
+ height: `${Math.max(20, rect.height)}px`,
74
+ minWidth: '0',
75
+ maxWidth: `${Math.max(24, rect.width)}px`,
76
+ zIndex: 20,
77
+ boxSizing: 'border-box',
78
+ margin: '0',
79
+ outline: 'none',
80
+ border: '1px solid #38bdf8',
81
+ borderRadius: '3px',
82
+ background: '#0b1020',
83
+ color: '#e5e7eb',
84
+ font: '12px system-ui, sans-serif',
85
+ padding: data.editorType === 'color' ? '0 2px' : '0 6px',
86
+ textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left',
87
+ });
88
+ const commit = () => {
89
+ const nextValue = parseEditorValue(element, data);
90
+ this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
91
+ this.#removeOverlay();
92
+ };
93
+ element.addEventListener('keydown', (event) => {
94
+ if (event.key === 'Enter') commit();
95
+ else if (event.key === 'Escape') this.#removeOverlay();
96
+ });
97
+ element.addEventListener('blur', commit);
98
+ ensureOverlayHost(this.host);
99
+ this.host.append(element);
100
+ this.overlay = element;
101
+ element.focus();
102
+ element.select?.();
103
+ if (options.showPicker && element.showPicker) {
104
+ requestAnimationFrame(() => {
105
+ try {
106
+ element.showPicker();
107
+ } catch (_error) {
108
+ // Some browsers restrict showPicker to specific input types or gestures.
109
+ }
110
+ });
111
+ }
112
+ }
113
+
114
+ #showHeaderFilterOverlay(hit) {
115
+ this.#removeOverlay();
116
+ const rect = this.controller.getHeaderClientRect(hit);
117
+ const hostRect = this.host.getBoundingClientRect();
118
+ const element = document.createElement('input');
119
+ element.type = 'search';
120
+ element.value = this.controller.filterQuery ?? '';
121
+ element.placeholder = 'Filter inspector';
122
+ Object.assign(element.style, {
123
+ position: 'absolute',
124
+ left: `${rect.x - hostRect.left + 8}px`,
125
+ top: `${rect.y - hostRect.top + 5}px`,
126
+ width: `${Math.max(24, rect.width - 16)}px`,
127
+ height: `${Math.max(20, rect.height - 10)}px`,
128
+ minWidth: '0',
129
+ maxWidth: `${Math.max(24, rect.width - 16)}px`,
130
+ zIndex: 20,
131
+ boxSizing: 'border-box',
132
+ margin: '0',
133
+ outline: 'none',
134
+ border: '1px solid #38bdf8',
135
+ borderRadius: '4px',
136
+ background: '#0b1020',
137
+ color: '#e5e7eb',
138
+ font: '12px system-ui, sans-serif',
139
+ padding: '0 8px',
140
+ });
141
+ element.addEventListener('input', () => this.controller.setFilter(element.value));
142
+ element.addEventListener('keydown', (event) => {
143
+ if (event.key === 'Escape') this.#removeOverlay();
144
+ if (event.key === 'Enter') this.#removeOverlay();
145
+ });
146
+ element.addEventListener('blur', () => this.#removeOverlay());
147
+ ensureOverlayHost(this.host);
148
+ this.host.append(element);
149
+ this.overlay = element;
150
+ element.focus();
151
+ element.select();
152
+ }
153
+
154
+ #updateRangeFromEvent(event) {
155
+ const { hit, data } = this.rangeDrag;
156
+ const node = this.controller.model.nodes[hit.row.nodeIndex];
157
+ const rect = this.#rangeBarRect(hit);
158
+ const x = Math.max(0, Math.min(rect.width, event.clientX - rect.x));
159
+ const meta = data.meta ?? {};
160
+ const min = meta.min ?? 0;
161
+ const max = meta.max ?? 100;
162
+ const step = meta.step ?? (meta.integer ? 1 : 0);
163
+ let value = min + (x / Math.max(1, rect.width)) * (max - min);
164
+ if (step) value = Math.round(value / step) * step;
165
+ if (meta.integer) value = Math.round(value);
166
+ value = Math.max(min, Math.min(max, value));
167
+ this.controller.updateInspectorValue(node.id, value, 'range');
168
+ }
169
+
170
+ #onMouseMove(event) {
171
+ if (this.rangeDrag) this.#updateRangeFromEvent(event);
172
+ }
173
+
174
+ #onMouseUp() {
175
+ this.rangeDrag = null;
176
+ window.removeEventListener('mousemove', this.onMouseMove);
177
+ window.removeEventListener('mouseup', this.onMouseUp);
178
+ }
179
+
180
+ #isEditableHit(hit) {
181
+ return hit?.area === 'row' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
182
+ }
183
+
184
+ #overlayRect(hit) {
185
+ if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
186
+ const rect = this.controller.getCellClientRect(hit);
187
+ const editorLeft = Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
188
+ const editorWidth = Math.max(80, rect.width - editorLeft - 14);
189
+ if (hit.part === 'number') {
190
+ const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
191
+ return { x: rect.x + editorLeft + editorWidth - valueWidth - 10, y: rect.y + 4, width: valueWidth, height: rect.height - 8 };
192
+ }
193
+ return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: editorWidth - 20, height: rect.height - 8 };
194
+ }
195
+
196
+ #rangeBarRect(hit) {
197
+ const rect = this.controller.getCellClientRect(hit);
198
+ if (hit.column?.kind === 'inspectorPane') {
199
+ const editorLeft = Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
200
+ const editorWidth = Math.max(80, rect.width - editorLeft - 14);
201
+ const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
202
+ const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
203
+ return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
204
+ }
205
+ const valueWidth = Math.min(64, Math.max(42, (rect.width - 20) * 0.28));
206
+ return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
207
+ }
208
+
209
+ #removeOverlay() {
210
+ this.overlay?.remove();
211
+ this.overlay = null;
212
+ }
213
+ }
214
+
215
+ function createEditorElement(data) {
216
+ if (data.editorType === 'select') {
217
+ const select = document.createElement('select');
218
+ for (const [label, value] of Object.entries(data.meta.options ?? {})) {
219
+ const option = document.createElement('option');
220
+ option.textContent = label;
221
+ option.value = String(value);
222
+ option.selected = value === data.value;
223
+ select.append(option);
224
+ }
225
+ return select;
226
+ }
227
+ const input = document.createElement('input');
228
+ input.type = data.editorType === 'color' ? 'color' : data.editorType === 'number' || data.editorType === 'range' ? 'number' : 'text';
229
+ input.value = data.value ?? '';
230
+ if (data.meta.min !== undefined) input.min = data.meta.min;
231
+ if (data.meta.max !== undefined) input.max = data.meta.max;
232
+ if (data.meta.step !== undefined) input.step = data.meta.step;
233
+ return input;
234
+ }
235
+
236
+ function shouldOpenOverlayOnPointerDown(data, hit) {
237
+ if (data.readonly || data.disabled) return false;
238
+ if (data.editorType === 'range') return hit.part === 'number';
239
+ return data.editorType === 'text' || data.editorType === 'number' || data.editorType === 'select' || data.editorType === 'color';
240
+ }
241
+
242
+ function ensureOverlayHost(host) {
243
+ const style = getComputedStyle(host);
244
+ if (style.position === 'static') host.style.position = 'relative';
245
+ if (style.overflow === 'visible') host.style.overflow = 'hidden';
246
+ }
247
+
248
+ function parseEditorValue(element, data) {
249
+ if (data.editorType === 'number' || data.editorType === 'range') return data.meta.integer ? Number.parseInt(element.value, 10) : Number(element.value);
250
+ if (data.editorType === 'select') {
251
+ const values = Object.values(data.meta.options ?? {});
252
+ const match = values.find((value) => String(value) === element.value);
253
+ return match ?? element.value;
254
+ }
255
+ return element.value;
256
+ }
@@ -0,0 +1,33 @@
1
+ export function valueTypeOf(value) {
2
+ if (Array.isArray(value)) return 'array';
3
+ if (value === null) return 'null';
4
+ return typeof value === 'object' ? 'object' : typeof value;
5
+ }
6
+
7
+ export function resolveEditorType(value, meta = {}) {
8
+ const valueType = valueTypeOf(value);
9
+ if (meta.button) return 'button';
10
+ if (meta.options) return 'select';
11
+ if (meta.color) return 'color';
12
+ if (valueType === 'boolean') return 'checkbox';
13
+ if (valueType === 'number' && (meta.min !== undefined || meta.max !== undefined)) return 'range';
14
+ if (valueType === 'number') return 'number';
15
+ if (valueType === 'string') return 'text';
16
+ if (valueType === 'object') return 'group';
17
+ if (valueType === 'array') return 'array';
18
+ if (valueType === 'null') return 'text';
19
+ return 'readonly';
20
+ }
21
+
22
+ export function formatInspectorValue(value, meta = {}) {
23
+ if (meta.button) return meta.button;
24
+ if (meta.options) {
25
+ const match = Object.entries(meta.options).find(([, optionValue]) => optionValue === value);
26
+ if (match) return match[0];
27
+ }
28
+ if (value === null) return 'null';
29
+ if (value === undefined) return 'undefined';
30
+ if (typeof value === 'object') return Array.isArray(value) ? `Array(${value.length})` : 'Object';
31
+ if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
32
+ return String(value);
33
+ }
@@ -0,0 +1,4 @@
1
+ export * from './cell-editor-manager.js';
2
+ export * from './editor-resolver.js';
3
+ export * from './model-inspector-builder.js';
4
+ export * from './model-path.js';
@@ -0,0 +1,139 @@
1
+ import { formatInspectorValue, resolveEditorType, valueTypeOf } from './editor-resolver.js';
2
+ import { joinPath, resolveMetaForPath } from './model-path.js';
3
+
4
+ export class ModelInspectorBuilder {
5
+ build(model, meta = {}, options = {}) {
6
+ const nodes = [];
7
+ const context = {
8
+ meta,
9
+ enforceMeta: Boolean(options.enforceMeta),
10
+ };
11
+ if (options.flatRoot && model && typeof model === 'object' && !Array.isArray(model)) {
12
+ for (const [childKey, childValue] of Object.entries(model)) {
13
+ this.#visit({ nodes, value: childValue, key: childKey, path: childKey, parentId: null, context });
14
+ }
15
+ return nodes;
16
+ }
17
+ this.#visit({ nodes, value: model, key: 'model', path: '', parentId: null, context });
18
+ return nodes;
19
+ }
20
+
21
+ #visit({ nodes, value, key, path, parentId, context }) {
22
+ const { rule, hasMeta } = resolveMetaForPath(context.meta, path);
23
+ const valueType = valueTypeOf(value);
24
+ const editorType = resolveEditorType(value, rule);
25
+ const id = inspectorNodeId(path);
26
+ const label = rule.label ?? labelForKey(key, value, rule);
27
+ const structural = valueType === 'object' || valueType === 'array';
28
+ const metaDisabled = context.enforceMeta && !hasMeta && !structural;
29
+ nodes.push({
30
+ id,
31
+ parentId,
32
+ label,
33
+ type: inspectorNodeType(valueType, editorType),
34
+ data: {
35
+ inspector: true,
36
+ path,
37
+ key,
38
+ value,
39
+ valueText: formatInspectorValue(value, rule),
40
+ valueType,
41
+ editorType,
42
+ meta: rule,
43
+ hasMeta,
44
+ readonly: Boolean(rule.readonly || metaDisabled || editorType === 'readonly' || structural),
45
+ disabled: Boolean(rule.disabled || metaDisabled),
46
+ },
47
+ });
48
+
49
+ if (Array.isArray(value)) {
50
+ value.forEach((item, index) => {
51
+ const childPath = joinPath(path, index);
52
+ const { rule: itemRule } = resolveMetaForPath(context.meta, childPath);
53
+ const itemTitle = rule.itemTitle?.(index, item) ?? itemRule.label ?? `[${index}]`;
54
+ this.#visit({ nodes, value: item, key: itemTitle, path: childPath, parentId: id, context });
55
+ });
56
+ } else if (value && typeof value === 'object') {
57
+ for (const [childKey, childValue] of Object.entries(value)) {
58
+ this.#visit({ nodes, value: childValue, key: childKey, path: joinPath(path, childKey), parentId: id, context });
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ export function inspectorNodeId(path) {
65
+ return `model:${path || '$'}`;
66
+ }
67
+
68
+ export function inspectorColumns() {
69
+ return [
70
+ {
71
+ id: 'property',
72
+ label: 'Property',
73
+ width: 300,
74
+ minWidth: 160,
75
+ align: 'left',
76
+ kind: 'tree',
77
+ sortable: false,
78
+ value: (node) => node.label ?? node.id,
79
+ },
80
+ {
81
+ id: 'value',
82
+ label: 'Value',
83
+ width: 260,
84
+ minWidth: 140,
85
+ align: 'left',
86
+ kind: 'inspectorValue',
87
+ sortable: false,
88
+ value: (node) => node.data?.valueText ?? '',
89
+ },
90
+ {
91
+ id: 'type',
92
+ label: 'Type',
93
+ width: 96,
94
+ minWidth: 72,
95
+ align: 'left',
96
+ kind: 'inspectorType',
97
+ sortable: false,
98
+ value: (node) => node.data?.valueType ?? '',
99
+ },
100
+ {
101
+ id: 'description',
102
+ label: 'Description',
103
+ width: 220,
104
+ minWidth: 120,
105
+ align: 'left',
106
+ kind: 'inspectorDescription',
107
+ sortable: false,
108
+ value: (node) => node.data?.meta?.description ?? '',
109
+ },
110
+ ];
111
+ }
112
+
113
+ export function inspectorPaneColumns() {
114
+ return [
115
+ {
116
+ id: 'pane',
117
+ label: 'Inspector',
118
+ width: 560,
119
+ minWidth: 260,
120
+ align: 'left',
121
+ kind: 'inspectorPane',
122
+ sortable: false,
123
+ value: (node) => node.label ?? node.id,
124
+ },
125
+ ];
126
+ }
127
+
128
+ function labelForKey(key, value, meta) {
129
+ if (meta.label) return meta.label;
130
+ if (typeof key === 'number') return `[${key}]`;
131
+ return String(key);
132
+ }
133
+
134
+ function inspectorNodeType(valueType, editorType) {
135
+ if (valueType === 'array') return 'array';
136
+ if (valueType === 'object') return 'object';
137
+ if (editorType === 'button') return 'task';
138
+ return valueType;
139
+ }
@@ -0,0 +1,48 @@
1
+ export function joinPath(parentPath, key) {
2
+ return parentPath ? `${parentPath}.${key}` : String(key);
3
+ }
4
+
5
+ export function splitPath(path) {
6
+ if (!path) return [];
7
+ return String(path).split('.').filter(Boolean).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
8
+ }
9
+
10
+ export function getAtPath(model, path) {
11
+ let value = model;
12
+ for (const part of splitPath(path)) {
13
+ if (value == null) return undefined;
14
+ value = value[part];
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export function setAtPath(model, path, nextValue) {
20
+ const parts = splitPath(path);
21
+ if (!parts.length) throw new Error('Cannot replace root model value through setAtPath');
22
+ let target = model;
23
+ for (let i = 0; i < parts.length - 1; i++) target = target[parts[i]];
24
+ const key = parts[parts.length - 1];
25
+ const oldValue = target[key];
26
+ target[key] = nextValue;
27
+ return oldValue;
28
+ }
29
+
30
+ export function wildcardPath(path) {
31
+ return String(path)
32
+ .split('.')
33
+ .map((part) => (/^\d+$/.test(part) ? '*' : part))
34
+ .join('.');
35
+ }
36
+
37
+ export function metaForPath(meta = {}, path) {
38
+ return resolveMetaForPath(meta, path).rule;
39
+ }
40
+
41
+ export function resolveMetaForPath(meta = {}, path) {
42
+ const exact = meta[path];
43
+ if (exact) return { rule: exact, hasMeta: true, source: path };
44
+ const wildcard = wildcardPath(path);
45
+ const wildcardRule = meta[wildcard];
46
+ if (wildcardRule) return { rule: wildcardRule, hasMeta: true, source: wildcard };
47
+ return { rule: {}, hasMeta: false, source: '' };
48
+ }
@@ -0,0 +1,120 @@
1
+ import { cullLayoutNodes } from '../core/culling.js';
2
+
3
+ export class Canvas2DRenderer {
4
+ constructor({ themeManager } = {}) {
5
+ this.canvas = null;
6
+ this.ctx = null;
7
+ this.scene = null;
8
+ this.themeManager = themeManager;
9
+ this.visibleNodeIndices = [];
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 model.dynamicState directly. Dynamic patches must not rebuild layout.
25
+ }
26
+
27
+ render(frameState) {
28
+ if (!this.canvas || !this.ctx || !this.scene) return;
29
+ const { viewport } = frameState;
30
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
31
+ const width = Math.floor(this.canvas.clientWidth * dpr);
32
+ const height = Math.floor(this.canvas.clientHeight * dpr);
33
+ if (this.canvas.width !== width || this.canvas.height !== height) {
34
+ this.canvas.width = width;
35
+ this.canvas.height = height;
36
+ viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
37
+ }
38
+
39
+ const ctx = this.ctx;
40
+ const theme = this.themeManager?.get() ?? {};
41
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
42
+ ctx.fillStyle = theme.background ?? '#101419';
43
+ ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
44
+
45
+ this.visibleNodeIndices = cullLayoutNodes(this.scene.layout.nodes, viewport.getWorldBounds());
46
+ const visibleSet = new Set(this.visibleNodeIndices);
47
+
48
+ ctx.save();
49
+ ctx.scale(viewport.zoom, viewport.zoom);
50
+ ctx.translate(-viewport.x, -viewport.y);
51
+ this.#drawEdges(ctx, visibleSet, theme);
52
+ this.#drawNodes(ctx, theme, viewport.zoom);
53
+ ctx.restore();
54
+
55
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
56
+ ctx.fillStyle = theme.mutedLabel ?? '#9ba8b3';
57
+ ctx.font = '12px system-ui, sans-serif';
58
+ ctx.fillText(`${this.scene.layout.nodes.length.toLocaleString()} nodes / ${this.visibleNodeIndices.length.toLocaleString()} visible`, 12, 20);
59
+ }
60
+
61
+ #drawEdges(ctx, visibleSet, theme) {
62
+ const nodes = this.scene.layout.nodes;
63
+ ctx.strokeStyle = theme.edge ?? '#51606d';
64
+ ctx.lineWidth = 1;
65
+ ctx.beginPath();
66
+ for (const edge of this.scene.layout.edges) {
67
+ if (!visibleSet.has(edge.sourceIndex) && !visibleSet.has(edge.targetIndex)) continue;
68
+ const source = nodes[edge.sourceIndex];
69
+ const target = nodes[edge.targetIndex];
70
+ ctx.moveTo(source.x + source.width, source.y + source.height / 2);
71
+ ctx.lineTo(target.x, target.y + target.height / 2);
72
+ }
73
+ ctx.stroke();
74
+ }
75
+
76
+ #drawNodes(ctx, theme, zoom) {
77
+ const model = this.scene.model;
78
+ for (const index of this.visibleNodeIndices) {
79
+ const node = this.scene.layout.nodes[index];
80
+ const state = this.scene.dynamicState.get(node.id) ?? {};
81
+ if (state.visible === false) continue;
82
+ const structural = model.index.getNode(node.id);
83
+ const fill = state.color ?? theme.nodeFill ?? '#202a33';
84
+
85
+ ctx.fillStyle = fill;
86
+ ctx.strokeStyle = state.selected ? theme.selected : state.highlighted ? theme.highlighted : theme.nodeStroke;
87
+ ctx.lineWidth = state.selected || state.highlighted ? 2 / zoom : 1 / zoom;
88
+ ctx.beginPath();
89
+ ctx.roundRect(node.x, node.y, node.width, node.height, 4);
90
+ ctx.fill();
91
+ ctx.stroke();
92
+
93
+ if (state.progress !== undefined) {
94
+ ctx.fillStyle = theme.progress ?? '#42d392';
95
+ ctx.fillRect(node.x, node.y + node.height - 3, node.width * clamp01(state.progress), 3);
96
+ }
97
+
98
+ if (zoom >= 0.28) {
99
+ ctx.fillStyle = theme.label ?? '#eef3f7';
100
+ ctx.font = `${Math.max(9, Math.min(12, 12 / Math.sqrt(zoom)))}px system-ui, sans-serif`;
101
+ ctx.textBaseline = 'middle';
102
+ const label = structural?.label ?? node.id;
103
+ ctx.fillText(label, node.x + 8, node.y + node.height / 2, node.width - 14);
104
+ }
105
+ }
106
+ }
107
+
108
+ pick(worldX, worldY) {
109
+ if (!this.scene) return null;
110
+ for (let i = this.visibleNodeIndices.length - 1; i >= 0; i--) {
111
+ const node = this.scene.layout.nodes[this.visibleNodeIndices[i]];
112
+ if (worldX >= node.x && worldX <= node.x + node.width && worldY >= node.y && worldY <= node.y + node.height) return node.id;
113
+ }
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function clamp01(value) {
119
+ return Math.max(0, Math.min(1, value));
120
+ }
@@ -0,0 +1,3 @@
1
+ export * from './canvas2d-renderer.js';
2
+ export * from './renderer.js';
3
+ export * from './tree-row-renderer.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @typedef {Object} Renderer
3
+ * @property {(canvas: HTMLCanvasElement) => Promise<void> | void} initialize
4
+ * @property {(scene: any) => void} setScene
5
+ * @property {(patches: Array<import('../core/types.js').DynamicPatch>) => void} updateDynamicState
6
+ * @property {(frameState: any) => void} render
7
+ */
8
+
9
+ export class RendererNotImplemented extends Error {}
10
+