virtual-tree-canvas 0.2.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -385,27 +385,38 @@ function drawSituation(ctx, x, y, size, color) {
385
385
  function drawDamage(ctx, x, y, size, color) {
386
386
  const cx = x + size * 0.5;
387
387
  const cy = y + size * 0.52;
388
- ctx.strokeStyle = color;
389
- ctx.fillStyle = color;
390
- ctx.lineWidth = 1.4;
388
+ const r = size * 0.42;
391
389
 
392
390
  ctx.beginPath();
393
391
  ctx.moveTo(cx, y + 1);
394
- ctx.lineTo(x + size * 0.9, y + size * 0.34);
395
- ctx.lineTo(x + size * 0.68, y + size * 0.42);
396
- ctx.lineTo(x + size - 1, y + size * 0.64);
397
- ctx.lineTo(x + size * 0.62, y + size * 0.6);
398
- ctx.lineTo(x + size * 0.72, y + size - 1);
399
- ctx.lineTo(cx, y + size * 0.72);
400
- ctx.lineTo(x + size * 0.28, y + size - 1);
401
- ctx.lineTo(x + size * 0.38, y + size * 0.6);
402
- ctx.lineTo(x + 1, y + size * 0.64);
403
- ctx.lineTo(x + size * 0.32, y + size * 0.42);
404
- ctx.lineTo(x + size * 0.1, y + size * 0.34);
392
+ ctx.lineTo(cx + r * 0.28, cy - r * 0.28);
393
+ ctx.lineTo(x + size - 1, y + size * 0.2);
394
+ ctx.lineTo(cx + r * 0.55, cy + r * 0.05);
395
+ ctx.lineTo(x + size * 0.92, y + size * 0.76);
396
+ ctx.lineTo(cx + r * 0.22, cy + r * 0.32);
397
+ ctx.lineTo(cx + r * 0.08, y + size - 1);
398
+ ctx.lineTo(cx - r * 0.18, cy + r * 0.34);
399
+ ctx.lineTo(x + size * 0.14, y + size * 0.86);
400
+ ctx.lineTo(cx - r * 0.42, cy + r * 0.12);
401
+ ctx.lineTo(x + 1, y + size * 0.38);
402
+ ctx.lineTo(cx - r * 0.3, cy - r * 0.18);
405
403
  ctx.closePath();
406
- ctx.stroke();
404
+ ctx.fillStyle = color;
405
+ ctx.fill();
407
406
 
407
+ ctx.fillStyle = '#fff7ed';
408
408
  ctx.beginPath();
409
- ctx.arc(cx, cy, size * 0.12, 0, Math.PI * 2);
409
+ ctx.arc(cx, cy, size * 0.13, 0, Math.PI * 2);
410
410
  ctx.fill();
411
+
412
+ ctx.strokeStyle = color;
413
+ ctx.lineWidth = 1.1;
414
+ ctx.beginPath();
415
+ ctx.moveTo(x + size * 0.08, y + size * 0.08);
416
+ ctx.lineTo(x + size * 0.2, y + size * 0.2);
417
+ ctx.moveTo(x + size * 0.84, y + size * 0.88);
418
+ ctx.lineTo(x + size * 0.94, y + size * 0.98);
419
+ ctx.moveTo(x + size * 0.9, y + size * 0.06);
420
+ ctx.lineTo(x + size * 0.82, y + size * 0.18);
421
+ ctx.stroke();
411
422
  }
@@ -28,19 +28,21 @@ export class Canvas2DRenderer {
28
28
  if (!this.canvas || !this.ctx || !this.scene) return;
29
29
  const { viewport } = frameState;
30
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);
31
+ const viewportWidth = Math.max(0, viewport.viewportWidth);
32
+ const viewportHeight = Math.max(0, viewport.viewportHeight);
33
+ if (viewportWidth <= 0 || viewportHeight <= 0) return;
34
+ const width = Math.floor(viewportWidth * dpr);
35
+ const height = Math.floor(viewportHeight * dpr);
33
36
  if (this.canvas.width !== width || this.canvas.height !== height) {
34
37
  this.canvas.width = width;
35
38
  this.canvas.height = height;
36
- viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
37
39
  }
38
40
 
39
41
  const ctx = this.ctx;
40
42
  const theme = this.themeManager?.get() ?? {};
41
43
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
42
44
  ctx.fillStyle = theme.background ?? '#101419';
43
- ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
45
+ ctx.fillRect(0, 0, viewportWidth, viewportHeight);
44
46
 
45
47
  this.visibleNodeIndices = cullLayoutNodes(this.scene.layout.nodes, viewport.getWorldBounds());
46
48
  const visibleSet = new Set(this.visibleNodeIndices);
@@ -1,5 +1,9 @@
1
1
  import { IconRegistry } from '../core/icon-registry.js';
2
2
 
3
+ const DISABLED_ALPHA = 0.72;
4
+ const SCROLL_INDICATOR_HOLD_MS = 1600;
5
+ const SCROLL_INDICATOR_FADE_MS = 700;
6
+
3
7
  export class TreeRowRenderer {
4
8
  constructor({ iconRegistry } = {}) {
5
9
  this.canvas = null;
@@ -7,6 +11,8 @@ export class TreeRowRenderer {
7
11
  this.scene = null;
8
12
  this.iconRegistry = iconRegistry ?? new IconRegistry();
9
13
  this.renderedRows = 0;
14
+ this.scrollIndicatorState = null;
15
+ this.scrollIndicatorVisibleUntil = 0;
10
16
  }
11
17
 
12
18
  /** @param {HTMLCanvasElement} canvas */
@@ -24,30 +30,32 @@ export class TreeRowRenderer {
24
30
  // Canvas2D reads the state map directly; patches still stay on the hot path.
25
31
  }
26
32
 
27
- render(scene) {
33
+ render(scene, time = performance.now()) {
28
34
  if (scene?.rows) this.scene = scene;
29
35
  if (!this.canvas || !this.ctx || !this.scene) return;
30
36
  const { viewport, theme } = this.scene;
31
37
  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);
38
+ const viewportWidth = Math.max(0, viewport.viewportWidth);
39
+ const viewportHeight = Math.max(0, viewport.viewportHeight);
40
+ if (viewportWidth <= 0 || viewportHeight <= 0) return;
41
+ const width = Math.floor(viewportWidth * dpr);
42
+ const height = Math.floor(viewportHeight * dpr);
34
43
  if (this.canvas.width !== width || this.canvas.height !== height) {
35
44
  this.canvas.width = width;
36
45
  this.canvas.height = height;
37
- viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
38
46
  }
39
47
 
40
48
  const ctx = this.ctx;
41
49
  const colors = theme.colors;
42
50
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
43
51
  ctx.fillStyle = colors.background;
44
- ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
52
+ ctx.fillRect(0, 0, viewportWidth, viewportHeight);
45
53
 
46
54
  ctx.save();
47
55
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
48
56
  this.#drawHeader(ctx);
49
57
  this.#drawRows(ctx);
50
- this.#drawScrollIndicator(ctx);
58
+ this.#drawScrollIndicator(ctx, time);
51
59
  ctx.restore();
52
60
  }
53
61
 
@@ -119,10 +127,12 @@ export class TreeRowRenderer {
119
127
  ctx.restore();
120
128
  }
121
129
 
122
- #drawScrollIndicator(ctx) {
123
- const { viewport, theme } = this.scene;
130
+ #drawScrollIndicator(ctx, time) {
131
+ const { viewport, theme, rows } = this.scene;
124
132
  const maxY = Math.max(0, viewport.contentHeight - viewport.rowViewportHeight);
125
133
  if (maxY <= 0 || viewport.viewportHeight <= viewport.headerHeight + 12) return;
134
+ const alpha = this.#scrollIndicatorAlpha(viewport, rows?.length ?? 0, time);
135
+ if (alpha <= 0) return;
126
136
 
127
137
  const trackTop = viewport.headerHeight + 4;
128
138
  const trackHeight = Math.max(1, viewport.viewportHeight - viewport.headerHeight - 8);
@@ -133,14 +143,14 @@ export class TreeRowRenderer {
133
143
  ctx.save();
134
144
  ctx.lineCap = 'round';
135
145
  ctx.lineWidth = 2;
136
- ctx.globalAlpha = 0.28;
146
+ ctx.globalAlpha = 0.28 * alpha;
137
147
  ctx.strokeStyle = theme.colors.guide;
138
148
  ctx.beginPath();
139
149
  ctx.moveTo(x, trackTop);
140
150
  ctx.lineTo(x, trackTop + trackHeight);
141
151
  ctx.stroke();
142
152
 
143
- ctx.globalAlpha = 0.9;
153
+ ctx.globalAlpha = 0.9 * alpha;
144
154
  ctx.strokeStyle = theme.colors.focus;
145
155
  ctx.beginPath();
146
156
  ctx.moveTo(x, thumbY);
@@ -149,6 +159,24 @@ export class TreeRowRenderer {
149
159
  ctx.restore();
150
160
  }
151
161
 
162
+ #scrollIndicatorAlpha(viewport, rowCount, time) {
163
+ const nextState = {
164
+ scrollY: viewport.scrollY,
165
+ contentHeight: viewport.contentHeight,
166
+ rowViewportHeight: viewport.rowViewportHeight,
167
+ viewportHeight: viewport.viewportHeight,
168
+ rowCount,
169
+ };
170
+ if (!sameScrollIndicatorState(this.scrollIndicatorState, nextState)) {
171
+ this.scrollIndicatorState = nextState;
172
+ this.scrollIndicatorVisibleUntil = time + SCROLL_INDICATOR_HOLD_MS;
173
+ return 1;
174
+ }
175
+ if (time <= this.scrollIndicatorVisibleUntil) return 1;
176
+ const fade = 1 - ((time - this.scrollIndicatorVisibleUntil) / SCROLL_INDICATOR_FADE_MS);
177
+ return clamp01(fade);
178
+ }
179
+
152
180
  #drawRow(ctx, row) {
153
181
  const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
154
182
  const node = nodes[row.nodeIndex];
@@ -226,8 +254,16 @@ export class TreeRowRenderer {
226
254
  ctx.font = theme.font;
227
255
  ctx.textBaseline = 'middle';
228
256
  ctx.textAlign = 'left';
229
- ctx.globalAlpha = data.disabled ? 0.45 : 1;
257
+ ctx.globalAlpha = data.disabled ? DISABLED_ALPHA : 1;
230
258
  ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
259
+
260
+ if (data.valueType === 'object') {
261
+ drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + rect.width - labelX - 8));
262
+ if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
263
+ ctx.globalAlpha = 1;
264
+ return;
265
+ }
266
+
231
267
  const layout = inspectorPaneLayout(rect.width, row.depth, theme.indentWidth, data.editorType, this.scene.inspectorPaneLabelEnd);
232
268
  drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + layout.editorLeft - labelX - 8));
