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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export const builtInColumns = {
|
|
2
|
+
tree: {
|
|
3
|
+
id: 'name',
|
|
4
|
+
label: 'Name',
|
|
5
|
+
width: 320,
|
|
6
|
+
minWidth: 160,
|
|
7
|
+
align: 'left',
|
|
8
|
+
kind: 'tree',
|
|
9
|
+
value: (node) => node.label ?? node.id,
|
|
10
|
+
},
|
|
11
|
+
status: {
|
|
12
|
+
id: 'status',
|
|
13
|
+
label: 'Status',
|
|
14
|
+
width: 84,
|
|
15
|
+
minWidth: 64,
|
|
16
|
+
align: 'center',
|
|
17
|
+
kind: 'status',
|
|
18
|
+
value: (_node, state) => state.status ?? 0,
|
|
19
|
+
},
|
|
20
|
+
value: {
|
|
21
|
+
id: 'value',
|
|
22
|
+
label: 'Value',
|
|
23
|
+
width: 84,
|
|
24
|
+
minWidth: 56,
|
|
25
|
+
align: 'right',
|
|
26
|
+
kind: 'value',
|
|
27
|
+
value: (_node, state) => state.value ?? '',
|
|
28
|
+
},
|
|
29
|
+
progress: {
|
|
30
|
+
id: 'progress',
|
|
31
|
+
label: 'Progress',
|
|
32
|
+
width: 120,
|
|
33
|
+
minWidth: 80,
|
|
34
|
+
align: 'left',
|
|
35
|
+
kind: 'progress',
|
|
36
|
+
value: (_node, state) => state.progress ?? '',
|
|
37
|
+
},
|
|
38
|
+
type: {
|
|
39
|
+
id: 'type',
|
|
40
|
+
label: 'Type',
|
|
41
|
+
width: 110,
|
|
42
|
+
minWidth: 70,
|
|
43
|
+
align: 'left',
|
|
44
|
+
kind: 'type',
|
|
45
|
+
value: (node) => node.type ?? '',
|
|
46
|
+
},
|
|
47
|
+
updated: {
|
|
48
|
+
id: 'updated',
|
|
49
|
+
label: 'Updated',
|
|
50
|
+
width: 120,
|
|
51
|
+
minWidth: 84,
|
|
52
|
+
align: 'right',
|
|
53
|
+
kind: 'updated',
|
|
54
|
+
value: (_node, state) => state.updatedAt ?? '',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export class TreeColumnModel {
|
|
59
|
+
constructor(columns = [builtInColumns.tree]) {
|
|
60
|
+
this.columns = [];
|
|
61
|
+
this.contentWidth = 0;
|
|
62
|
+
this.sort = { columnId: null, direction: null };
|
|
63
|
+
this.setColumns(columns);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setColumns(columns = [builtInColumns.tree]) {
|
|
67
|
+
const normalized = columns.length ? columns : [builtInColumns.tree];
|
|
68
|
+
this.columns = normalized.map((column, index) => normalizeColumn(column, index));
|
|
69
|
+
if (!this.columns.some((column) => column.kind === 'tree' || column.kind === 'inspectorPane')) {
|
|
70
|
+
this.columns.unshift(normalizeColumn(builtInColumns.tree, 0));
|
|
71
|
+
}
|
|
72
|
+
this.#layout();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {number} x */
|
|
76
|
+
getColumnAt(x) {
|
|
77
|
+
return this.columns.find((column) => x >= column.x && x < column.x + column.width) ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getColumn(id) {
|
|
81
|
+
return this.columns.find((column) => column.id === id) ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resizeColumn(id, width) {
|
|
85
|
+
const column = this.getColumn(id);
|
|
86
|
+
if (!column) return false;
|
|
87
|
+
column.width = Math.max(column.minWidth, width);
|
|
88
|
+
this.#layout();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
moveColumn(id, targetIndex) {
|
|
93
|
+
const currentIndex = this.columns.findIndex((column) => column.id === id);
|
|
94
|
+
if (currentIndex === -1) return false;
|
|
95
|
+
const [column] = this.columns.splice(currentIndex, 1);
|
|
96
|
+
const nextIndex = Math.max(0, Math.min(this.columns.length, targetIndex));
|
|
97
|
+
this.columns.splice(nextIndex, 0, column);
|
|
98
|
+
if (!this.columns.some((item) => item.kind === 'tree')) this.columns.unshift(normalizeColumn(builtInColumns.tree, 0));
|
|
99
|
+
this.#layout();
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setSort(columnId, direction) {
|
|
104
|
+
if (columnId !== null && !this.getColumn(columnId)) return false;
|
|
105
|
+
this.sort = { columnId, direction };
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getResizeHandleAt(x, tolerance = 5) {
|
|
110
|
+
return this.columns.find((column) => Math.abs(x - (column.x + column.width)) <= tolerance) ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#layout() {
|
|
114
|
+
let x = 0;
|
|
115
|
+
for (const column of this.columns) {
|
|
116
|
+
column.x = x;
|
|
117
|
+
x += column.width;
|
|
118
|
+
}
|
|
119
|
+
this.contentWidth = x;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function defaultTreeTableColumns() {
|
|
124
|
+
return [
|
|
125
|
+
{ ...builtInColumns.tree, id: 'name', label: 'Name', width: 340 },
|
|
126
|
+
builtInColumns.type,
|
|
127
|
+
builtInColumns.status,
|
|
128
|
+
builtInColumns.value,
|
|
129
|
+
builtInColumns.progress,
|
|
130
|
+
builtInColumns.updated,
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeColumn(column, index) {
|
|
135
|
+
const builtIn = typeof column === 'string' ? builtInColumns[column] : null;
|
|
136
|
+
const source = builtIn ?? column;
|
|
137
|
+
if (!source?.id) throw new Error(`Column at index ${index} is missing id`);
|
|
138
|
+
const width = Math.max(source.minWidth ?? 40, source.width ?? 120);
|
|
139
|
+
return {
|
|
140
|
+
id: source.id,
|
|
141
|
+
label: source.label ?? source.id,
|
|
142
|
+
width,
|
|
143
|
+
minWidth: source.minWidth ?? 40,
|
|
144
|
+
align: source.align ?? 'left',
|
|
145
|
+
kind: source.kind ?? (index === 0 ? 'tree' : 'text'),
|
|
146
|
+
sortable: source.sortable ?? true,
|
|
147
|
+
value: source.value ?? ((node) => node[source.id] ?? ''),
|
|
148
|
+
render: source.render,
|
|
149
|
+
x: 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class TreeExpansionManager extends EventTarget {
|
|
2
|
+
/** @param {import('./tree-model.js').TreeModel} model */
|
|
3
|
+
constructor(model) {
|
|
4
|
+
super();
|
|
5
|
+
this.model = model;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** @param {string} id */
|
|
9
|
+
isExpanded(id) {
|
|
10
|
+
return this.model.expanded.has(id);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {string} id */
|
|
14
|
+
hasChildren(id) {
|
|
15
|
+
return this.model.index.getChildren(id).length > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {string} id */
|
|
19
|
+
expand(id) {
|
|
20
|
+
if (!this.hasChildren(id) || this.model.expanded.has(id)) return false;
|
|
21
|
+
this.model.expanded.add(id);
|
|
22
|
+
this.dispatchEvent(new Event('change'));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} id */
|
|
27
|
+
collapse(id) {
|
|
28
|
+
if (!this.model.expanded.delete(id)) return false;
|
|
29
|
+
this.dispatchEvent(new Event('change'));
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} id */
|
|
34
|
+
toggle(id) {
|
|
35
|
+
return this.isExpanded(id) ? this.collapse(id) : this.expand(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expandAll() {
|
|
39
|
+
for (const node of this.model.nodes) {
|
|
40
|
+
if (this.hasChildren(node.id)) this.model.expanded.add(node.id);
|
|
41
|
+
}
|
|
42
|
+
this.dispatchEvent(new Event('change'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
collapseAll() {
|
|
46
|
+
this.model.expanded.clear();
|
|
47
|
+
this.dispatchEvent(new Event('change'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {number} maxDepth */
|
|
51
|
+
expandToDepth(maxDepth) {
|
|
52
|
+
this.model.expanded.clear();
|
|
53
|
+
const visit = (id, depth) => {
|
|
54
|
+
if (depth < maxDepth && this.hasChildren(id)) this.model.expanded.add(id);
|
|
55
|
+
for (const childId of this.model.index.getChildren(id)) visit(childId, depth + 1);
|
|
56
|
+
};
|
|
57
|
+
for (const rootId of this.model.index.roots) visit(rootId, 0);
|
|
58
|
+
this.dispatchEvent(new Event('change'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} id */
|
|
62
|
+
expandAncestors(id) {
|
|
63
|
+
for (const ancestorId of this.model.index.getAncestors(id)) this.model.expanded.add(ancestorId);
|
|
64
|
+
this.dispatchEvent(new Event('change'));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CPU index for structural tree queries. Rebuild this only on cold-path changes.
|
|
3
|
+
*/
|
|
4
|
+
export class TreeIndex {
|
|
5
|
+
constructor() {
|
|
6
|
+
/** @type {Map<string, number>} */
|
|
7
|
+
this.idToIndex = new Map();
|
|
8
|
+
/** @type {Map<string | null, string[]>} */
|
|
9
|
+
this.childrenByParent = new Map();
|
|
10
|
+
/** @type {string[]} */
|
|
11
|
+
this.roots = [];
|
|
12
|
+
/** @type {Array<import('./types.js').TreeNode>} */
|
|
13
|
+
this.nodes = [];
|
|
14
|
+
/** @type {Map<string, string>} */
|
|
15
|
+
this.pathById = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<import('./types.js').TreeNode>} nodes
|
|
20
|
+
*/
|
|
21
|
+
rebuild(nodes) {
|
|
22
|
+
this.idToIndex.clear();
|
|
23
|
+
this.childrenByParent.clear();
|
|
24
|
+
this.roots = [];
|
|
25
|
+
this.nodes = nodes;
|
|
26
|
+
this.pathById.clear();
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
29
|
+
const node = nodes[i];
|
|
30
|
+
if (!node?.id) throw new Error(`Tree node at index ${i} is missing id`);
|
|
31
|
+
if (this.idToIndex.has(node.id)) throw new Error(`Duplicate tree node id: ${node.id}`);
|
|
32
|
+
this.idToIndex.set(node.id, i);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const node of nodes) {
|
|
36
|
+
const parentId = node.parentId ?? null;
|
|
37
|
+
if (parentId !== null && !this.idToIndex.has(parentId)) {
|
|
38
|
+
throw new Error(`Parent ${parentId} for node ${node.id} does not exist`);
|
|
39
|
+
}
|
|
40
|
+
if (!this.childrenByParent.has(parentId)) this.childrenByParent.set(parentId, []);
|
|
41
|
+
this.childrenByParent.get(parentId).push(node.id);
|
|
42
|
+
if (parentId === null) this.roots.push(node.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const rootId of this.roots) this.#indexPaths(rootId, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} id */
|
|
49
|
+
getNode(id) {
|
|
50
|
+
const index = this.idToIndex.get(id);
|
|
51
|
+
return index === undefined ? null : this.nodes[index];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string | null} parentId */
|
|
55
|
+
getChildren(parentId) {
|
|
56
|
+
return this.childrenByParent.get(parentId) ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {string} id */
|
|
60
|
+
getAncestors(id) {
|
|
61
|
+
const ancestors = [];
|
|
62
|
+
let node = this.getNode(id);
|
|
63
|
+
while (node?.parentId) {
|
|
64
|
+
ancestors.push(node.parentId);
|
|
65
|
+
node = this.getNode(node.parentId);
|
|
66
|
+
}
|
|
67
|
+
return ancestors;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#indexPaths(id, parentPath) {
|
|
71
|
+
const node = this.getNode(id);
|
|
72
|
+
if (!node) return;
|
|
73
|
+
const label = node.label || node.id;
|
|
74
|
+
const path = parentPath ? `${parentPath}/${label}` : label;
|
|
75
|
+
this.pathById.set(id, path);
|
|
76
|
+
for (const childId of this.getChildren(id)) this.#indexPaths(childId, path);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { TreeIndex } from './tree-index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owns structural nodes, expanded state, and per-node dynamic state.
|
|
5
|
+
* Structural operations are cold-path; dynamic patches are hot-path.
|
|
6
|
+
*/
|
|
7
|
+
export class TreeModel extends EventTarget {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
/** @type {Array<import('./types.js').TreeNode>} */
|
|
11
|
+
this.nodes = [];
|
|
12
|
+
this.index = new TreeIndex();
|
|
13
|
+
/** @type {Map<string, import('./types.js').NodeDynamicState>} */
|
|
14
|
+
this.dynamicState = new Map();
|
|
15
|
+
/** @type {Set<string>} */
|
|
16
|
+
this.expanded = new Set();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {Array<import('./types.js').TreeNode>} nodes */
|
|
20
|
+
setTree(nodes) {
|
|
21
|
+
this.nodes = nodes.slice();
|
|
22
|
+
this.index.rebuild(this.nodes);
|
|
23
|
+
this.expanded = new Set(this.nodes.map((node) => node.id));
|
|
24
|
+
for (const node of this.nodes) {
|
|
25
|
+
if (!this.dynamicState.has(node.id)) this.dynamicState.set(node.id, { visible: true });
|
|
26
|
+
}
|
|
27
|
+
this.dispatchEvent(new Event('structurechange'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Array<import('./types.js').TreeNode>} nodes
|
|
32
|
+
*/
|
|
33
|
+
addNodes(nodes) {
|
|
34
|
+
this.nodes = this.nodes.concat(nodes);
|
|
35
|
+
this.index.rebuild(this.nodes);
|
|
36
|
+
for (const node of nodes) this.dynamicState.set(node.id, { visible: true });
|
|
37
|
+
this.dispatchEvent(new Event('structurechange'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string[]} ids */
|
|
41
|
+
removeNodes(ids) {
|
|
42
|
+
const remove = new Set(ids);
|
|
43
|
+
this.nodes = this.nodes.filter((node) => !remove.has(node.id) && !remove.has(node.parentId ?? ''));
|
|
44
|
+
for (const id of remove) {
|
|
45
|
+
this.dynamicState.delete(id);
|
|
46
|
+
this.expanded.delete(id);
|
|
47
|
+
}
|
|
48
|
+
this.index.rebuild(this.nodes);
|
|
49
|
+
this.dispatchEvent(new Event('structurechange'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} id */
|
|
53
|
+
expand(id) {
|
|
54
|
+
this.expanded.add(id);
|
|
55
|
+
this.dispatchEvent(new Event('structurechange'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} id */
|
|
59
|
+
collapse(id) {
|
|
60
|
+
this.expanded.delete(id);
|
|
61
|
+
this.dispatchEvent(new Event('structurechange'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Array<import('./types.js').DynamicPatch>} patches
|
|
66
|
+
*/
|
|
67
|
+
applyDynamicPatches(patches) {
|
|
68
|
+
for (const patch of patches) {
|
|
69
|
+
const current = this.dynamicState.get(patch.id);
|
|
70
|
+
if (!current) continue;
|
|
71
|
+
Object.assign(current, patch.state);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} id */
|
|
76
|
+
isVisibleByExpansion(id) {
|
|
77
|
+
const ancestors = this.index.getAncestors(id);
|
|
78
|
+
return ancestors.every((ancestorId) => this.expanded.has(ancestorId));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export class TreeViewViewport extends EventTarget {
|
|
2
|
+
constructor({ rowHeight = 28, indentWidth = 18, headerHeight = 28 } = {}) {
|
|
3
|
+
super();
|
|
4
|
+
this.rowHeight = rowHeight;
|
|
5
|
+
this.indentWidth = indentWidth;
|
|
6
|
+
this.headerHeight = headerHeight;
|
|
7
|
+
this.renderInsetX = 0;
|
|
8
|
+
this.renderInsetY = 0;
|
|
9
|
+
this.scrollX = 0;
|
|
10
|
+
this.scrollY = 0;
|
|
11
|
+
this.viewportWidth = 1;
|
|
12
|
+
this.viewportHeight = 1;
|
|
13
|
+
this.contentWidth = 1;
|
|
14
|
+
this.contentHeight = 1;
|
|
15
|
+
this.zoom = 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get rowViewportHeight() {
|
|
19
|
+
return Math.max(1, this.viewportHeight - this.headerHeight);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
resize(width, height) {
|
|
23
|
+
this.viewportWidth = Math.max(1, width);
|
|
24
|
+
this.viewportHeight = Math.max(1, height);
|
|
25
|
+
this.clamp();
|
|
26
|
+
this.dispatchEvent(new Event('change'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setContentSize(width, height) {
|
|
30
|
+
this.contentWidth = Math.max(1, width);
|
|
31
|
+
this.contentHeight = Math.max(1, height);
|
|
32
|
+
this.clamp();
|
|
33
|
+
this.dispatchEvent(new Event('change'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
scrollBy(dx, dy) {
|
|
37
|
+
this.scrollX += dx;
|
|
38
|
+
this.scrollY += dy;
|
|
39
|
+
this.clamp();
|
|
40
|
+
this.dispatchEvent(new Event('change'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
scrollTo(x, y) {
|
|
44
|
+
this.scrollX = x;
|
|
45
|
+
this.scrollY = y;
|
|
46
|
+
this.clamp();
|
|
47
|
+
this.dispatchEvent(new Event('change'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {number} rowIndex @param {'start' | 'center' | 'end' | 'nearest'} align */
|
|
51
|
+
scrollRowIntoView(rowIndex, align = 'nearest') {
|
|
52
|
+
const rowTop = rowIndex * this.rowHeight;
|
|
53
|
+
const rowBottom = rowTop + this.rowHeight;
|
|
54
|
+
if (align === 'start') this.scrollY = rowTop;
|
|
55
|
+
else if (align === 'center') this.scrollY = rowTop - (this.rowViewportHeight - this.rowHeight) / 2;
|
|
56
|
+
else if (align === 'end') this.scrollY = rowBottom - this.rowViewportHeight;
|
|
57
|
+
else if (rowTop < this.scrollY) this.scrollY = rowTop;
|
|
58
|
+
else if (rowBottom > this.scrollY + this.rowViewportHeight) this.scrollY = rowBottom - this.rowViewportHeight;
|
|
59
|
+
this.clamp();
|
|
60
|
+
this.dispatchEvent(new Event('change'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clamp() {
|
|
64
|
+
const maxX = Math.max(0, this.contentWidth - this.viewportWidth);
|
|
65
|
+
const maxY = Math.max(0, this.contentHeight - this.rowViewportHeight);
|
|
66
|
+
this.scrollX = Math.max(0, Math.min(maxX, this.scrollX));
|
|
67
|
+
this.scrollY = Math.max(0, Math.min(maxY, this.scrollY));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class TreeWorkerClient {
|
|
2
|
+
constructor(workerUrl = new URL('../workers/tree-worker.js', import.meta.url)) {
|
|
3
|
+
if (typeof Worker === 'undefined') throw new Error('Worker is not available');
|
|
4
|
+
this.worker = new Worker(workerUrl, { type: 'module' });
|
|
5
|
+
this.nextId = 1;
|
|
6
|
+
this.pending = new Map();
|
|
7
|
+
this.ready = Promise.resolve();
|
|
8
|
+
this.worker.addEventListener('message', this.#onMessage);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
setData(nodes) {
|
|
12
|
+
this.ready = this.#request('setData', { nodes });
|
|
13
|
+
return this.ready;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async search(query, options = {}) {
|
|
17
|
+
await this.ready;
|
|
18
|
+
return this.#request('search', { query, options });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async rebuildRows(options = {}) {
|
|
22
|
+
await this.ready;
|
|
23
|
+
return this.#request('rebuildRows', options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
destroy() {
|
|
27
|
+
for (const { reject } of this.pending.values()) reject(new Error('Tree worker destroyed'));
|
|
28
|
+
this.pending.clear();
|
|
29
|
+
this.worker.removeEventListener('message', this.#onMessage);
|
|
30
|
+
this.worker.terminate();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#request(type, payload) {
|
|
34
|
+
const id = this.nextId++;
|
|
35
|
+
const promise = new Promise((resolve, reject) => {
|
|
36
|
+
this.pending.set(id, { resolve, reject });
|
|
37
|
+
});
|
|
38
|
+
this.worker.postMessage({ id, type, payload });
|
|
39
|
+
return promise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#onMessage = (event) => {
|
|
43
|
+
const { id, ok, result, error } = event.data ?? {};
|
|
44
|
+
const pending = this.pending.get(id);
|
|
45
|
+
if (!pending) return;
|
|
46
|
+
this.pending.delete(id);
|
|
47
|
+
if (ok) pending.resolve(result);
|
|
48
|
+
else pending.reject(new Error(error || 'Tree worker request failed'));
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export function createWorkerTreeState(nodes) {
|
|
2
|
+
const idToIndex = new Map();
|
|
3
|
+
const childrenByParent = new Map();
|
|
4
|
+
const parentById = new Map();
|
|
5
|
+
const roots = [];
|
|
6
|
+
const pathById = new Map();
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < nodes.length; i++) idToIndex.set(nodes[i].id, i);
|
|
9
|
+
for (const node of nodes) {
|
|
10
|
+
const parentId = node.parentId ?? null;
|
|
11
|
+
parentById.set(node.id, parentId);
|
|
12
|
+
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []);
|
|
13
|
+
childrenByParent.get(parentId).push(node.id);
|
|
14
|
+
if (parentId === null) roots.push(node.id);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const indexPaths = (id, parentPath) => {
|
|
18
|
+
const node = nodes[idToIndex.get(id)];
|
|
19
|
+
if (!node) return;
|
|
20
|
+
const label = node.label || node.id;
|
|
21
|
+
const path = parentPath ? `${parentPath}/${label}` : label;
|
|
22
|
+
pathById.set(id, path);
|
|
23
|
+
for (const childId of childrenByParent.get(id) ?? []) indexPaths(childId, path);
|
|
24
|
+
};
|
|
25
|
+
for (const rootId of roots) indexPaths(rootId, '');
|
|
26
|
+
|
|
27
|
+
const records = nodes.map((node) => {
|
|
28
|
+
const path = pathById.get(node.id) ?? '';
|
|
29
|
+
const tags = (node.tags ?? []).join(' ');
|
|
30
|
+
return {
|
|
31
|
+
id: node.id,
|
|
32
|
+
label: node.label ?? '',
|
|
33
|
+
path,
|
|
34
|
+
tags,
|
|
35
|
+
type: node.type ?? '',
|
|
36
|
+
searchText: normalize(`${node.id} ${node.label ?? ''} ${path} ${tags} ${node.type ?? ''}`),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return { nodes, idToIndex, childrenByParent, parentById, roots, pathById, records };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function searchWorkerTree(state, query, { fields = ['label', 'id', 'path', 'tags', 'type'], limit = 500 } = {}) {
|
|
44
|
+
const q = normalize(query);
|
|
45
|
+
if (!q) return [];
|
|
46
|
+
const results = [];
|
|
47
|
+
const defaultFields = fields.length === 5 && fields.includes('label') && fields.includes('id') && fields.includes('path') && fields.includes('tags') && fields.includes('type');
|
|
48
|
+
|
|
49
|
+
for (const record of state.records) {
|
|
50
|
+
const matches = defaultFields ? record.searchText.includes(q) : fields.some((field) => normalize(record[field]).includes(q));
|
|
51
|
+
if (!matches) continue;
|
|
52
|
+
results.push(record.id);
|
|
53
|
+
if (results.length >= limit) break;
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function rebuildWorkerRows(state, options = {}) {
|
|
59
|
+
const rowHeight = options.rowHeight ?? 28;
|
|
60
|
+
const indentWidth = options.indentWidth ?? 18;
|
|
61
|
+
const expanded = new Set(options.expandedIds ?? []);
|
|
62
|
+
const query = normalize(options.filterQuery ?? '');
|
|
63
|
+
const sort = options.sort ?? { columnId: null, direction: null };
|
|
64
|
+
const sortValues = options.sortValues ? new Map(options.sortValues) : null;
|
|
65
|
+
const includedIds = options.includedIds ? new Set(options.includedIds) : query ? getIncludedIdsForQuery(state, query).includedIds : null;
|
|
66
|
+
const rows = [];
|
|
67
|
+
let maxDepth = 0;
|
|
68
|
+
|
|
69
|
+
const hasChildren = (id) => (state.childrenByParent.get(id) ?? []).length > 0;
|
|
70
|
+
const nodeForId = (id) => state.nodes[state.idToIndex.get(id)];
|
|
71
|
+
const isIncluded = (id) => !includedIds || includedIds.has(id);
|
|
72
|
+
|
|
73
|
+
const sortIds = (ids) => {
|
|
74
|
+
const filtered = includedIds ? ids.filter((id) => includedIds.has(id)) : ids;
|
|
75
|
+
if (!sort.columnId || !sort.direction) return filtered;
|
|
76
|
+
const direction = sort.direction === 'desc' ? -1 : 1;
|
|
77
|
+
return filtered.slice().sort((aId, bId) => compareNodes(nodeForId(aId), nodeForId(bId), sort.columnId, sortValues) * direction);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const visit = (id, depth) => {
|
|
81
|
+
if (!isIncluded(id)) return;
|
|
82
|
+
const nodeIndex = state.idToIndex.get(id);
|
|
83
|
+
if (nodeIndex === undefined) return;
|
|
84
|
+
const expandedRow = expanded.has(id);
|
|
85
|
+
const rowIndex = rows.length;
|
|
86
|
+
rows.push({
|
|
87
|
+
nodeId: id,
|
|
88
|
+
nodeIndex,
|
|
89
|
+
depth,
|
|
90
|
+
rowIndex,
|
|
91
|
+
y: rowIndex * rowHeight,
|
|
92
|
+
height: rowHeight,
|
|
93
|
+
expanded: expandedRow,
|
|
94
|
+
hasChildren: hasChildren(id),
|
|
95
|
+
});
|
|
96
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
97
|
+
if (!expandedRow && !query) return;
|
|
98
|
+
for (const childId of sortIds(state.childrenByParent.get(id) ?? [])) visit(childId, depth + 1);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const rootId of sortIds(state.roots)) visit(rootId, 0);
|
|
102
|
+
return {
|
|
103
|
+
rows,
|
|
104
|
+
contentHeight: rows.length * rowHeight,
|
|
105
|
+
contentWidth: Math.max(640, (maxDepth + 1) * indentWidth + 520),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getIncludedIdsForQuery(state, query, candidateIds = null) {
|
|
110
|
+
const q = normalize(query);
|
|
111
|
+
const includedIds = new Set();
|
|
112
|
+
const matchingIds = [];
|
|
113
|
+
const records = candidateIds ? idsToRecords(state, candidateIds) : state.records;
|
|
114
|
+
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
if (!record.searchText.includes(q)) continue;
|
|
117
|
+
matchingIds.push(record.id);
|
|
118
|
+
let id = record.id;
|
|
119
|
+
while (id !== null && id !== undefined && !includedIds.has(id)) {
|
|
120
|
+
includedIds.add(id);
|
|
121
|
+
id = state.parentById.get(id) ?? null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { matchingIds, includedIds };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function idsToRecords(state, ids) {
|
|
128
|
+
const records = [];
|
|
129
|
+
for (const id of ids) {
|
|
130
|
+
const index = state.idToIndex.get(id);
|
|
131
|
+
if (index !== undefined) records.push(state.records[index]);
|
|
132
|
+
}
|
|
133
|
+
return records;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compareNodes(a, b, columnId, sortValues = null) {
|
|
137
|
+
const aValue = sortValues ? sortValues.get(a?.id) : columnValue(a, columnId);
|
|
138
|
+
const bValue = sortValues ? sortValues.get(b?.id) : columnValue(b, columnId);
|
|
139
|
+
if (typeof aValue === 'number' && typeof bValue === 'number') return aValue - bValue;
|
|
140
|
+
return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function columnValue(node, columnId) {
|
|
144
|
+
if (!node) return '';
|
|
145
|
+
if (columnId === 'name') return node.label ?? node.id;
|
|
146
|
+
if (columnId === 'type') return node.type ?? '';
|
|
147
|
+
return node[columnId] ?? '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalize(value) {
|
|
151
|
+
return String(value ?? '').toLowerCase();
|
|
152
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} TreeNode
|
|
3
|
+
* @property {string} id
|
|
4
|
+
* @property {string | null} [parentId]
|
|
5
|
+
* @property {string} [label]
|
|
6
|
+
* @property {string} [type]
|
|
7
|
+
* @property {string} [icon]
|
|
8
|
+
* @property {string} [image]
|
|
9
|
+
* @property {string[]} [tags]
|
|
10
|
+
* @property {any} [data]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} NodeDynamicState
|
|
15
|
+
* @property {number} [value]
|
|
16
|
+
* @property {number | string} [status]
|
|
17
|
+
* @property {number} [progress]
|
|
18
|
+
* @property {number} [pulse]
|
|
19
|
+
* @property {string} [color]
|
|
20
|
+
* @property {boolean} [selected]
|
|
21
|
+
* @property {boolean} [highlighted]
|
|
22
|
+
* @property {boolean} [visible]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} LayoutNode
|
|
27
|
+
* @property {number} index
|
|
28
|
+
* @property {string} id
|
|
29
|
+
* @property {number} parentIndex
|
|
30
|
+
* @property {number} x
|
|
31
|
+
* @property {number} y
|
|
32
|
+
* @property {number} width
|
|
33
|
+
* @property {number} height
|
|
34
|
+
* @property {number} depth
|
|
35
|
+
* @property {number} iconIndex
|
|
36
|
+
* @property {number} typeIndex
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} DynamicPatch
|
|
41
|
+
* @property {string} id
|
|
42
|
+
* @property {NodeDynamicState} state
|
|
43
|
+
*/
|
|
44
|
+
|