virtual-tree-canvas 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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
  }
@@ -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,7 +30,7 @@ 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;
@@ -47,7 +53,7 @@ export class TreeRowRenderer {
47
53
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
48
54
  this.#drawHeader(ctx);
49
55
  this.#drawRows(ctx);
50
- this.#drawScrollIndicator(ctx);
56
+ this.#drawScrollIndicator(ctx, time);
51
57
  ctx.restore();
52
58
  }
53
59
 
@@ -119,10 +125,12 @@ export class TreeRowRenderer {
119
125
  ctx.restore();
120
126
  }
121
127
 
122
- #drawScrollIndicator(ctx) {
123
- const { viewport, theme } = this.scene;
128
+ #drawScrollIndicator(ctx, time) {
129
+ const { viewport, theme, rows } = this.scene;
124
130
  const maxY = Math.max(0, viewport.contentHeight - viewport.rowViewportHeight);
125
131
  if (maxY <= 0 || viewport.viewportHeight <= viewport.headerHeight + 12) return;
132
+ const alpha = this.#scrollIndicatorAlpha(viewport, rows?.length ?? 0, time);
133
+ if (alpha <= 0) return;
126
134
 
127
135
  const trackTop = viewport.headerHeight + 4;
128
136
  const trackHeight = Math.max(1, viewport.viewportHeight - viewport.headerHeight - 8);
@@ -133,14 +141,14 @@ export class TreeRowRenderer {
133
141
  ctx.save();
134
142
  ctx.lineCap = 'round';
135
143
  ctx.lineWidth = 2;
136
- ctx.globalAlpha = 0.28;
144
+ ctx.globalAlpha = 0.28 * alpha;
137
145
  ctx.strokeStyle = theme.colors.guide;
138
146
  ctx.beginPath();
139
147
  ctx.moveTo(x, trackTop);
140
148
  ctx.lineTo(x, trackTop + trackHeight);
141
149
  ctx.stroke();
142
150
 
143
- ctx.globalAlpha = 0.9;
151
+ ctx.globalAlpha = 0.9 * alpha;
144
152
  ctx.strokeStyle = theme.colors.focus;
145
153
  ctx.beginPath();
146
154
  ctx.moveTo(x, thumbY);
@@ -149,6 +157,24 @@ export class TreeRowRenderer {
149
157
  ctx.restore();
150
158
  }
151
159
 
160
+ #scrollIndicatorAlpha(viewport, rowCount, time) {
161
+ const nextState = {
162
+ scrollY: viewport.scrollY,
163
+ contentHeight: viewport.contentHeight,
164
+ rowViewportHeight: viewport.rowViewportHeight,
165
+ viewportHeight: viewport.viewportHeight,
166
+ rowCount,
167
+ };
168
+ if (!sameScrollIndicatorState(this.scrollIndicatorState, nextState)) {
169
+ this.scrollIndicatorState = nextState;
170
+ this.scrollIndicatorVisibleUntil = time + SCROLL_INDICATOR_HOLD_MS;
171
+ return 1;
172
+ }
173
+ if (time <= this.scrollIndicatorVisibleUntil) return 1;
174
+ const fade = 1 - ((time - this.scrollIndicatorVisibleUntil) / SCROLL_INDICATOR_FADE_MS);
175
+ return clamp01(fade);
176
+ }
177
+
152
178
  #drawRow(ctx, row) {
153
179
  const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
154
180
  const node = nodes[row.nodeIndex];
@@ -226,8 +252,16 @@ export class TreeRowRenderer {
226
252
  ctx.font = theme.font;
227
253
  ctx.textBaseline = 'middle';
228
254
  ctx.textAlign = 'left';
229
- ctx.globalAlpha = data.disabled ? 0.45 : 1;
255
+ ctx.globalAlpha = data.disabled ? DISABLED_ALPHA : 1;
230
256
  ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
257
+
258
+ if (data.valueType === 'object') {
259
+ drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + rect.width - labelX - 8));
260
+ if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
261
+ ctx.globalAlpha = 1;
262
+ return;
263
+ }
264
+
231
265
  const layout = inspectorPaneLayout(rect.width, row.depth, theme.indentWidth, data.editorType, this.scene.inspectorPaneLabelEnd);
232
266
  drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + layout.editorLeft - labelX - 8));
233
267
 
@@ -238,8 +272,6 @@ export class TreeRowRenderer {
238
272
  drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
239
273
  this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
240
274
  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
275
  } else {
244
276
  this.#drawInspectorValueCell(ctx, {
245
277
  node,
@@ -264,7 +296,7 @@ export class TreeRowRenderer {
264
296
  ctx.font = theme.font;
265
297
  ctx.textBaseline = 'middle';
266
298
  ctx.textAlign = 'left';
267
- ctx.globalAlpha = disabled ? 0.45 : 1;
299
+ ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
268
300
 
269
301
  if (data.editorType === 'checkbox') {
270
302
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
@@ -475,6 +507,15 @@ function resolveNodeStyle(theme, node, state = {}) {
475
507
  };
476
508
  }
477
509
 
510
+ function sameScrollIndicatorState(a, b) {
511
+ return Boolean(a) &&
512
+ a.scrollY === b.scrollY &&
513
+ a.contentHeight === b.contentHeight &&
514
+ a.rowViewportHeight === b.rowViewportHeight &&
515
+ a.viewportHeight === b.viewportHeight &&
516
+ a.rowCount === b.rowCount;
517
+ }
518
+
478
519
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
479
520
  const safeWidth = Math.max(1, width);
480
521
  const rightPadding = 14;
@@ -76,6 +76,9 @@ export class TreeViewController {
76
76
 
77
77
  setData(nodes) {
78
78
  this.inspector = null;
79
+ if (this.columnModel.columns.length === 1 && this.columnModel.columns[0]?.kind === 'inspectorPane') {
80
+ this.columnModel.setColumns([]);
81
+ }
79
82
  this.model.setTree(nodes);
80
83
  this.expansion.expandToDepth(this.initialExpandDepth);
81
84
  this.searchIndex.rebuild(this.model);
@@ -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
 
@@ -841,6 +850,7 @@ export class TreeViewController {
841
850
  if (!row) continue;
842
851
  const node = this.model.nodes[row.nodeIndex];
843
852
  if (!node) continue;
853
+ if (node.data?.valueType === 'object') continue;
844
854
  const indentX = row.depth * this.rowModel.indentWidth;
845
855
  const labelX = indentX + (row.hasChildren ? 28 : 24);
846
856
  const labelWidth = String(node.label ?? node.id ?? '').length * 6.4;