233
269
 
@@ -238,8 +274,6 @@ export class TreeRowRenderer {
238
274
  drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
239
275
  this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
240
276
  this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
241
- } else if (data.valueType === 'object') {
242
- // Pane mode uses object rows as folders; the label is enough.
243
277
  } else {
244
278
  this.#drawInspectorValueCell(ctx, {
245
279
  node,
@@ -264,7 +298,7 @@ export class TreeRowRenderer {
264
298
  ctx.font = theme.font;
265
299
  ctx.textBaseline = 'middle';
266
300
  ctx.textAlign = 'left';
267
- ctx.globalAlpha = disabled ? 0.45 : 1;
301
+ ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
268
302
 
269
303
  if (data.editorType === 'checkbox') {
270
304
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
@@ -475,6 +509,15 @@ function resolveNodeStyle(theme, node, state = {}) {
475
509
  };
476
510
  }
477
511
 
512
+ function sameScrollIndicatorState(a, b) {
513
+ return Boolean(a) &&
514
+ a.scrollY === b.scrollY &&
515
+ a.contentHeight === b.contentHeight &&
516
+ a.rowViewportHeight === b.rowViewportHeight &&
517
+ a.viewportHeight === b.viewportHeight &&
518
+ a.rowCount === b.rowCount;
519
+ }
520
+
478
521
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
479
522
  const safeWidth = Math.max(1, width);
480
523
  const rightPadding = 14;
@@ -63,6 +63,7 @@ export class TreeViewController {
63
63
  this.canvas = canvas;
64
64
  this.renderer.initialize(canvas);
65
65
  this.renderer.setScene(this.scene);
66
+ this.#observeCanvasSize();
66
67
  return this;
67
68
  }
68
69
 
@@ -76,6 +77,9 @@ export class TreeViewController {
76
77
 
77
78
  setData(nodes) {
78
79
  this.inspector = null;
80
+ if (this.columnModel.columns.length === 1 && this.columnModel.columns[0]?.kind === 'inspectorPane') {
81
+ this.columnModel.setColumns([]);
82
+ }
79
83
  this.model.setTree(nodes);
80
84
  this.expansion.expandToDepth(this.initialExpandDepth);
81
85
  this.searchIndex.rebuild(this.model);
@@ -561,7 +565,6 @@ export class TreeViewController {
561
565
  }
562
566
 
563
567
  renderMeasured(time = performance.now()) {
564
- this.#syncViewportFromCanvas();
565
568
  const sceneStart = performance.now();
566
569
  this.scene = this.createRenderScene();
567
570
  const sceneMs = performance.now() - sceneStart;
@@ -626,6 +629,11 @@ export class TreeViewController {
626
629
  if (column.kind === 'inspectorPane') {
627
630
  const localX = x - column.x;
628
631
  const node = this.model.nodes[row.nodeIndex];
632
+ if (node?.data?.valueType === 'object') {
633
+ const treeX = row.depth * this.rowModel.indentWidth;
634
+ part = localX >= treeX + 4 && localX <= treeX + 22 ? 'chevron' : 'label';
635
+ return { area: 'row', part, row, column, x, y: rowY };
636
+ }
629
637
  if (node?.data?.valueType === 'array') {
630
638
  if (localX >= column.width - 54 && localX <= column.width - 32) part = 'arrayAdd';
631
639
  else if (localX >= column.width - 28 && localX <= column.width - 6) part = 'arrayRemove';
@@ -718,7 +726,7 @@ export class TreeViewController {
718
726
  if (hit.part === 'label' || hit.part === 'chevron') {
719
727
  const labelX = hit.row.depth * this.rowModel.indentWidth + (hit.row.hasChildren ? 28 : 24);
720
728
  text = node.label ?? node.id;
721
- width = Math.max(0, layout.editorLeft - labelX - 8);
729
+ width = data.valueType === 'object' ? Math.max(0, visibleWidth - labelX - 8) : Math.max(0, layout.editorLeft - labelX - 8);
722
730
  } else if (hit.part === 'arrayAdd' || hit.part === 'arrayRemove') {
723
731
  return null;
724
732
  } else {
@@ -795,6 +803,7 @@ export class TreeViewController {
795
803
  }
796
804
 
797
805
  #syncContentSize() {
806
+ if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.viewportWidth);
798
807
  this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
799
808
  }
800
809
 
@@ -821,15 +830,16 @@ export class TreeViewController {
821
830
  return Math.max(0, Math.floor(this.viewport.scrollY / this.rowModel.rowHeight));
822
831
  }
823
832
 
824
- #syncViewportFromCanvas() {
825
- if (!this.canvas) return;
826
- const width = this.canvas.clientWidth;
827
- const height = this.canvas.clientHeight;
828
- if (width > 0 && height > 0 && (this.viewport.viewportWidth !== width || this.viewport.viewportHeight !== height)) {
829
- this.viewport.resize(width, height);
830
- this.#fitInspectorPaneColumn(width);
831
- this.#syncContentSize();
832
- }
833
+ #observeCanvasSize() {
834
+ if (!this.canvas || typeof ResizeObserver === 'undefined') return;
835
+ this.#resizeObserver?.disconnect();
836
+ this.#resizeObserver = new ResizeObserver((entries) => {
837
+ const entry = entries[0];
838
+ const width = Math.floor(entry?.contentRect?.width ?? 0);
839
+ const height = Math.floor(entry?.contentRect?.height ?? 0);
840
+ if (width > 0 && height > 0) this.resize(width, height);
841
+ });
842
+ this.#resizeObserver.observe(this.canvas);
833
843
  }
834
844
 
835
845
  #computeInspectorPaneLabelEnd(visibleRange) {
@@ -841,6 +851,7 @@ export class TreeViewController {
841
851
  if (!row) continue;
842
852
  const node = this.model.nodes[row.nodeIndex];
843
853
  if (!node) continue;
854
+ if (node.data?.valueType === 'object') continue;
844
855
  const indentX = row.depth * this.rowModel.indentWidth;
845
856
  const labelX = indentX + (row.hasChildren ? 28 : 24);
846
857
  const labelWidth = String(node.label ?? node.id ?? '').length * 6.4;
@@ -891,6 +902,8 @@ export class TreeViewController {
891
902
  this.#rebuildRows();
892
903
  if (focusId) this.focusedId = focusId;
893
904
  }
905
+
906
+ #resizeObserver = null;
894
907
  }
895
908
 
896
909
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {