virtual-tree-canvas 0.4.0 → 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.
package/README.md CHANGED
@@ -197,7 +197,7 @@ tree.setModel(
197
197
  {
198
198
  'sensor.range': { min: 0, max: 120, step: 1, integer: true },
199
199
  'sensor.mode': { options: { Search: 'search', Track: 'track' } },
200
- 'tracks.*.speed': { min: 0, max: 900, step: 5 },
200
+ 'tracks.*.speed': { min: 0, max: 900, step: 5, unit: 'm/s', precision: 2 },
201
201
  'tracks.*.id': { readonly: true },
202
202
  },
203
203
  { presentation: 'pane', flatRoot: true },
@@ -209,6 +209,15 @@ Choose `presentation: 'pane'` for a compact folder-and-controls view, or
209
209
  Editors are inferred from data and metadata: checkbox, range, number, text,
210
210
  select, colour, button, object and array.
211
211
 
212
+ Numeric metadata supports `unit` (a display-only string suffix) and `precision`
213
+ (decimal places, 0–20). The unit has a reserved slot beside the number in both
214
+ pane and table presentations, including range sliders. It stays visible while
215
+ editing; the editor receives the full original numeric value and emits a number.
216
+ Precision changes display only, not stored values, limits or step size. Omit both
217
+ options to keep the existing formatting. Invalid precision values are ignored.
218
+ Customize the suffix with `theme.unitFont` and `theme.colors.unit` (defaults to
219
+ `textMuted`). Long units are truncated to fit the available cell width.
220
+
212
221
  Listen for user edits and actions with `valuechange`, `modelchange` and
213
222
  `action` events.
214
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -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',
package/src/index.d.ts CHANGED
@@ -33,6 +33,10 @@ export type MetaRule = {
33
33
  max?: number;
34
34
  step?: number;
35
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;
36
40
  readonly?: boolean;
37
41
  disabled?: boolean;
38
42
  updated?: boolean;
@@ -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;
@@ -344,13 +345,22 @@ export class TreeRowRenderer {
344
345
  const readonly = data.readonly;
345
346
  const x = rect.x + 10;
346
347
  const y = rect.y + 5;
347
- 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;
348
351
  const height = rect.height - 10;
349
352
  ctx.font = theme.font;
350
353
  ctx.textBaseline = 'middle';
351
354
  ctx.textAlign = 'left';
352
355
  ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
353
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
+
354
364
  if (data.editorType === 'checkbox') {
355
365
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
356
366
  } else if (data.editorType === 'range') {
@@ -385,7 +395,15 @@ export class TreeRowRenderer {
385
395
  this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
386
396
  this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
387
397
  } else {
388
- 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
+ }
389
407
  }
390
408
 
391
409
  if (meta.updated && !suppressUpdatedMarker) {
@@ -432,9 +450,8 @@ export class TreeRowRenderer {
432
450
  const max = meta.max ?? 100;
433
451
  const value = typeof data.value === 'number' ? data.value : min;
434
452
  const ratio = max === min ? 0 : clamp01((value - min) / (max - min));
435
- const valueWidth = Math.min(64, Math.max(42, width * 0.28));
453
+ const { valueWidth, barWidth } = numericLayout(width);
436
454
  const gap = 8;
437
- const barWidth = Math.max(24, width - valueWidth - gap);
438
455
  this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
439
456
  this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
440
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';
@@ -789,11 +790,11 @@ export class TreeViewController {
789
790
  const node = this.model.nodes[row.nodeIndex];
790
791
  const data = node?.data;
791
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';
792
795
  if (data.editorType === 'checkbox') return 'checkbox';
793
796
  if (data.editorType === 'range') {
794
- const width = Math.max(24, editorWidth - 20);
795
- const valueWidth = Math.min(64, Math.max(42, width * 0.28));
796
- const numberLeft = editorWidth - valueWidth - 10;
797
+ const numberLeft = 10 + numeric.numberLeft;
797
798
  return localEditorX >= numberLeft ? 'number' : 'range';
798
799
  }
799
800
  if (data.editorType === 'button') return 'button';