virtual-tree-canvas 0.3.9 → 0.5.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 (36) hide show
  1. package/README.md +173 -225
  2. package/docs/inspector-demo.png +0 -0
  3. package/docs/tree-demo.png +0 -0
  4. package/package.json +2 -1
  5. package/src/assets/icons/air.svg +1 -0
  6. package/src/assets/icons/aircraft.svg +1 -0
  7. package/src/assets/icons/bus.svg +1 -0
  8. package/src/assets/icons/control.svg +1 -0
  9. package/src/assets/icons/damage.svg +1 -0
  10. package/src/assets/icons/error.svg +1 -0
  11. package/src/assets/icons/folder.svg +1 -0
  12. package/src/assets/icons/ground.svg +1 -0
  13. package/src/assets/icons/inspector-array.svg +1 -0
  14. package/src/assets/icons/inspector-object.svg +1 -0
  15. package/src/assets/icons/inspector-value.svg +1 -0
  16. package/src/assets/icons/munition.svg +1 -0
  17. package/src/assets/icons/placeholder.svg +1 -0
  18. package/src/assets/icons/point.svg +1 -0
  19. package/src/assets/icons/radar.svg +1 -0
  20. package/src/assets/icons/situation.svg +1 -0
  21. package/src/assets/icons/space.svg +1 -0
  22. package/src/assets/icons/subsurface.svg +1 -0
  23. package/src/assets/icons/surface.svg +1 -0
  24. package/src/assets/icons/task.svg +1 -0
  25. package/src/assets/icons/track.svg +1 -0
  26. package/src/assets/icons/warning.svg +1 -0
  27. package/src/assets/icons.js +25 -0
  28. package/src/core/icon-registry.js +173 -495
  29. package/src/core/theme-manager.js +1 -0
  30. package/src/core/tree-worker-client.js +17 -0
  31. package/src/index.d.ts +17 -0
  32. package/src/inspector/cell-editor-manager.js +23 -18
  33. package/src/inspector/editor-resolver.js +6 -1
  34. package/src/inspector/numeric-layout.js +16 -0
  35. package/src/renderers/tree-row-renderer.js +56 -4
  36. package/src/tree-view-controller.js +13 -5
