@data-navigator/inspector 1.1.0 → 1.2.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": "@data-navigator/inspector",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "author": "Frank Elavsky",
6
6
  "license": "MIT",
@@ -14,11 +14,7 @@
14
14
 
15
15
  import { createMenuState } from './menu-state.js';
16
16
  import { connectStateToSvg } from './svg-highlight.js';
17
- import {
18
- buildConsoleSection,
19
- buildRenderedElementsSection,
20
- buildSourceInputSection
21
- } from './menu-sections.js';
17
+ import { buildConsoleSection, buildRenderedElementsSection, buildSourceInputSection } from './menu-sections.js';
22
18
 
23
19
  /**
24
20
  * Create and mount the console menu.
@@ -59,9 +55,7 @@ export function createConsoleMenu({
59
55
  outerDetails.appendChild(consoleEl);
60
56
 
61
57
  // 2. Rendered Elements (collapsed — Nodes grouped by dim/div, Edges grouped by nav rule)
62
- const renderedEl = buildRenderedElementsSection(
63
- structure, state, container, consoleListEl, buildLabelFn
64
- );
58
+ const renderedEl = buildRenderedElementsSection(structure, state, container, consoleListEl, buildLabelFn);
65
59
  outerDetails.appendChild(renderedEl);
66
60
 
67
61
  // 3. Source Input (collapsed — Data, Props, Dimensions, Divisions)
@@ -1,7 +1,7 @@
1
1
  // Copyright 2021-2024 Observable, Inc.
2
2
  // Released under the ISC license.
3
3
  // https://observablehq.com/@d3/force-directed-graph
4
- // Adapted for data-navigator-inspector with modular D3 imports.
4
+ // Adapted for @data-navigator/inspector with modular D3 imports.
5
5
 
6
6
  import { map, sort } from 'd3-array';
7
7
  import { drag } from 'd3-drag';
package/src/inspector.js CHANGED
@@ -71,6 +71,9 @@ const showTooltip = (node, tooltipEl, graphWidth, graphHeight, colorBy) => {
71
71
  const bbox = tooltipEl.getBoundingClientRect();
72
72
  const yOffset = bbox.height / 2;
73
73
  tooltipEl.style.textAlign = 'left';
74
+ tooltipEl.style.position = 'absolute';
75
+ tooltipEl.style.left = 0;
76
+ tooltipEl.style.top = 0;
74
77
  tooltipEl.style.transform = `translate(${graphWidth}px,${graphHeight / 2 - yOffset}px)`;
75
78
  };
76
79
 
@@ -150,8 +153,9 @@ export function Inspector({
150
153
  if (!rootEl.id) rootEl.id = rootId;
151
154
 
152
155
  // Build the graph SVG
153
- const graphWidth = mode === 'tree' ? size * 2 : size;
154
- const graphHeight = mode === 'tree' ? Math.round(size * 1.5) : size;
156
+ const sizeIsObject = size !== null && typeof size === 'object';
157
+ const graphWidth = sizeIsObject ? size.width : mode === 'tree' ? size * 2 : size;
158
+ const graphHeight = sizeIsObject ? size.height : mode === 'tree' ? Math.round(size * 1.5) : size;
155
159
  const nodeArray = convertToArray(structure.nodes, nodeInclusions);
156
160
  const linkArray = convertToArray(structure.edges, [], edgeExclusions);
157
161
  const idPrefix = rootId + '-';
@@ -165,15 +169,16 @@ export function Inspector({
165
169
  idPrefix
166
170
  };
167
171
 
168
- const graph = mode === 'tree'
169
- ? TreeGraph(
170
- { nodes: nodeArray, links: linkArray },
171
- { ...graphOptions, dimensions: structure.dimensions }
172
- )
173
- : ForceGraph(
174
- { nodes: nodeArray, links: linkArray },
175
- graphOptions
176
- );
172
+ const graph =
173
+ mode === 'tree'
174
+ ? TreeGraph({ nodes: nodeArray, links: linkArray }, { ...graphOptions, dimensions: structure.dimensions })
175
+ : ForceGraph({ nodes: nodeArray, links: linkArray }, graphOptions);
176
+
177
+ // When explicit pixel dimensions are given, remove the auto-scaling inline style so the
178
+ // SVG renders at exactly the specified size rather than scaling to container width.
179
+ if (sizeIsObject) {
180
+ graph.style.cssText = 'display: block;';
181
+ }
177
182
 
178
183
  // Create wrapper structure
179
184
  const wrapperEl = document.createElement('div');
@@ -247,7 +252,7 @@ export function Inspector({
247
252
  showConsoleMenu,
248
253
  indicatorEl,
249
254
  edgeSvgIdMap,
250
- buildLabelFn: (node) => buildLabel(node, colorBy)
255
+ buildLabelFn: node => buildLabel(node, colorBy)
251
256
  });
252
257
  }
253
258
 
@@ -19,8 +19,10 @@ export const EVENTS = {
19
19
  * @param {Object} detail - Event detail payload
20
20
  */
21
21
  export function dispatch(container, eventName, detail) {
22
- container.dispatchEvent(new CustomEvent(eventName, {
23
- bubbles: true,
24
- detail
25
- }));
22
+ container.dispatchEvent(
23
+ new CustomEvent(eventName, {
24
+ bubbles: true,
25
+ detail
26
+ })
27
+ );
26
28
  }
@@ -1,10 +1,44 @@
1
1
  /**
2
2
  * Builders for each section of the console menu.
3
3
  * Each function returns an HTMLElement to be appended to the menu.
4
+ *
5
+ * Rendered Elements uses lazy rendering: child menu items are only created
6
+ * when their parent <details> is opened, and destroyed when it closes.
7
+ * Checked state persists in menu-state.js (independent of DOM).
4
8
  */
5
9
 
6
10
  import { EVENTS, dispatch } from './menu-events.js';
7
11
 
