virtual-tree-canvas 0.4.0 → 0.6.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 (94) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +115 -28
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +380 -0
  5. package/docs/icon-catalog.md +167 -0
  6. package/examples/generate-tree.js +86 -0
  7. package/examples/reorder-table.html +33 -0
  8. package/examples/units.html +43 -0
  9. package/package.json +7 -3
  10. package/resources/icons/bicycle.svg +1 -0
  11. package/resources/icons/bird.svg +1 -0
  12. package/resources/icons/box.svg +1 -0
  13. package/resources/icons/building.svg +1 -0
  14. package/resources/icons/calendar.svg +1 -0
  15. package/resources/icons/car.svg +1 -0
  16. package/resources/icons/cat.svg +1 -0
  17. package/resources/icons/child.svg +1 -0
  18. package/resources/icons/clock.svg +1 -0
  19. package/resources/icons/cloud.svg +1 -0
  20. package/resources/icons/data-bus.svg +1 -0
  21. package/resources/icons/database.svg +1 -0
  22. package/resources/icons/dog.svg +1 -0
  23. package/resources/icons/drone.svg +1 -0
  24. package/resources/icons/fish.svg +1 -0
  25. package/resources/icons/flower.svg +1 -0
  26. package/resources/icons/fog.svg +1 -0
  27. package/resources/icons/globe.svg +1 -0
  28. package/resources/icons/grip.svg +1 -0
  29. package/resources/icons/heart.svg +1 -0
  30. package/resources/icons/helicopter.svg +1 -0
  31. package/resources/icons/insect.svg +1 -0
  32. package/resources/icons/leaf.svg +1 -0
  33. package/resources/icons/link.svg +1 -0
  34. package/resources/icons/lock.svg +1 -0
  35. package/resources/icons/moon.svg +1 -0
  36. package/resources/icons/network.svg +1 -0
  37. package/resources/icons/people.svg +1 -0
  38. package/resources/icons/person.svg +1 -0
  39. package/resources/icons/pin.svg +1 -0
  40. package/resources/icons/radar.svg +1 -0
  41. package/resources/icons/rain.svg +1 -0
  42. package/resources/icons/seedling.svg +1 -0
  43. package/resources/icons/sensor.svg +1 -0
  44. package/resources/icons/server.svg +1 -0
  45. package/resources/icons/ship.svg +1 -0
  46. package/resources/icons/snow.svg +1 -0
  47. package/resources/icons/star.svg +1 -0
  48. package/resources/icons/storm.svg +1 -0
  49. package/resources/icons/sun.svg +1 -0
  50. package/resources/icons/thermometer.svg +1 -0
  51. package/resources/icons/tree.svg +1 -0
  52. package/resources/icons/wind.svg +1 -0
  53. package/src/assets/icons.js +73 -25
  54. package/src/core/icon-registry.js +6 -8
  55. package/src/core/theme-manager.js +15 -4
  56. package/src/core/tree-column-model.js +16 -3
  57. package/src/core/tree-model.js +20 -0
  58. package/src/core/view-options.js +30 -0
  59. package/src/index.d.ts +166 -5
  60. package/src/index.js +3 -0
  61. package/src/input/index.js +1 -0
  62. package/src/input/row-reorder-input.js +143 -0
  63. package/src/input/tree-filter-bar.js +55 -0
  64. package/src/input/tree-tooltip.js +55 -0
  65. package/src/input/tree-view-input-controller.js +5 -0
  66. package/src/inspector/cell-editor-manager.js +42 -22
  67. package/src/inspector/editor-resolver.js +6 -1
  68. package/src/inspector/numeric-layout.js +16 -0
  69. package/src/renderers/tree-row-renderer.js +79 -13
  70. package/src/tree-view-controller.js +261 -34
  71. package/src/tree-view.js +181 -0
  72. package/src/view/styles.js +14 -0
  73. package/src/assets/icons/air.svg +0 -1
  74. package/src/assets/icons/radar.svg +0 -1
  75. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  76. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  77. /package/{src/assets → resources}/icons/control.svg +0 -0
  78. /package/{src/assets → resources}/icons/damage.svg +0 -0
  79. /package/{src/assets → resources}/icons/error.svg +0 -0
  80. /package/{src/assets → resources}/icons/folder.svg +0 -0
  81. /package/{src/assets → resources}/icons/ground.svg +0 -0
  82. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  83. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  84. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  85. /package/{src/assets → resources}/icons/munition.svg +0 -0
  86. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  87. /package/{src/assets → resources}/icons/point.svg +0 -0
  88. /package/{src/assets → resources}/icons/situation.svg +0 -0
  89. /package/{src/assets → resources}/icons/space.svg +0 -0
  90. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  91. /package/{src/assets → resources}/icons/surface.svg +0 -0
  92. /package/{src/assets → resources}/icons/task.svg +0 -0
  93. /package/{src/assets → resources}/icons/track.svg +0 -0
  94. /package/{src/assets → resources}/icons/warning.svg +0 -0
@@ -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,7 +10,8 @@ export class TreeRowRenderer {
9
10
  this.scene = null;
10
11
  this.iconRegistry = iconRegistry ?? new IconRegistry();
11
12
  this.renderedRows = 0;
12
- this.iconRenderQueued = false;
13
+ this.iconRenderFrame = null;
14
+ this.invalidate = null;
13
15
  this.preparedIconThemeKey = null;
14
16
  this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
15
17
  }
@@ -25,7 +27,15 @@ export class TreeRowRenderer {
25
27
  this.scene = scene;
26
28
  }
27
29
 
30
+ setInvalidationHandler(invalidate) { this.invalidate = invalidate; }
31
+
28
32
  destroy() {
33
+ if (this.iconRenderFrame !== null) {
34
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
35
+ (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.iconRenderFrame);
36
+ }
37
+ this.iconRenderFrame = null;
38
+ this.invalidate = null;
29
39
  this.stopIconListener?.();
30
40
  this.stopIconListener = null;
31
41
  this.canvas = null;
@@ -64,15 +74,18 @@ export class TreeRowRenderer {
64
74
  this.#drawHeader(ctx);
65
75
  this.#drawRows(ctx);
66
76
  this.#drawStickyRows(ctx);
77
+ this.#drawRowDrop(ctx);
67
78
  ctx.restore();
68
79
  }
69
80
 
70
81
  #scheduleIconRender() {
71
- if (this.iconRenderQueued) return;
72
- this.iconRenderQueued = true;
73
- const schedule = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0));
74
- schedule(() => {
75
- this.iconRenderQueued = false;
82
+ if (!this.canvas) return;
83
+ if (this.invalidate) return this.invalidate();
84
+ if (this.iconRenderFrame !== null) return;
85
+ const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
86
+ const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
87
+ this.iconRenderFrame = schedule(() => {
88
+ this.iconRenderFrame = null;
76
89
  this.render();
77
90
  });
78
91
  }
@@ -106,7 +119,7 @@ export class TreeRowRenderer {
106
119
  ctx.textBaseline = 'middle';
107
120
 
108
121
  for (const column of columns) {
109
- if (headerFilter && column.kind === 'inspectorPane') {
122
+ if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder')) {
110
123
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
111
124
  } else {
112
125
  ctx.fillStyle = colors.textMuted;
@@ -144,7 +157,7 @@ export class TreeRowRenderer {
144
157
  ctx.strokeStyle = colors.border;
145
158
  ctx.stroke();
146
159
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
147
- drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
160
+ drawTruncatedText(ctx, filterQuery || 'Filter rows', x + 8, viewport.headerHeight / 2, width - 16);
148
161
  }
149
162
 
150
163
  #drawSortIndicator(ctx, x, y, direction, theme) {
@@ -267,7 +280,8 @@ export class TreeRowRenderer {
267
280
  cell.column.render(ctx, cell);
268
281
  return;
269
282
  }
270
- if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
283
+ if (cell.column.kind === 'rowOrder') this.#drawRowOrder(ctx, cell);
284
+ else if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
271
285
  else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
272
286
  else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
273
287
  else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
@@ -277,6 +291,42 @@ export class TreeRowRenderer {
277
291
  else this.#drawTextCell(ctx, cell);
278
292
  }
279
293
 
294
+ #drawRowOrder(ctx, { node, row, rect, theme }) {
295
+ const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
296
+ const enabled = this.scene.canReorderRows && siblings.length > 1;
297
+ const cy = rect.y + row.height / 2;
298
+ ctx.save();
299
+ ctx.strokeStyle = theme.colors.textMuted;
300
+ ctx.fillStyle = theme.colors.textMuted;
301
+ ctx.lineWidth = 1.5;
302
+ ctx.globalAlpha = enabled ? 1 : 0.3;
303
+ for (const dx of [10, 14]) for (const dy of [-4, 0, 4]) ctx.fillRect(rect.x + dx, cy + dy, 1.5, 1.5);
304
+ for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
305
+ ctx.globalAlpha = enabled && available ? 1 : 0.3;
306
+ ctx.beginPath();
307
+ ctx.moveTo(rect.x + center - 4, cy - direction * 2);
308
+ ctx.lineTo(rect.x + center, cy + direction * 2);
309
+ ctx.lineTo(rect.x + center + 4, cy - direction * 2);
310
+ ctx.stroke();
311
+ }
312
+ ctx.restore();
313
+ }
314
+
315
+ #drawRowDrop(ctx) {
316
+ const { rowDrop, viewport, theme } = this.scene;
317
+ if (!rowDrop) return;
318
+ const y = viewport.headerHeight + rowDrop.y - viewport.scrollY;
319
+ if (y < viewport.headerHeight || y > viewport.headerHeight + viewport.rowViewportHeight) return;
320
+ ctx.save();
321
+ ctx.strokeStyle = theme.colors.focus;
322
+ ctx.lineWidth = 2;
323
+ ctx.beginPath();
324
+ ctx.moveTo(3, y);
325
+ ctx.lineTo(viewport.contentViewportWidth - 3, y);
326
+ ctx.stroke();
327
+ ctx.restore();
328
+ }
329
+
280
330
  #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
281
331
  const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
282
332
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
@@ -344,13 +394,22 @@ export class TreeRowRenderer {
344
394
  const readonly = data.readonly;
345
395
  const x = rect.x + 10;
346
396
  const y = rect.y + 5;
347
- const width = Math.max(24, rect.width - 20);
397
+ const fullWidth = Math.max(24, rect.width - 20);
398
+ const numeric = numericLayout(fullWidth, data);
399
+ const width = numeric.contentWidth;
348
400
  const height = rect.height - 10;
349
401
  ctx.font = theme.font;
350
402
  ctx.textBaseline = 'middle';
351
403
  ctx.textAlign = 'left';
352
404
  ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
353
405
 
406
+ if (numeric.unit && numeric.unitWidth) {
407
+ ctx.font = theme.unitFont ?? theme.monoFont ?? theme.font;
408
+ ctx.fillStyle = theme.colors.unit ?? theme.colors.textMuted;
409
+ drawTruncatedText(ctx, numeric.unit, x + numeric.unitLeft, rect.y + rect.height / 2, numeric.unitWidth);
410
+ ctx.font = theme.font;
411
+ }
412
+
354
413
  if (data.editorType === 'checkbox') {
355
414
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
356
415
  } else if (data.editorType === 'range') {
@@ -385,7 +444,15 @@ export class TreeRowRenderer {
385
444
  this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
386
445
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
387
446
  } else {
388
- this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
447
+ if (numeric.unit) {
448
+ ctx.font = theme.monoFont ?? theme.font;
449
+ ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
450
+ ctx.textAlign = 'right';
451
+ drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
452
+ ctx.textAlign = 'left';
453
+ } else {
454
+ this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
455
+ }
389
456
  }
390
457
 
391
458
  if (meta.updated && !suppressUpdatedMarker) {
@@ -432,9 +499,8 @@ export class TreeRowRenderer {
432
499
  const max = meta.max ?? 100;
433
500
  const value = typeof data.value === 'number' ? data.value : min;
434
501
  const ratio = max === min ? 0 : clamp01((value - min) / (max - min));
435
- const valueWidth = Math.min(64, Math.max(42, width * 0.28));
502
+ const { valueWidth, barWidth } = numericLayout(width);
436
503
  const gap = 8;
437
- const barWidth = Math.max(24, width - valueWidth - gap);
438
504
  this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
439
505
  this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
440
506
  hovered: Boolean(state.hoveredNumber),