@@ -2,6 +2,7 @@ export const darkTheme = {
2
2
  rowHeight: 28,
3
3
  indentWidth: 18,
4
4
  font: '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
5
+ unitFont: '11px ui-monospace, SFMono-Regular, Consolas, monospace',
5
6
  monoFont: '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace',
6
7
  colors: {
7
8
  background: '#0a0f1c',
@@ -6,6 +6,8 @@ export class TreeWorkerClient {
6
6
  this.pending = new Map();
7
7
  this.ready = Promise.resolve();
8
8
  this.worker.addEventListener('message', this.#onMessage);
9
+ this.worker.addEventListener('error', this.#onError);
10
+ this.worker.addEventListener('messageerror', this.#onMessageError);
9
11
  }
10
12
 
11
13
  setData(nodes) {
@@ -27,6 +29,8 @@ export class TreeWorkerClient {
27
29
  for (const { reject } of this.pending.values()) reject(new Error('Tree worker destroyed'));
28
30
  this.pending.clear();
29
31
  this.worker.removeEventListener('message', this.#onMessage);
32
+ this.worker.removeEventListener('error', this.#onError);
33
+ this.worker.removeEventListener('messageerror', this.#onMessageError);
30
34
  this.worker.terminate();
31
35
  }
32
36
 
@@ -47,4 +51,17 @@ export class TreeWorkerClient {
47
51
  if (ok) pending.resolve(result);
48
52
  else pending.reject(new Error(error || 'Tree worker request failed'));
49
53
  };
54
+
55
+ #onError = (event) => {
56
+ this.#rejectPending(new Error(event.message || 'Tree worker failed'));
57
+ };
58
+
59
+ #onMessageError = () => {
60
+ this.#rejectPending(new Error('Tree worker returned an unreadable message'));
61
+ };
62
+
63
+ #rejectPending(error) {
64
+ for (const { reject } of this.pending.values()) reject(error);
65
+ this.pending.clear();
66
+ }
50
67
  }
package/src/index.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
2
2
 
3
+ export type IconDrawFunction = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) => void;
4
+ export type IconSource = string | CanvasImageSource | IconDrawFunction;
5
+
6
+ export class IconRegistry {
7
+ constructor(options?: { pixelRatio?: number });
8
+ register(name: string, icon: IconSource): any;
9
+ get(name: string): any;
10
+ onChange(listener: () => void): () => void;
11
+ prepare(options?: { icons?: Iterable<string>; size?: number; color?: string; pixelRatio?: number }): Promise<any[]>;
12
+ draw(ctx: CanvasRenderingContext2D, name: string, x: number, y: number, size: number, color: string): void;
13
+ }
14
+
3
15
  export type TreeNode = {
4
16
  id: string;
5
17
  parentId?: string | null;
@@ -21,6 +33,10 @@ export type MetaRule = {
21
33
  max?: number;
22
34
  step?: number;
23
35
  integer?: boolean;
36
+ /** Display-only suffix for numeric values. Never appended to the model value. */
37
+ unit?: string;
38
+ /** Display decimal places (0–20). Editing retains the original precision. */
39
+ precision?: number;
24
40
  readonly?: boolean;
25
41
  disabled?: boolean;
26
42
  updated?: boolean;
@@ -86,6 +102,7 @@ export class TreeViewController {
86
102
  setDynamicState(patches: DynamicPatch[]): void;
87
103
  setTheme(theme: any): void;
88
104
  setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
105
+ registerIcon(name: string, icon: IconSource): any;
89
106
  resize(width: number, height: number): void;
90
107
  render(time?: number): void;
91
108
  renderMeasured(time?: number): any;
@@ -1,3 +1,5 @@
1
+ import { numericLayout } from './numeric-layout.js';
2
+
1
3
  export class CellEditorManager {
2
4
  constructor({ controller, host = null }) {
3
5
  if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
@@ -81,6 +83,7 @@ export class CellEditorManager {
81
83
  const hostRect = this.host.getBoundingClientRect();
82
84
  const element = createEditorElement(data);
83
85
  element.className = `vtc-editor vtc-editor-${data.editorType}`;
86
+ element.setAttribute('aria-label', [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' '));
84
87
  Object.assign(element.style, {
85
88
  position: 'absolute',
86
89
  left: `${rect.x - hostRect.left}px`,
@@ -208,34 +211,36 @@ export class CellEditorManager {
208
211
  }
209
212
 
210
213
  #isEditableHit(hit) {
211
- return hit?.area === 'row' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
214
+ return hit?.area === 'row' && hit.part !== 'unit' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
212
215
  }
213
216
 
214
- #overlayRect(hit) {
215
- if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
217
+ #editorContentRect(hit) {
216
218
  const rect = this.controller.getCellClientRect(hit);
219
+ if (hit.column?.kind !== 'inspectorPane') {
220
+ return { x: rect.x + 10, y: rect.y + 4, width: Math.max(24, rect.width - 20), height: rect.height - 8 };
221
+ }
217
222
  const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
218
223
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
219
224
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
220
- if (hit.part === 'number') {
221
- const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
222
- return { x: rect.x + editorLeft + editorWidth - valueWidth - 10, y: rect.y + 4, width: valueWidth, height: rect.height - 8 };
225
+ return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: Math.max(24, editorWidth - 20), height: rect.height - 8 };
226
+ }
227
+
228
+ #overlayRect(hit) {
229
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
230
+ const rect = this.#editorContentRect(hit);
231
+ const layout = numericLayout(rect.width, data);
232
+ if (hit.part === 'number' && data.editorType === 'range') {
233
+ return { ...rect, x: rect.x + layout.numberLeft, width: layout.valueWidth };
223
234
  }
224
- return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: editorWidth - 20, height: rect.height - 8 };
235
+ if (layout.unit) return { ...rect, width: layout.contentWidth };
236
+ if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
237
+ return rect;
225
238
  }
226
239
 
227
240
  #rangeBarRect(hit) {
228
- const rect = this.controller.getCellClientRect(hit);
229
- if (hit.column?.kind === 'inspectorPane') {
230
- const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
231
- const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
232
- const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
233
- const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
234
- const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
235
- return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
236
- }
237
- const valueWidth = Math.min(64, Math.max(42, (rect.width - 20) * 0.28));
238
- return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
241
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
242
+ const rect = this.#editorContentRect(hit);
243
+ return { ...rect, y: rect.y + rect.height / 2 - 4, width: numericLayout(rect.width, data).barWidth, height: 8 };
239
244
  }
240
245
 
241
246
  #clampRectToHost(rect, inset = 0) {
@@ -28,6 +28,11 @@ export function formatInspectorValue(value, meta = {}) {
28
28
  if (value === null) return 'null';
29
29
  if (value === undefined) return 'undefined';
30
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);
31
+ if (typeof value === 'number') {
32
+ if (Number.isFinite(value) && Number.isInteger(meta.precision) && meta.precision >= 0 && meta.precision <= 20) {
33
+ return value.toFixed(meta.precision);
34
+ }
35
+ return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
36
+ }
32
37
  return String(value);
33
38
  }
@@ -0,0 +1,16 @@
1
+ /** Shared geometry for canvas drawing, hit testing and the numeric editor. */
2
+ export function numericLayout(width, data = {}) {
3
+ width = Math.max(0, width);
4
+ const numeric = typeof data.value === 'number' && !data.meta?.options && !data.meta?.button;
5
+ const unit = numeric && typeof data.meta?.unit === 'string' ? data.meta.unit.trim() : '';
6
+ // Reserve a stable slot: changes to the number must not move the unit.
7
+ const gap = unit ? 6 : 0;
8
+ const unitWidth = unit ? Math.min(Math.max(24, [...unit].length * 7), 96, Math.max(0, width - 48)) : 0;
9
+ const contentWidth = Math.max(0, width - unitWidth - gap);
10
+ const valueWidth = Math.min(64, Math.max(42, contentWidth * 0.28), Math.max(0, contentWidth - 32));
11
+ return {
12
+ unit, unitWidth, unitLeft: contentWidth + gap, contentWidth,
13
+ valueWidth, numberLeft: contentWidth - valueWidth,
14
+ barWidth: Math.max(0, contentWidth - valueWidth - 8),
15
+ };
16
+ }
@@ -1,3 +1,4 @@
1
+ import { numericLayout } from '../inspector/numeric-layout.js';
1
2
  import { IconRegistry } from '../core/icon-registry.js';
2
3
 
3
4
  const DISABLED_ALPHA = 0.72;
@@ -9,6 +10,9 @@ export class TreeRowRenderer {
9
10
  this.scene = null;
10
11
  this.iconRegistry = iconRegistry ?? new IconRegistry();
11
12
  this.renderedRows = 0;
13
+ this.iconRenderQueued = false;
14
+ this.preparedIconThemeKey = null;
15
+ this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
12
16
  }
13
17
 
14
18
  /** @param {HTMLCanvasElement} canvas */
@@ -22,6 +26,14 @@ export class TreeRowRenderer {
22
26
  this.scene = scene;
23
27
  }
24
28
 
29
+ destroy() {
30
+ this.stopIconListener?.();
31
+ this.stopIconListener = null;
32
+ this.canvas = null;
33
+ this.ctx = null;
34
+ this.scene = null;
35
+ }
36
+
25
37
  updateDynamicState(_patches) {
26
38
  // Canvas2D reads the state map directly; patches still stay on the hot path.
27
39
  }
@@ -43,6 +55,7 @@ export class TreeRowRenderer {
43
55
 
44
56
  const ctx = this.ctx;
45
57
  const colors = theme.colors;
58
+ this.#prepareThemeIcons(theme, dpr);
46
59
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
47
60
  ctx.fillStyle = colors.background;
48
61
  ctx.fillRect(0, 0, viewportWidth, viewportHeight);
@@ -55,6 +68,29 @@ export class TreeRowRenderer {
55
68
  ctx.restore();
56
69
  }
57
70
 
71
+ #scheduleIconRender() {
72
+ if (this.iconRenderQueued) return;
73
+ this.iconRenderQueued = true;
74
+ const schedule = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0));
75
+ schedule(() => {
76
+ this.iconRenderQueued = false;
77
+ this.render();
78
+ });
79
+ }
80
+
81
+ #prepareThemeIcons(theme, pixelRatio) {
82
+ const iconColors = new Map([['placeholder', theme.colors.textMuted]]);
83
+ for (const style of Object.values(theme.types ?? {})) {
84
+ if (style?.icon) iconColors.set(style.icon, style.color ?? theme.colors.progressFill);
85
+ }
86
+ const key = `${pixelRatio}|${[...iconColors].map(([icon, color]) => `${icon}:${color}`).join(',')}`;
87
+ if (key === this.preparedIconThemeKey) return;
88
+ this.preparedIconThemeKey = key;
89
+ for (const [icon, color] of iconColors) {
90
+ this.iconRegistry.prepare?.({ icons: [icon], size: 15, color, pixelRatio });
91
+ }
92
+ }
93
+
58
94
  #drawHeader(ctx) {
59
95
  const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
60
96
  if (viewport.headerHeight <= 0) return;
@@ -309,13 +345,22 @@ export class TreeRowRenderer {
309
345
  const readonly = data.readonly;
310
346
  const x = rect.x + 10;
311
347
  const y = rect.y + 5;
312
- const width = Math.max(24, rect.width - 20);
348
+ const fullWidth = Math.max(24, rect.width - 20);
349
+ const numeric = numericLayout(fullWidth, data);
350
+ const width = numeric.contentWidth;
313
351
  const height = rect.height - 10;
314
352
  ctx.font = theme.font;
315
353
  ctx.textBaseline = 'middle';
316
354
  ctx.textAlign = 'left';
317
355
  ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
318
356
 
357
+ if (numeric.unit && numeric.unitWidth) {
358
+ ctx.font = theme.unitFont ?? theme.monoFont ?? theme.font;
359
+ ctx.fillStyle = theme.colors.unit ?? theme.colors.textMuted;
360
+ drawTruncatedText(ctx, numeric.unit, x + numeric.unitLeft, rect.y + rect.height / 2, numeric.unitWidth);
361
+ ctx.font = theme.font;
362
+ }
363
+
319
364
  if (data.editorType === 'checkbox') {
320
365
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
321
366
  } else if (data.editorType === 'range') {
@@ -350,7 +395,15 @@ export class TreeRowRenderer {
350
395
  this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
351
396
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
352
397
  } else {
353
- this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
398
+ if (numeric.unit) {
399
+ ctx.font = theme.monoFont ?? theme.font;
400
+ ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
401
+ ctx.textAlign = 'right';
402
+ drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
403
+ ctx.textAlign = 'left';
404
+ } else {
405
+ this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
406
+ }
354
407
  }
355
408
 
356
409
  if (meta.updated && !suppressUpdatedMarker) {
@@ -397,9 +450,8 @@ export class TreeRowRenderer {
397
450
  const max = meta.max ?? 100;
398
451
  const value = typeof data.value === 'number' ? data.value : min;
399
452
  const ratio = max === min ? 0 : clamp01((value - min) / (max - min));
400
- const valueWidth = Math.min(64, Math.max(42, width * 0.28));
453
+ const { valueWidth, barWidth } = numericLayout(width);
401
454
  const gap = 8;
402
- const barWidth = Math.max(24, width - valueWidth - gap);
403
455
  this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
404
456
  this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
405
457
  hovered: Boolean(state.hoveredNumber),
@@ -12,6 +12,7 @@ import {
12
12
  TreeViewViewport,
13
13
  VisibleRowModel,
14
14
  } from './core/index.js';
15
+ import { numericLayout } from './inspector/numeric-layout.js';
15
16
  import { CellEditorManager } from './inspector/cell-editor-manager.js';
16
17
  import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
17
18
  import { TreeViewInputController } from './input/tree-view-input-controller.js';
@@ -113,6 +114,7 @@ export class TreeViewController {
113
114
  this.#nativeScroll = null;
114
115
  this.inputController = null;
115
116
  this.cellEditor = null;
117
+ this.renderer?.destroy?.();
116
118
  this.canvas = null;
117
119
  }
118
120
 
@@ -212,8 +214,14 @@ export class TreeViewController {
212
214
 
213
215
  enableWorkers(workerUrl) {
214
216
  if (this.workerClient) return Promise.resolve(this);
215
- this.workerClient = new TreeWorkerClient(workerUrl);
216
- return this.workerClient.setData(this.model.nodes).then(() => this);
217
+ const client = new TreeWorkerClient(workerUrl);
218
+ this.workerClient = client;
219
+ return client.setData(this.model.nodes)
220
+ .then(() => this)
221
+ .catch((error) => {
222
+ if (this.workerClient === client) this.disableWorkers();
223
+ throw error;
224
+ });
217
225
  }
218
226
 
219
227
  disableWorkers() {
@@ -782,11 +790,11 @@ export class TreeViewController {
782
790
  const node = this.model.nodes[row.nodeIndex];
783
791
  const data = node?.data;
784
792
  if (!data?.inspector) return 'cell';
793
+ const numeric = numericLayout(Math.max(24, editorWidth - 20), data);
794
+ if (numeric.unit && localEditorX >= 10 + numeric.unitLeft) return 'unit';
785
795
  if (data.editorType === 'checkbox') return 'checkbox';
786
796
  if (data.editorType === 'range') {
787
- const width = Math.max(24, editorWidth - 20);
788
- const valueWidth = Math.min(64, Math.max(42, width * 0.28));
789
- const numberLeft = editorWidth - valueWidth - 10;
797
+ const numberLeft = 10 + numeric.numberLeft;
790
798
  return localEditorX >= numberLeft ? 'number' : 'range';
791
799
  }
792
800
  if (data.editorType === 'button') return 'button';