12
+ // ---------------------------------------------------------------------------
13
+ // Utility: lazy cleanup
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Recursively call _unsub on an element and all its descendants.
18
+ */
19
+ function cleanupElement(el) {
20
+ if (el._unsub) {
21
+ el._unsub();
22
+ delete el._unsub;
23
+ }
24
+ if (el.children) {
25
+ Array.from(el.children).forEach(cleanupElement);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Remove all children of a <details> except its <summary>,
31
+ * recursively calling _unsub on each removed subtree.
32
+ */
33
+ function cleanupChildren(detailsEl) {
34
+ const children = Array.from(detailsEl.children);
35
+ children.forEach(child => {
36
+ if (child.tagName === 'SUMMARY') return;
37
+ cleanupElement(child);
38
+ detailsEl.removeChild(child);
39
+ });
40
+ }
41
+
8
42
  // ---------------------------------------------------------------------------
9
43
  // Utility: collapsible section with caret indicator
10
44
  // ---------------------------------------------------------------------------
@@ -25,21 +59,56 @@ function makeDetails(summaryText, opts = {}) {
25
59
  return details;
26
60
  }
27
61
 
62
+ /**
63
+ * Create a <details> that lazily populates children on open and
64
+ * destroys them on close. For sections without a group checkbox.
65
+ *
66
+ * @param {string} summaryText
67
+ * @param {Function} populateFn - Called with (detailsEl) when opened
68
+ * @param {Object} state - Menu state (for hover cleanup on close)
69
+ * @param {Object} [opts] - className, open, summaryClass
70
+ */
71
+ function makeLazyDetails(summaryText, populateFn, state, opts = {}) {
72
+ const details = document.createElement('details');
73
+ if (opts.className) details.className = opts.className;
74
+
75
+ const summary = document.createElement('summary');
76
+ summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
77
+ summary.textContent = summaryText;
78
+ details.appendChild(summary);
79
+
80
+ details.addEventListener('toggle', () => {
81
+ if (details.open) {
82
+ populateFn(details);
83
+ } else {
84
+ if (state.hoveredItem) state.clearHover();
85
+ cleanupChildren(details);
86
+ }
87
+ });
88
+
89
+ if (opts.open) {
90
+ details.open = true;
91
+ populateFn(details);
92
+ }
93
+
94
+ return details;
95
+ }
96
+
28
97
  /**
29
98
  * Create a <details> with a group checkbox in the <summary>.
30
- * The checkbox checks/unchecks all child items and syncs bidirectionally.
99
+ * Lazily renders children on open, destroys on close.
31
100
  *
32
101
  * @param {string} summaryText - Label text for the summary
33
102
  * @param {Array<{type: string, id: string}>} childItems - Items this group controls
34
103
  * @param {Object} state - The menu state object
35
104
  * @param {HTMLElement} container - Container for event dispatching
105
+ * @param {Function} populateFn - Called with (detailsEl) when opened
36
106
  * @param {Object} [opts] - Options passed to details (className, open, summaryClass)
37
107
  * @returns {HTMLDetailsElement}
38
108
  */
39
- function makeGroupDetails(summaryText, childItems, state, container, opts = {}) {
109
+ function makeGroupDetails(summaryText, childItems, state, container, populateFn, opts = {}) {
40
110
  const details = document.createElement('details');
41
111
  if (opts.className) details.className = opts.className;
42
- if (opts.open) details.open = true;
43
112
 
44
113
  const summary = document.createElement('summary');
45
114
  summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
@@ -47,7 +116,7 @@ function makeGroupDetails(summaryText, childItems, state, container, opts = {})
47
116
  const checkbox = document.createElement('input');
48
117
  checkbox.type = 'checkbox';
49
118
  checkbox.className = 'dn-menu-group-checkbox';
50
- checkbox.addEventListener('click', (e) => {
119
+ checkbox.addEventListener('click', e => {
51
120
  e.stopPropagation(); // Don't toggle <details> when clicking checkbox
52
121
  });
53
122
  checkbox.addEventListener('change', () => {
@@ -70,20 +139,36 @@ function makeGroupDetails(summaryText, childItems, state, container, opts = {})
70
139
 
71
140
  details.appendChild(summary);
72
141
 
73
- // Sync checkbox state when child items change
142
+ // Sync checkbox state — works without child DOM (reads from state)
74
143
  function syncGroupCheckbox() {
75
144
  const checkedCount = childItems.filter(item => state.isChecked(item.type, item.id)).length;
76
145
  checkbox.checked = checkedCount === childItems.length && childItems.length > 0;
77
146
  checkbox.indeterminate = checkedCount > 0 && checkedCount < childItems.length;
78
147
  }
148
+ syncGroupCheckbox();
79
149
 
80
- const unsub = state.subscribe((changeType) => {
150
+ const unsub = state.subscribe(changeType => {
81
151
  if (changeType === 'check' || changeType === 'uncheck') {
82
152
  syncGroupCheckbox();
83
153
  }
84
154
  });
85
155
  details._unsub = unsub;
86
156
 
157
+ // Lazy toggle handler
158
+ details.addEventListener('toggle', () => {
159
+ if (details.open) {
160
+ populateFn(details);
161
+ } else {
162
+ if (state.hoveredItem) state.clearHover();
163
+ cleanupChildren(details);
164
+ }
165
+ });
166
+
167
+ if (opts.open) {
168
+ details.open = true;
169
+ populateFn(details);
170
+ }
171
+
87
172
  return details;
88
173
  }
89
174
 
@@ -125,7 +210,8 @@ function buildInlineArray({ label, items, state, container, structure }) {
125
210
  chip.addEventListener('mouseenter', () => {
126
211
  state.setHover(item.type, item.id);
127
212
  dispatch(container, EVENTS.ITEM_HOVER, {
128
- type: item.type, id: item.id,
213
+ type: item.type,
214
+ id: item.id,
129
215
  sourceData: getSourceData(item.type, item.id, structure)
130
216
  });
131
217
  });
@@ -193,7 +279,7 @@ export function buildMenuItem({ type, id, label, state, container, showLog, logF
193
279
  const logBtn = document.createElement('button');
194
280
  logBtn.className = 'dn-menu-log-btn';
195
281
  logBtn.textContent = 'log';
196
- logBtn.addEventListener('click', (e) => {
282
+ logBtn.addEventListener('click', e => {
197
283
  e.stopPropagation();
198
284
  const result = logFn();
199
285
  if (result && consoleListEl) {
@@ -217,7 +303,9 @@ export function buildMenuItem({ type, id, label, state, container, showLog, logF
217
303
  row.addEventListener('mouseenter', () => {
218
304
  state.setHover(type, id);
219
305
  dispatch(container, EVENTS.ITEM_HOVER, {
220
- type, id, sourceData: getSourceData(type, id, structure)
306
+ type,
307
+ id,
308
+ sourceData: getSourceData(type, id, structure)
221
309
  });
222
310
  });
223
311
  row.addEventListener('mouseleave', () => {
@@ -226,8 +314,7 @@ export function buildMenuItem({ type, id, label, state, container, showLog, logF
226
314
  });
227
315
 
228
316
  const unsub = state.subscribe((changeType, payload) => {
229
- if ((changeType === 'check' || changeType === 'uncheck') &&
230
- payload.type === type && payload.id === id) {
317
+ if ((changeType === 'check' || changeType === 'uncheck') && payload.type === type && payload.id === id) {
231
318
  checkbox.checked = state.isChecked(type, id);
232
319
  }
233
320
  });
@@ -255,7 +342,10 @@ function appendConsoleLogGroup(result, state, container, consoleListEl, structur
255
342
  type: mainEntry.type,
256
343
  id: mainEntry.id,
257
344
  label: mainEntry.label,
258
- state, container, showLog: false, structure
345
+ state,
346
+ container,
347
+ showLog: false,
348
+ structure
259
349
  });
260
350
  mainRow.classList.add('dn-menu-console-entry');
261
351
  consoleListEl.appendChild(mainRow);
@@ -267,7 +357,9 @@ function appendConsoleLogGroup(result, state, container, consoleListEl, structur
267
357
  const inlineEl = buildInlineArray({
268
358
  label: arr.label,
269
359
  items: arr.items,
270
- state, container, structure
360
+ state,
361
+ container,
362
+ structure
271
363
  });
272
364
  inlineEl.classList.add('dn-menu-console-entry');
273
365
  inlineEl.style.paddingLeft = '24px';
@@ -298,14 +390,21 @@ function buildNodeLogResult(nodeId, structure) {
298
390
 
299
391
  return {
300
392
  mainEntry: {
301
- type: 'node', id: nodeId,
393
+ type: 'node',
394
+ id: nodeId,
302
395
  label: 'node: ' + nodeId,
303
- data: node, timestamp: Date.now()
396
+ data: node,
397
+ timestamp: Date.now()
304
398
  },
305
- relatedArrays: edgeItems.length > 0 ? [{
306
- label: 'edges',
307
- items: edgeItems
308
- }] : []
399
+ relatedArrays:
400
+ edgeItems.length > 0
401
+ ? [
402
+ {
403
+ label: 'edges',
404
+ items: edgeItems
405
+ }
406
+ ]
407
+ : []
309
408
  };
310
409
  }
311
410
 
@@ -326,9 +425,11 @@ function buildEdgeLogResult(edgeKey, structure) {
326
425
 
327
426
  return {
328
427
  mainEntry: {
329
- type: 'edge', id: edgeKey,
428
+ type: 'edge',
429
+ id: edgeKey,
330
430
  label: 'edge: ' + (src || '?') + ' \u2192 ' + (tgt || '?'),
331
- data: edge, timestamp: Date.now()
431
+ data: edge,
432
+ timestamp: Date.now()
332
433
  },
333
434
  relatedArrays
334
435
  };
@@ -365,9 +466,11 @@ function buildNavRuleLogResult(ruleId, structure) {
365
466
 
366
467
  return {
367
468
  mainEntry: {
368
- type: 'rule', id: ruleId,
469
+ type: 'rule',
470
+ id: ruleId,
369
471
  label: 'rule: ' + ruleId,
370
- data: rule || ruleId, timestamp: Date.now()
472
+ data: rule || ruleId,
473
+ timestamp: Date.now()
371
474
  },
372
475
  relatedArrays
373
476
  };
@@ -384,10 +487,15 @@ function buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl)
384
487
  const rules = (edge.navigationRules || []).join(',');
385
488
  const label = src + ' \u2192 ' + tgt + (rules ? ' [' + rules + ']' : '');
386
489
  return buildMenuItem({
387
- type: 'edge', id: edgeKey, label,
388
- state, container, showLog: true,
490
+ type: 'edge',
491
+ id: edgeKey,
492
+ label,
493
+ state,
494
+ container,
495
+ showLog: true,
389
496
  logFn: () => buildEdgeLogResult(edgeKey, structure),
390
- consoleListEl, structure
497
+ consoleListEl,
498
+ structure
391
499
  });
392
500
  }
393
501
 
@@ -412,7 +520,7 @@ export function buildConsoleSection() {
412
520
  const clearBtn = document.createElement('button');
413
521
  clearBtn.className = 'dn-menu-log-btn dn-menu-clear-btn';
414
522
  clearBtn.textContent = 'clear';
415
- clearBtn.addEventListener('click', (e) => {
523
+ clearBtn.addEventListener('click', e => {
416
524
  e.stopPropagation();
417
525
  e.preventDefault();
418
526
  listEl.innerHTML = '';
@@ -428,126 +536,17 @@ export function buildConsoleSection() {
428
536
 
429
537
  /**
430
538
  * Rendered Elements section with grouped Nodes and grouped Edges.
539
+ * Uses lazy rendering: children are created on <details> open and
540
+ * destroyed on close. State persists in the menu-state Map.
431
541
  *
432
542
  * Nodes are grouped by dimension > division, then "All Nodes" flat list.
433
543
  * Edges are grouped by navigation rule, then "All Edges" flat list.
434
544
  */
435
545
  export function buildRenderedElementsSection(structure, state, container, consoleListEl, buildLabelFn) {
436
- const details = makeDetails('Rendered Elements', { summaryClass: 'dn-menu-summary-top' });
437
-
438
- // --- Nodes ---
439
546
  const allNodeKeys = Object.keys(structure.nodes || {});
440
- const nodesDetails = makeDetails('Nodes (' + allNodeKeys.length + ')');
441
-
442
- // Group nodes by dimension > division
443
- if (structure.dimensions) {
444
- const dimKeys = Object.keys(structure.dimensions);
445
- dimKeys.forEach(dimKey => {
446
- const dim = structure.dimensions[dimKey];
447
- const dimNode = structure.nodes[dim.nodeId];
448
- const divIds = Object.keys(dim.divisions || {});
449
-
450
- // Collect all node IDs in this dimension for the group checkbox
451
- const dimChildItems = [];
452
- if (dimNode) dimChildItems.push({ type: 'node', id: dim.nodeId });
453
- divIds.forEach(divId => {
454
- const div = dim.divisions[divId];
455
- const divNodeId = findDivisionNodeId(structure, dimKey, divId);
456
- if (divNodeId) dimChildItems.push({ type: 'node', id: divNodeId });
457
- Object.keys(div.values || {}).forEach(leafId => {
458
- if (structure.nodes[leafId]) dimChildItems.push({ type: 'node', id: leafId });
459
- });
460
- });
461
-
462
- // Dimension-level details with group checkbox
463
- const dimDetails = makeGroupDetails(
464
- dimKey + ' (' + dim.type + ', ' + divIds.length + ' div' + (divIds.length !== 1 ? 's' : '') + ')',
465
- dimChildItems, state, container
466
- );
467
-
468
- // The dimension node itself
469
- if (dimNode) {
470
- dimDetails.appendChild(buildMenuItem({
471
- type: 'node', id: dim.nodeId,
472
- label: dim.nodeId + ' (dimension)',
473
- state, container, showLog: true,
474
- logFn: () => buildNodeLogResult(dim.nodeId, structure),
475
- consoleListEl, structure
476
- }));
477
- }
478
-
479
- // Each division
480
- divIds.forEach(divId => {
481
- const div = dim.divisions[divId];
482
- const leafIds = Object.keys(div.values || {});
483
-
484
- // Collect node IDs in this division for the group checkbox
485
- const divChildItems = [];
486
- const divNodeId = findDivisionNodeId(structure, dimKey, divId);
487
- if (divNodeId) divChildItems.push({ type: 'node', id: divNodeId });
488
- leafIds.forEach(leafId => {
489
- if (structure.nodes[leafId]) divChildItems.push({ type: 'node', id: leafId });
490
- });
491
-
492
- const divDetails = makeGroupDetails(
493
- divId + ' (' + leafIds.length + ' item' + (leafIds.length !== 1 ? 's' : '') + ')',
494
- divChildItems, state, container
495
- );
496
-
497
- // Division node itself
498
- if (divNodeId) {
499
- divDetails.appendChild(buildMenuItem({
500
- type: 'node', id: divNodeId,
501
- label: divNodeId + ' (division)',
502
- state, container, showLog: true,
503
- logFn: () => buildNodeLogResult(divNodeId, structure),
504
- consoleListEl, structure
505
- }));
506
- }
507
-
508
- // Leaf nodes in this division
509
- leafIds.forEach(leafId => {
510
- const node = structure.nodes[leafId];
511
- if (!node) return;
512
- const desc = buildLabelFn ? buildLabelFn(node) : leafId;
513
- const label = leafId + (desc !== leafId ? ' \u2014 ' + truncate(desc, 30) : '');
514
- divDetails.appendChild(buildMenuItem({
515
- type: 'node', id: leafId, label,
516
- state, container, showLog: true,
517
- logFn: () => buildNodeLogResult(leafId, structure),
518
- consoleListEl, structure
519
- }));
520
- });
521
-
522
- dimDetails.appendChild(divDetails);
523
- });
524
-
525
- nodesDetails.appendChild(dimDetails);
526
- });
527
- }
528
-
529
- // All Nodes flat list
530
- const allNodesDetails = makeDetails('All Nodes (' + allNodeKeys.length + ')');
531
- allNodeKeys.forEach(nodeId => {
532
- const node = structure.nodes[nodeId];
533
- const desc = buildLabelFn ? buildLabelFn(node) : nodeId;
534
- const label = nodeId + (desc !== nodeId ? ' \u2014 ' + truncate(desc, 30) : '');
535
- allNodesDetails.appendChild(buildMenuItem({
536
- type: 'node', id: nodeId, label,
537
- state, container, showLog: true,
538
- logFn: () => buildNodeLogResult(nodeId, structure),
539
- consoleListEl, structure
540
- }));
541
- });
542
- nodesDetails.appendChild(allNodesDetails);
543
-
544
- details.appendChild(nodesDetails);
545
-
546
- // --- Edges ---
547
547
  const allEdgeKeys = Object.keys(structure.edges || {});
548
- const edgesDetails = makeDetails('Edges (' + allEdgeKeys.length + ')');
549
548
 
550
- // Group edges by navigation rule
549
+ // Pre-compute edge grouping by navigation rule (cheap — just strings)
551
550
  const ruleToEdges = new Map();
552
551
  allEdgeKeys.forEach(edgeKey => {
553
552
  const edge = structure.edges[edgeKey];
@@ -557,32 +556,220 @@ export function buildRenderedElementsSection(structure, state, container, consol
557
556
  ruleToEdges.get(rule).push(edgeKey);
558
557
  });
559
558
  });
560
-
561
- // Sort rule names for consistent ordering
562
559
  const sortedRules = [...ruleToEdges.keys()].sort();
563
- sortedRules.forEach(rule => {
564
- const edgeKeysForRule = ruleToEdges.get(rule);
565
- const ruleChildItems = edgeKeysForRule.map(edgeKey => ({ type: 'edge', id: edgeKey }));
566
- const ruleDetails = makeGroupDetails(
567
- rule + ' (' + edgeKeysForRule.length + ')',
568
- ruleChildItems, state, container
569
- );
570
-
571
- edgeKeysForRule.forEach(edgeKey => {
572
- ruleDetails.appendChild(buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl));
573
- });
574
-
575
- edgesDetails.appendChild(ruleDetails);
576
- });
577
560
 
578
- // All Edges flat list
579
- const allEdgesDetails = makeDetails('All Edges (' + allEdgeKeys.length + ')');
580
- allEdgeKeys.forEach(edgeKey => {
581
- allEdgesDetails.appendChild(buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl));
582
- });
583
- edgesDetails.appendChild(allEdgesDetails);
584
-
585
- details.appendChild(edgesDetails);
561
+ // Top-level: Rendered Elements
562
+ const details = makeLazyDetails(
563
+ 'Rendered Elements',
564
+ parent => {
565
+ // --- Nodes ---
566
+ const nodesDetails = makeLazyDetails(
567
+ 'Nodes (' + allNodeKeys.length + ')',
568
+ nodesParent => {
569
+ // Group nodes by dimension > division
570
+ if (structure.dimensions) {
571
+ const dimKeys = Object.keys(structure.dimensions);
572
+ dimKeys.forEach(dimKey => {
573
+ const dim = structure.dimensions[dimKey];
574
+ const dimNode = structure.nodes[dim.nodeId];
575
+ const divIds = Object.keys(dim.divisions || {});
576
+
577
+ // Collect all node IDs in this dimension for the group checkbox
578
+ const dimChildItems = [];
579
+ if (dimNode) dimChildItems.push({ type: 'node', id: dim.nodeId });
580
+ divIds.forEach(divId => {
581
+ const div = dim.divisions[divId];
582
+ const divNodeId = findDivisionNodeId(structure, dimKey, divId);
583
+ if (divNodeId) dimChildItems.push({ type: 'node', id: divNodeId });
584
+ Object.keys(div.values || {}).forEach(leafId => {
585
+ if (structure.nodes[leafId]) dimChildItems.push({ type: 'node', id: leafId });
586
+ });
587
+ });
588
+
589
+ // Dimension-level details with group checkbox
590
+ const dimDetails = makeGroupDetails(
591
+ dimKey +
592
+ ' (' +
593
+ dim.type +
594
+ ', ' +
595
+ divIds.length +
596
+ ' div' +
597
+ (divIds.length !== 1 ? 's' : '') +
598
+ ')',
599
+ dimChildItems,
600
+ state,
601
+ container,
602
+ dimParent => {
603
+ // The dimension node itself
604
+ if (dimNode) {
605
+ dimParent.appendChild(
606
+ buildMenuItem({
607
+ type: 'node',
608
+ id: dim.nodeId,
609
+ label: dim.nodeId + ' (dimension)',
610
+ state,
611
+ container,
612
+ showLog: true,
613
+ logFn: () => buildNodeLogResult(dim.nodeId, structure),
614
+ consoleListEl,
615
+ structure
616
+ })
617
+ );
618
+ }
619
+
620
+ // Each division
621
+ divIds.forEach(divId => {
622
+ const div = dim.divisions[divId];
623
+ const leafIds = Object.keys(div.values || {});
624
+
625
+ // Collect node IDs in this division for the group checkbox
626
+ const divChildItems = [];
627
+ const divNodeId = findDivisionNodeId(structure, dimKey, divId);
628
+ if (divNodeId) divChildItems.push({ type: 'node', id: divNodeId });
629
+ leafIds.forEach(leafId => {
630
+ if (structure.nodes[leafId])
631
+ divChildItems.push({ type: 'node', id: leafId });
632
+ });
633
+
634
+ const divDetails = makeGroupDetails(
635
+ divId +
636
+ ' (' +
637
+ leafIds.length +
638
+ ' item' +
639
+ (leafIds.length !== 1 ? 's' : '') +
640
+ ')',
641
+ divChildItems,
642
+ state,
643
+ container,
644
+ divParent => {
645
+ // Division node itself
646
+ if (divNodeId) {
647
+ divParent.appendChild(
648
+ buildMenuItem({
649
+ type: 'node',
650
+ id: divNodeId,
651
+ label: divNodeId + ' (division)',
652
+ state,
653
+ container,
654
+ showLog: true,
655
+ logFn: () => buildNodeLogResult(divNodeId, structure),
656
+ consoleListEl,
657
+ structure
658
+ })
659
+ );
660
+ }
661
+
662
+ // Leaf nodes in this division
663
+ leafIds.forEach(leafId => {
664
+ const node = structure.nodes[leafId];
665
+ if (!node) return;
666
+ const desc = buildLabelFn ? buildLabelFn(node) : leafId;
667
+ const label =
668
+ leafId +
669
+ (desc !== leafId ? ' \u2014 ' + truncate(desc, 30) : '');
670
+ divParent.appendChild(
671
+ buildMenuItem({
672
+ type: 'node',
673
+ id: leafId,
674
+ label,
675
+ state,
676
+ container,
677
+ showLog: true,
678
+ logFn: () => buildNodeLogResult(leafId, structure),
679
+ consoleListEl,
680
+ structure
681
+ })
682
+ );
683
+ });
684
+ }
685
+ );
686
+
687
+ dimParent.appendChild(divDetails);
688
+ });
689
+ }
690
+ );
691
+
692
+ nodesParent.appendChild(dimDetails);
693
+ });
694
+ }
695
+
696
+ // All Nodes flat list
697
+ const allNodesDetails = makeLazyDetails(
698
+ 'All Nodes (' + allNodeKeys.length + ')',
699
+ allParent => {
700
+ allNodeKeys.forEach(nodeId => {
701
+ const node = structure.nodes[nodeId];
702
+ const desc = buildLabelFn ? buildLabelFn(node) : nodeId;
703
+ const label = nodeId + (desc !== nodeId ? ' \u2014 ' + truncate(desc, 30) : '');
704
+ allParent.appendChild(
705
+ buildMenuItem({
706
+ type: 'node',
707
+ id: nodeId,
708
+ label,
709
+ state,
710
+ container,
711
+ showLog: true,
712
+ logFn: () => buildNodeLogResult(nodeId, structure),
713
+ consoleListEl,
714
+ structure
715
+ })
716
+ );
717
+ });
718
+ },
719
+ state
720
+ );
721
+ nodesParent.appendChild(allNodesDetails);
722
+ },
723
+ state
724
+ );
725
+ parent.appendChild(nodesDetails);
726
+
727
+ // --- Edges ---
728
+ const edgesDetails = makeLazyDetails(
729
+ 'Edges (' + allEdgeKeys.length + ')',
730
+ edgesParent => {
731
+ // Group edges by navigation rule
732
+ sortedRules.forEach(rule => {
733
+ const edgeKeysForRule = ruleToEdges.get(rule);
734
+ const ruleChildItems = edgeKeysForRule.map(edgeKey => ({ type: 'edge', id: edgeKey }));
735
+ const ruleDetails = makeGroupDetails(
736
+ rule + ' (' + edgeKeysForRule.length + ')',
737
+ ruleChildItems,
738
+ state,
739
+ container,
740
+ ruleParent => {
741
+ edgeKeysForRule.forEach(edgeKey => {
742
+ ruleParent.appendChild(
743
+ buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl)
744
+ );
745
+ });
746
+ }
747
+ );
748
+
749
+ edgesParent.appendChild(ruleDetails);
750
+ });
751
+
752
+ // All Edges flat list
753
+ const allEdgesDetails = makeLazyDetails(
754
+ 'All Edges (' + allEdgeKeys.length + ')',
755
+ allParent => {
756
+ allEdgeKeys.forEach(edgeKey => {
757
+ allParent.appendChild(
758
+ buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl)
759
+ );
760
+ });
761
+ },
762
+ state
763
+ );
764
+ edgesParent.appendChild(allEdgesDetails);
765
+ },
766
+ state
767
+ );
768
+ parent.appendChild(edgesDetails);
769
+ },
770
+ state,
771
+ { summaryClass: 'dn-menu-summary-top' }
772
+ );
586
773
 
587
774
  return details;
588
775
  }
package/src/menu-state.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export function createMenuState() {
7
7
  const checked = new Map(); // Map<"type:id", {type, id}>
8
- const logEntries = []; // Array<{type, id, data, relatedItems, timestamp}>
8
+ const logEntries = []; // Array<{type, id, data, relatedItems, timestamp}>
9
9
  const listeners = new Set();
10
10
 
11
11
  function notify(changeType, payload) {
@@ -30,9 +30,9 @@ const DYNAMIC_EDGE_STROKE_WIDTH = '1.5';
30
30
  */
31
31
  export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicatorEl) {
32
32
  // Build direct reference maps at setup time
33
- const nodeElMap = new Map(); // nodeId -> SVGElement
34
- const edgeElMap = new Map(); // edgeKey -> SVGElement[]
35
- const allGraphEls = []; // every node/edge SVG element
33
+ const nodeElMap = new Map(); // nodeId -> SVGElement
34
+ const edgeElMap = new Map(); // edgeKey -> SVGElement[]
35
+ const allGraphEls = []; // every node/edge SVG element
36
36
 
37
37
  // Index circles (nodes) by their data ID
38
38
  svgEl.querySelectorAll('circle').forEach(el => {
@@ -144,10 +144,14 @@ export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicat
144
144
  const srcEl = nodeElMap.get(String(src));
145
145
  const tgtEl = nodeElMap.get(String(tgt));
146
146
  if (srcEl && tgtEl) {
147
- lines.push(insertDynamicLine(
148
- srcEl.getAttribute('cx'), srcEl.getAttribute('cy'),
149
- tgtEl.getAttribute('cx'), tgtEl.getAttribute('cy')
150
- ));
147
+ lines.push(
148
+ insertDynamicLine(
149
+ srcEl.getAttribute('cx'),
150
+ srcEl.getAttribute('cy'),
151
+ tgtEl.getAttribute('cx'),
152
+ tgtEl.getAttribute('cy')
153
+ )
154
+ );
151
155
  }
152
156
  } else {
153
157
  // At least one endpoint is a function — find connected nodes
@@ -168,10 +172,14 @@ export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicat
168
172
  if (nodeId === src) return;
169
173
  const nodeEl = nodeElMap.get(String(nodeId));
170
174
  if (nodeEl) {
171
- lines.push(insertDynamicLine(
172
- nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
173
- srcEl.getAttribute('cx'), srcEl.getAttribute('cy')
174
- ));
175
+ lines.push(
176
+ insertDynamicLine(
177
+ nodeEl.getAttribute('cx'),
178
+ nodeEl.getAttribute('cy'),
179
+ srcEl.getAttribute('cx'),
180
+ srcEl.getAttribute('cy')
181
+ )
182
+ );
175
183
  }
176
184
  });
177
185
  }
@@ -183,10 +191,14 @@ export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicat
183
191
  if (nodeId === tgt) return;
184
192
  const nodeEl = nodeElMap.get(String(nodeId));
185
193
  if (nodeEl) {
186
- lines.push(insertDynamicLine(
187
- nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
188
- tgtEl.getAttribute('cx'), tgtEl.getAttribute('cy')
189
- ));
194
+ lines.push(
195
+ insertDynamicLine(
196
+ nodeEl.getAttribute('cx'),
197
+ nodeEl.getAttribute('cy'),
198
+ tgtEl.getAttribute('cx'),
199
+ tgtEl.getAttribute('cy')
200
+ )
201
+ );
190
202
  }
191
203
  });
192
204
  }
@@ -199,10 +211,14 @@ export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicat
199
211
  if (nodeId === pseudoId) return;
200
212
  const nodeEl = nodeElMap.get(String(nodeId));
201
213
  if (nodeEl) {
202
- lines.push(insertDynamicLine(
203
- nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
204
- pseudoEl.getAttribute('cx'), pseudoEl.getAttribute('cy')
205
- ));
214
+ lines.push(
215
+ insertDynamicLine(
216
+ nodeEl.getAttribute('cx'),
217
+ nodeEl.getAttribute('cy'),
218
+ pseudoEl.getAttribute('cx'),
219
+ pseudoEl.getAttribute('cy')
220
+ )
221
+ );
206
222
  }
207
223
  });
208
224
  });
@@ -290,8 +306,14 @@ export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicat
290
306
  // Also include connected nodes for highlighting
291
307
  const src = typeof edge.source === 'function' ? null : edge.source;
292
308
  const tgt = typeof edge.target === 'function' ? null : edge.target;
293
- if (src) { const el = nodeElMap.get(String(src)); if (el) els.push(el); }
294
- if (tgt) { const el = nodeElMap.get(String(tgt)); if (el) els.push(el); }
309
+ if (src) {
310
+ const el = nodeElMap.get(String(src));
311
+ if (el) els.push(el);
312
+ }
313
+ if (tgt) {
314
+ const el = nodeElMap.get(String(tgt));
315
+ if (el) els.push(el);
316
+ }
295
317
  // For function-based edges, include all nodes that reference this edge
296
318
  if (!src || !tgt) {
297
319
  Object.keys(structure.nodes).forEach(nodeId => {
package/src/tree-graph.js CHANGED
@@ -8,10 +8,7 @@ import { schemeTableau10 } from 'd3-scale-chromatic';
8
8
  import { create } from 'd3-selection';
9
9
 
10
10
  export function TreeGraph(
11
- {
12
- nodes,
13
- links
14
- },
11
+ { nodes, links },
15
12
  {
16
13
  nodeId = d => d.id,
17
14
  nodeGroup,
@@ -32,15 +29,19 @@ export function TreeGraph(
32
29
 
33
30
  // Build a lookup from node id to original node data.
34
31
  const nodeById = {};
35
- nodes.forEach((n, i) => { nodeById[N[i]] = n; });
32
+ nodes.forEach((n, i) => {
33
+ nodeById[N[i]] = n;
34
+ });
36
35
 
37
36
  // Replace input nodes with positioned objects.
38
37
  nodes = map(nodes, (_, i) => ({ id: N[i], x: 0, y: 0 }));
39
38
  const nodeObjById = {};
40
- nodes.forEach(n => { nodeObjById[n.id] = n; });
39
+ nodes.forEach(n => {
40
+ nodeObjById[n.id] = n;
41
+ });
41
42
 
42
43
  // Map links to source/target ids.
43
- const linkData = map(links, (l) => ({
44
+ const linkData = map(links, l => ({
44
45
  source: intern(l.source),
45
46
  target: intern(l.target),
46
47
  navigationRules: l.navigationRules || []
@@ -69,7 +70,9 @@ export function TreeGraph(
69
70
  // Map each sibling rule name to its pair for splitting merged edges.
70
71
  const ruleToPair = {};
71
72
  siblingRulePairs.forEach(pair => {
72
- pair.forEach(r => { ruleToPair[r] = pair.join(','); });
73
+ pair.forEach(r => {
74
+ ruleToPair[r] = pair.join(',');
75
+ });
73
76
  });
74
77
 
75
78
  // Classify links. Sibling links with merged rules (multiple pairs)
@@ -106,7 +109,10 @@ export function TreeGraph(
106
109
 
107
110
  nodes.forEach(n => {
108
111
  const orig = nodeById[n.id];
109
- if (!orig) { otherNodes.push(n); return; }
112
+ if (!orig) {
113
+ otherNodes.push(n);
114
+ return;
115
+ }
110
116
  const dl = orig.dimensionLevel;
111
117
  const dn = orig.derivedNode;
112
118
  if (dl === 0) level0.push(n);
@@ -140,7 +146,9 @@ export function TreeGraph(
140
146
 
141
147
  // Organize level2 nodes by their parent dimension.
142
148
  const divisionsByDim = {};
143
- dimOrder.forEach(key => { divisionsByDim[key] = []; });
149
+ dimOrder.forEach(key => {
150
+ divisionsByDim[key] = [];
151
+ });
144
152
  level2.forEach(n => {
145
153
  const orig = nodeById[n.id];
146
154
  if (orig && orig.derivedNode && divisionsByDim[orig.derivedNode]) {
@@ -161,9 +169,10 @@ export function TreeGraph(
161
169
  const upperSpacing = upperRows > 1 ? upperHeight / (upperRows - 1) : upperHeight;
162
170
  const leafGap = usableHeight * 0.05; // small gap between hierarchy and leaves
163
171
  const leafYPos = padding.top + upperHeight + leafGap;
172
+ const leafAreaCenter = (leafYPos + height - padding.bottom) / 2;
164
173
 
165
174
  let currentRow = 0;
166
- const yFor = (row) => padding.top + row * upperSpacing;
175
+ const yFor = row => padding.top + row * upperSpacing;
167
176
 
168
177
  // Level 0
169
178
  if (hasLevel0) {
@@ -216,13 +225,13 @@ export function TreeGraph(
216
225
  // Leaf nodes
217
226
  const leafY = leafYPos;
218
227
  if (dimOrder.length === 0 || !dimensions) {
219
- // No dimensions — flat row
228
+ // No dimensions — flat row, centered in leaf area
220
229
  leafNodes.forEach((n, i) => {
221
230
  n.x = padding.left + (usableWidth / (leafNodes.length + 1)) * (i + 1);
222
- n.y = leafY;
231
+ n.y = leafAreaCenter;
223
232
  });
224
233
  } else if (dimOrder.length === 1) {
225
- // Single dimension — spread leaves under their division parent
234
+ // Single dimension — spread leaves under their division parent, centered in leaf area
226
235
  const key = dimOrder[0];
227
236
  const dim = dimensions[key];
228
237
  const divIds = Object.keys(dim.divisions || {});
@@ -236,22 +245,30 @@ export function TreeGraph(
236
245
  const n = nodeObjById[leafId];
237
246
  if (n) {
238
247
  n.x = parentX - spreadWidth / 2 + leafSpacing * (i + 1);
239
- n.y = leafY;
248
+ n.y = leafAreaCenter;
240
249
  }
241
250
  });
242
251
  });
243
252
  } else {
244
253
  // 2+ dimensions — grid layout
245
- // First dimension controls columns, second controls rows
246
- const dim1Key = dimOrder[0];
247
- const dim2Key = dimOrder[1];
248
- const dim1 = dimensions[dim1Key];
249
- const dim2 = dimensions[dim2Key];
250
- const div1Ids = Object.keys(dim1.divisions || {});
251
- const div2Ids = Object.keys(dim2.divisions || {});
252
-
253
- const cols = div1Ids.length || 1;
254
- const rows = div2Ids.length || 1;
254
+ // Prefer horizontal spread: ensure cols >= rows by swapping dims if needed.
255
+ // This prevents single-column layouts (e.g. when dim1 is compressed to 1 division).
256
+ let dim1Key = dimOrder[0];
257
+ let dim2Key = dimOrder[1];
258
+ let dim1 = dimensions[dim1Key];
259
+ let dim2 = dimensions[dim2Key];
260
+ let div1Ids = Object.keys(dim1.divisions || {});
261
+ let div2Ids = Object.keys(dim2.divisions || {});
262
+
263
+ let cols = div1Ids.length || 1;
264
+ let rows = div2Ids.length || 1;
265
+
266
+ if (cols < rows) {
267
+ [dim1Key, dim2Key] = [dim2Key, dim1Key];
268
+ [dim1, dim2] = [dim2, dim1];
269
+ [div1Ids, div2Ids] = [div2Ids, div1Ids];
270
+ [cols, rows] = [rows, cols];
271
+ }
255
272
 
256
273
  // Grid occupies the full leaf area
257
274
  const gridTop = leafY;
@@ -299,7 +316,7 @@ export function TreeGraph(
299
316
 
300
317
  // Other nodes (exit, etc.) — placed bottom-right
301
318
  otherNodes.forEach((n, i) => {
302
- n.x = width - padding.right - (i * nodeRadius * 3);
319
+ n.x = width - padding.right - i * nodeRadius * 3;
303
320
  n.y = height - padding.bottom;
304
321
  });
305
322
 
@@ -317,17 +334,21 @@ export function TreeGraph(
317
334
  // Wrap-around (circular) siblings arc below/right with a larger curve.
318
335
  // When multiple edges share the same node pair, each gets a larger arc.
319
336
  const edgePairCounts = {};
320
- const siblingArc = (d) => {
337
+ const siblingArc = d => {
321
338
  const s = nodeObjById[d.source];
322
339
  const t = nodeObjById[d.target];
323
340
  if (!s || !t) return '';
324
- const sx = s.x, sy = s.y, tx = t.x, ty = t.y;
325
- const dx = tx - sx, dy = ty - sy;
341
+ const sx = s.x,
342
+ sy = s.y,
343
+ tx = t.x,
344
+ ty = t.y;
345
+ const dx = tx - sx,
346
+ dy = ty - sy;
326
347
  const dist = Math.sqrt(dx * dx + dy * dy);
327
348
  const isHorizontal = Math.abs(dx) > Math.abs(dy);
328
349
  // Detect wrap-around: source is to the right of target (horizontal)
329
350
  // or below target (vertical) — these are circular edges.
330
- const isWrap = isHorizontal ? (sx > tx) : (sy > ty);
351
+ const isWrap = isHorizontal ? sx > tx : sy > ty;
331
352
  // Track regular and wrap arcs separately — they curve in opposite
332
353
  // directions so only same-direction arcs need stacking.
333
354
  const pairKey = [d.source, d.target].sort().join('::') + (isWrap ? '::wrap' : '::reg');
@@ -392,10 +413,11 @@ export function TreeGraph(
392
413
  .attr('r', nodeRadius)
393
414
  .attr('role', 'presentation');
394
415
 
395
- if (G) node.attr('fill', ({ id }) => {
396
- const idx = N.indexOf(id);
397
- return idx >= 0 ? color(G[idx]) : '#999';
398
- });
416
+ if (G)
417
+ node.attr('fill', ({ id }) => {
418
+ const idx = N.indexOf(id);
419
+ return idx >= 0 ? color(G[idx]) : '#999';
420
+ });
399
421
 
400
422
  function intern(value) {
401
423
  return value !== null && typeof value === 'object' ? value.valueOf() : value;
package/style.css CHANGED
@@ -152,7 +152,7 @@
152
152
  background: #f0f0f0;
153
153
  }
154
154
 
155
- .dn-inspector-menu .dn-menu-item input[type="checkbox"] {
155
+ .dn-inspector-menu .dn-menu-item input[type='checkbox'] {
156
156
  margin: 0;
157
157
  flex-shrink: 0;
158
158
  width: 13px;
@@ -238,7 +238,7 @@
238
238
  }
239
239
 
240
240
  .dn-inspector-menu .dn-menu-console-list:empty::after {
241
- content: "(empty)";
241
+ content: '(empty)';
242
242
  color: #999;
243
243
  padding: 2px 30px;
244
244
  display: block;