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