@data-navigator/inspector 1.0.3 → 1.1.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.
@@ -0,0 +1,938 @@
1
+ /**
2
+ * Builders for each section of the console menu.
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).
8
+ */
9
+
10
+ import { EVENTS, dispatch } from './menu-events.js';
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
+
42
+ // ---------------------------------------------------------------------------
43
+ // Utility: collapsible section with caret indicator
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Create a <details> with a compact caret-styled <summary>.
48
+ */
49
+ function makeDetails(summaryText, opts = {}) {
50
+ const details = document.createElement('details');
51
+ if (opts.className) details.className = opts.className;
52
+ if (opts.open) details.open = true;
53
+
54
+ const summary = document.createElement('summary');
55
+ summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
56
+ summary.textContent = summaryText;
57
+ details.appendChild(summary);
58
+
59
+ return details;
60
+ }
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
+
97
+ /**
98
+ * Create a <details> with a group checkbox in the <summary>.
99
+ * Lazily renders children on open, destroys on close.
100
+ *
101
+ * @param {string} summaryText - Label text for the summary
102
+ * @param {Array<{type: string, id: string}>} childItems - Items this group controls
103
+ * @param {Object} state - The menu state object
104
+ * @param {HTMLElement} container - Container for event dispatching
105
+ * @param {Function} populateFn - Called with (detailsEl) when opened
106
+ * @param {Object} [opts] - Options passed to details (className, open, summaryClass)
107
+ * @returns {HTMLDetailsElement}
108
+ */
109
+ function makeGroupDetails(summaryText, childItems, state, container, populateFn, opts = {}) {
110
+ const details = document.createElement('details');
111
+ if (opts.className) details.className = opts.className;
112
+
113
+ const summary = document.createElement('summary');
114
+ summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
115
+
116
+ const checkbox = document.createElement('input');
117
+ checkbox.type = 'checkbox';
118
+ checkbox.className = 'dn-menu-group-checkbox';
119
+ checkbox.addEventListener('click', e => {
120
+ e.stopPropagation(); // Don't toggle <details> when clicking checkbox
121
+ });
122
+ checkbox.addEventListener('change', () => {
123
+ const shouldCheck = checkbox.checked;
124
+ childItems.forEach(item => {
125
+ if (shouldCheck) {
126
+ state.check(item.type, item.id);
127
+ } else {
128
+ state.uncheck(item.type, item.id);
129
+ }
130
+ });
131
+ const allChecked = state.getChecked();
132
+ dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
133
+ });
134
+ summary.appendChild(checkbox);
135
+
136
+ const labelSpan = document.createElement('span');
137
+ labelSpan.textContent = summaryText;
138
+ summary.appendChild(labelSpan);
139
+
140
+ details.appendChild(summary);
141
+
142
+ // Sync checkbox state — works without child DOM (reads from state)
143
+ function syncGroupCheckbox() {
144
+ const checkedCount = childItems.filter(item => state.isChecked(item.type, item.id)).length;
145
+ checkbox.checked = checkedCount === childItems.length && childItems.length > 0;
146
+ checkbox.indeterminate = checkedCount > 0 && checkedCount < childItems.length;
147
+ }
148
+ syncGroupCheckbox();
149
+
150
+ const unsub = state.subscribe(changeType => {
151
+ if (changeType === 'check' || changeType === 'uncheck') {
152
+ syncGroupCheckbox();
153
+ }
154
+ });
155
+ details._unsub = unsub;
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
+
172
+ return details;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Utility: inline expandable array "label: [...](N)"
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Create an inline expandable array display.
181
+ * Collapsed: "label: [...](N)"
182
+ * Expanded: "label: [item1, item2, item3]" (inline, no new lines)
183
+ * Each item is interactive (hoverable via state).
184
+ */
185
+ function buildInlineArray({ label, items, state, container, structure }) {
186
+ const wrapper = document.createElement('div');
187
+ wrapper.className = 'dn-menu-inline-array';
188
+
189
+ const toggle = document.createElement('span');
190
+ toggle.className = 'dn-menu-array-toggle';
191
+ toggle.textContent = label + ': ';
192
+ wrapper.appendChild(toggle);
193
+
194
+ const collapsed = document.createElement('span');
195
+ collapsed.className = 'dn-menu-array-collapsed';
196
+ collapsed.textContent = '[...](' + items.length + ')';
197
+ wrapper.appendChild(collapsed);
198
+
199
+ const expanded = document.createElement('span');
200
+ expanded.className = 'dn-menu-array-expanded dn-menu-hidden';
201
+ expanded.textContent = '[';
202
+
203
+ items.forEach((item, i) => {
204
+ const chip = document.createElement('span');
205
+ chip.className = 'dn-menu-array-chip';
206
+ chip.textContent = item.label;
207
+ chip.dataset.type = item.type;
208
+ chip.dataset.id = item.id;
209
+
210
+ chip.addEventListener('mouseenter', () => {
211
+ state.setHover(item.type, item.id);
212
+ dispatch(container, EVENTS.ITEM_HOVER, {
213
+ type: item.type,
214
+ id: item.id,
215
+ sourceData: getSourceData(item.type, item.id, structure)
216
+ });
217
+ });
218
+ chip.addEventListener('mouseleave', () => {
219
+ state.clearHover();
220
+ dispatch(container, EVENTS.ITEM_UNHOVER, { type: item.type, id: item.id });
221
+ });
222
+
223
+ expanded.appendChild(chip);
224
+ if (i < items.length - 1) {
225
+ expanded.appendChild(document.createTextNode(', '));
226
+ }
227
+ });
228
+
229
+ expanded.appendChild(document.createTextNode(']'));
230
+ wrapper.appendChild(expanded);
231
+
232
+ const toggleFn = () => {
233
+ const isCollapsed = !collapsed.classList.contains('dn-menu-hidden');
234
+ collapsed.classList.toggle('dn-menu-hidden', isCollapsed);
235
+ expanded.classList.toggle('dn-menu-hidden', !isCollapsed);
236
+ };
237
+ collapsed.addEventListener('click', toggleFn);
238
+ toggle.addEventListener('click', toggleFn);
239
+ toggle.style.cursor = 'pointer';
240
+ collapsed.style.cursor = 'pointer';
241
+
242
+ return wrapper;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Shared helper: buildMenuItem
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Creates a menu item row with checkbox, label, and optional "log" button.
251
+ */
252
+ export function buildMenuItem({ type, id, label, state, container, showLog, logFn, consoleListEl, structure }) {
253
+ const row = document.createElement('div');
254
+ row.className = 'dn-menu-item';
255
+ row.dataset.type = type;
256
+ row.dataset.id = id;
257
+
258
+ const checkbox = document.createElement('input');
259
+ checkbox.type = 'checkbox';
260
+ checkbox.checked = state.isChecked(type, id);
261
+ checkbox.addEventListener('change', () => {
262
+ state.toggle(type, id);
263
+ const allChecked = state.getChecked();
264
+ if (state.isChecked(type, id)) {
265
+ dispatch(container, EVENTS.ITEM_CHECK, { type, id, allChecked });
266
+ } else {
267
+ dispatch(container, EVENTS.ITEM_UNCHECK, { type, id, allChecked });
268
+ }
269
+ dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
270
+ });
271
+ row.appendChild(checkbox);
272
+
273
+ const labelSpan = document.createElement('span');
274
+ labelSpan.className = 'dn-menu-item-label';
275
+ labelSpan.textContent = label;
276
+ row.appendChild(labelSpan);
277
+
278
+ if (showLog && logFn) {
279
+ const logBtn = document.createElement('button');
280
+ logBtn.className = 'dn-menu-log-btn';
281
+ logBtn.textContent = 'log';
282
+ logBtn.addEventListener('click', e => {
283
+ e.stopPropagation();
284
+ const result = logFn();
285
+ if (result && consoleListEl) {
286
+ // Open the Console section and all ancestor <details> so it's visible
287
+ let el = consoleListEl;
288
+ while (el) {
289
+ if (el.tagName === 'DETAILS') el.open = true;
290
+ el = el.parentElement;
291
+ }
292
+ appendConsoleLogGroup(result, state, container, consoleListEl, structure);
293
+ dispatch(container, EVENTS.ITEM_LOG, {
294
+ type: result.mainEntry.type,
295
+ id: result.mainEntry.id,
296
+ loggedData: result.mainEntry.data
297
+ });
298
+ }
299
+ });
300
+ row.appendChild(logBtn);
301
+ }
302
+
303
+ row.addEventListener('mouseenter', () => {
304
+ state.setHover(type, id);
305
+ dispatch(container, EVENTS.ITEM_HOVER, {
306
+ type,
307
+ id,
308
+ sourceData: getSourceData(type, id, structure)
309
+ });
310
+ });
311
+ row.addEventListener('mouseleave', () => {
312
+ state.clearHover();
313
+ dispatch(container, EVENTS.ITEM_UNHOVER, { type, id });
314
+ });
315
+
316
+ const unsub = state.subscribe((changeType, payload) => {
317
+ if ((changeType === 'check' || changeType === 'uncheck') && payload.type === type && payload.id === id) {
318
+ checkbox.checked = state.isChecked(type, id);
319
+ }
320
+ });
321
+ row._unsub = unsub;
322
+
323
+ return row;
324
+ }
325
+
326
+ function getSourceData(type, id, structure) {
327
+ if (!structure) return null;
328
+ if (type === 'node') return structure.nodes[id] || null;
329
+ if (type === 'edge') return structure.edges[id] || null;
330
+ if (type === 'rule') return structure.navigationRules?.[id] || null;
331
+ return null;
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Console log rendering (with inline expandable arrays)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function appendConsoleLogGroup(result, state, container, consoleListEl, structure) {
339
+ const { mainEntry, relatedArrays } = result;
340
+
341
+ const mainRow = buildMenuItem({
342
+ type: mainEntry.type,
343
+ id: mainEntry.id,
344
+ label: mainEntry.label,
345
+ state,
346
+ container,
347
+ showLog: false,
348
+ structure
349
+ });
350
+ mainRow.classList.add('dn-menu-console-entry');
351
+ consoleListEl.appendChild(mainRow);
352
+
353
+ state.addLogEntry(mainEntry);
354
+
355
+ if (relatedArrays) {
356
+ relatedArrays.forEach(arr => {
357
+ const inlineEl = buildInlineArray({
358
+ label: arr.label,
359
+ items: arr.items,
360
+ state,
361
+ container,
362
+ structure
363
+ });
364
+ inlineEl.classList.add('dn-menu-console-entry');
365
+ inlineEl.style.paddingLeft = '24px';
366
+ consoleListEl.appendChild(inlineEl);
367
+ });
368
+ }
369
+
370
+ consoleListEl.scrollTop = consoleListEl.scrollHeight;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Log functions: return { mainEntry, relatedArrays }
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function buildNodeLogResult(nodeId, structure) {
378
+ const node = structure.nodes[nodeId];
379
+ if (!node) return null;
380
+
381
+ const edgeItems = (node.edges || [])
382
+ .map(eId => {
383
+ const edge = structure.edges[eId];
384
+ if (!edge) return null;
385
+ const src = typeof edge.source === 'function' ? '?' : edge.source;
386
+ const tgt = typeof edge.target === 'function' ? '?' : edge.target;
387
+ return { type: 'edge', id: eId, label: src + '\u2192' + tgt };
388
+ })
389
+ .filter(Boolean);
390
+
391
+ return {
392
+ mainEntry: {
393
+ type: 'node',
394
+ id: nodeId,
395
+ label: 'node: ' + nodeId,
396
+ data: node,
397
+ timestamp: Date.now()
398
+ },
399
+ relatedArrays:
400
+ edgeItems.length > 0
401
+ ? [
402
+ {
403
+ label: 'edges',
404
+ items: edgeItems
405
+ }
406
+ ]
407
+ : []
408
+ };
409
+ }
410
+
411
+ function buildEdgeLogResult(edgeKey, structure) {
412
+ const edge = structure.edges[edgeKey];
413
+ if (!edge) return null;
414
+
415
+ const src = typeof edge.source === 'function' ? null : edge.source;
416
+ const tgt = typeof edge.target === 'function' ? null : edge.target;
417
+
418
+ const relatedArrays = [];
419
+ if (src && structure.nodes[src]) {
420
+ relatedArrays.push({ label: 'source', items: [{ type: 'node', id: src, label: src }] });
421
+ }
422
+ if (tgt && structure.nodes[tgt]) {
423
+ relatedArrays.push({ label: 'target', items: [{ type: 'node', id: tgt, label: tgt }] });
424
+ }
425
+
426
+ return {
427
+ mainEntry: {
428
+ type: 'edge',
429
+ id: edgeKey,
430
+ label: 'edge: ' + (src || '?') + ' \u2192 ' + (tgt || '?'),
431
+ data: edge,
432
+ timestamp: Date.now()
433
+ },
434
+ relatedArrays
435
+ };
436
+ }
437
+
438
+ function buildNavRuleLogResult(ruleId, structure) {
439
+ const rule = structure.navigationRules?.[ruleId];
440
+
441
+ const matchingEdgeItems = [];
442
+ const matchingNodeIds = new Set();
443
+
444
+ Object.keys(structure.edges).forEach(edgeKey => {
445
+ const edge = structure.edges[edgeKey];
446
+ if (edge.navigationRules && edge.navigationRules.includes(ruleId)) {
447
+ const src = typeof edge.source === 'function' ? '?' : edge.source;
448
+ const tgt = typeof edge.target === 'function' ? '?' : edge.target;
449
+ matchingEdgeItems.push({ type: 'edge', id: edgeKey, label: src + '\u2192' + tgt });
450
+ if (typeof edge.source === 'string') matchingNodeIds.add(edge.source);
451
+ if (typeof edge.target === 'string') matchingNodeIds.add(edge.target);
452
+ }
453
+ });
454
+
455
+ const matchingNodeItems = [...matchingNodeIds]
456
+ .filter(nid => structure.nodes[nid])
457
+ .map(nid => ({ type: 'node', id: nid, label: nid }));
458
+
459
+ const relatedArrays = [];
460
+ if (matchingEdgeItems.length > 0) {
461
+ relatedArrays.push({ label: 'edges', items: matchingEdgeItems });
462
+ }
463
+ if (matchingNodeItems.length > 0) {
464
+ relatedArrays.push({ label: 'nodes', items: matchingNodeItems });
465
+ }
466
+
467
+ return {
468
+ mainEntry: {
469
+ type: 'rule',
470
+ id: ruleId,
471
+ label: 'rule: ' + ruleId,
472
+ data: rule || ruleId,
473
+ timestamp: Date.now()
474
+ },
475
+ relatedArrays
476
+ };
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Helper: build an edge menu item
481
+ // ---------------------------------------------------------------------------
482
+
483
+ function buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl) {
484
+ const edge = structure.edges[edgeKey];
485
+ const src = typeof edge.source === 'function' ? '(fn)' : edge.source;
486
+ const tgt = typeof edge.target === 'function' ? '(fn)' : edge.target;
487
+ const rules = (edge.navigationRules || []).join(',');
488
+ const label = src + ' \u2192 ' + tgt + (rules ? ' [' + rules + ']' : '');
489
+ return buildMenuItem({
490
+ type: 'edge',
491
+ id: edgeKey,
492
+ label,
493
+ state,
494
+ container,
495
+ showLog: true,
496
+ logFn: () => buildEdgeLogResult(edgeKey, structure),
497
+ consoleListEl,
498
+ structure
499
+ });
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Section builders
504
+ // ---------------------------------------------------------------------------
505
+
506
+ /**
507
+ * Console section (collapsed by default, sits at top of menu).
508
+ * Includes a "clear" button on the summary row.
509
+ */
510
+ export function buildConsoleSection() {
511
+ const details = document.createElement('details');
512
+ details.className = 'dn-menu-console';
513
+
514
+ const summary = document.createElement('summary');
515
+ summary.className = 'dn-menu-summary dn-menu-summary-top';
516
+ summary.textContent = 'Console';
517
+ details.appendChild(summary);
518
+
519
+ // Clear button (appended after the summary text, inside summary)
520
+ const clearBtn = document.createElement('button');
521
+ clearBtn.className = 'dn-menu-log-btn dn-menu-clear-btn';
522
+ clearBtn.textContent = 'clear';
523
+ clearBtn.addEventListener('click', e => {
524
+ e.stopPropagation();
525
+ e.preventDefault();
526
+ listEl.innerHTML = '';
527
+ });
528
+ summary.appendChild(clearBtn);
529
+
530
+ const listEl = document.createElement('div');
531
+ listEl.className = 'dn-menu-console-list';
532
+ details.appendChild(listEl);
533
+
534
+ return { element: details, listEl };
535
+ }
536
+
537
+ /**
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.
541
+ *
542
+ * Nodes are grouped by dimension > division, then "All Nodes" flat list.
543
+ * Edges are grouped by navigation rule, then "All Edges" flat list.
544
+ */
545
+ export function buildRenderedElementsSection(structure, state, container, consoleListEl, buildLabelFn) {
546
+ const allNodeKeys = Object.keys(structure.nodes || {});
547
+ const allEdgeKeys = Object.keys(structure.edges || {});
548
+
549
+ // Pre-compute edge grouping by navigation rule (cheap — just strings)
550
+ const ruleToEdges = new Map();
551
+ allEdgeKeys.forEach(edgeKey => {
552
+ const edge = structure.edges[edgeKey];
553
+ const rules = edge.navigationRules || [];
554
+ rules.forEach(rule => {
555
+ if (!ruleToEdges.has(rule)) ruleToEdges.set(rule, []);
556
+ ruleToEdges.get(rule).push(edgeKey);
557
+ });
558
+ });
559
+ const sortedRules = [...ruleToEdges.keys()].sort();
560
+
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
+ );
773
+
774
+ return details;
775
+ }
776
+
777
+ /**
778
+ * Find the node ID for a division node given dimension key and division ID.
779
+ */
780
+ function findDivisionNodeId(structure, dimKey, divId) {
781
+ // Division nodes have derivedNode === dimKey and data[dimKey] matching
782
+ const nodeKeys = Object.keys(structure.nodes);
783
+ for (let i = 0; i < nodeKeys.length; i++) {
784
+ const node = structure.nodes[nodeKeys[i]];
785
+ if (node.derivedNode === dimKey && node.dimensionLevel === 2) {
786
+ // Check if this node's data value matches divId
787
+ if (node.data && node.data[dimKey] !== undefined && String(node.data[dimKey]) === String(divId)) {
788
+ return nodeKeys[i];
789
+ }
790
+ // Also check by ID pattern — sometimes the node ID is the divId itself
791
+ if (nodeKeys[i] === divId) {
792
+ return divId;
793
+ }
794
+ }
795
+ }
796
+ return null;
797
+ }
798
+
799
+ /**
800
+ * Source Input section: wraps Data, Props, Dimensions, Divisions.
801
+ * (Console is now separate at top; nav rules are grouped under Edges)
802
+ */
803
+ export function buildSourceInputSection(structure, showConsoleMenu) {
804
+ const details = makeDetails('Source Input', { summaryClass: 'dn-menu-summary-top' });
805
+
806
+ // Data
807
+ if (showConsoleMenu.data) {
808
+ details.appendChild(buildDataSection(showConsoleMenu.data));
809
+ }
810
+
811
+ // Props
812
+ const propsEl = buildPropsSection({
813
+ structure: showConsoleMenu.structure,
814
+ input: showConsoleMenu.input,
815
+ rendering: showConsoleMenu.rendering
816
+ });
817
+ if (propsEl) details.appendChild(propsEl);
818
+
819
+ // Dimensions
820
+ details.appendChild(buildDimensionsSection(structure.dimensions));
821
+
822
+ // Divisions
823
+ details.appendChild(buildDivisionsSection(structure.dimensions));
824
+
825
+ return details;
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // Simple section builders (Data, Props, Dimensions, Divisions)
830
+ // ---------------------------------------------------------------------------
831
+
832
+ function buildDataSection(data) {
833
+ const count = Array.isArray(data) ? data.length + ' items' : 'object';
834
+ const details = makeDetails('Data (' + count + ')');
835
+
836
+ const pre = document.createElement('pre');
837
+ pre.className = 'dn-menu-pre';
838
+ const code = document.createElement('code');
839
+ code.textContent = JSON.stringify(data, null, 2);
840
+ pre.appendChild(code);
841
+ details.appendChild(pre);
842
+
843
+ return details;
844
+ }
845
+
846
+ function buildPropsSection(propsConfig) {
847
+ const { structure: s, input: i, rendering: r } = propsConfig;
848
+ if (!s && !i && !r) return null;
849
+
850
+ const details = makeDetails('Props');
851
+
852
+ if (s) {
853
+ const sub = makeDetails('structure');
854
+ const pre = document.createElement('pre');
855
+ pre.className = 'dn-menu-pre';
856
+ pre.textContent = JSON.stringify(s, null, 2);
857
+ sub.appendChild(pre);
858
+ details.appendChild(sub);
859
+ }
860
+ if (i) {
861
+ const sub = makeDetails('input');
862
+ const pre = document.createElement('pre');
863
+ pre.className = 'dn-menu-pre';
864
+ pre.textContent = JSON.stringify(i, null, 2);
865
+ sub.appendChild(pre);
866
+ details.appendChild(sub);
867
+ }
868
+ if (r) {
869
+ const sub = makeDetails('rendering');
870
+ const pre = document.createElement('pre');
871
+ pre.className = 'dn-menu-pre';
872
+ pre.textContent = JSON.stringify(r, null, 2);
873
+ sub.appendChild(pre);
874
+ details.appendChild(sub);
875
+ }
876
+
877
+ return details;
878
+ }
879
+
880
+ function buildDimensionsSection(dimensions) {
881
+ const dimKeys = dimensions ? Object.keys(dimensions) : [];
882
+ const details = makeDetails('Dimensions (' + dimKeys.length + ')');
883
+
884
+ dimKeys.forEach(key => {
885
+ const dim = dimensions[key];
886
+ const divCount = Object.keys(dim.divisions || {}).length;
887
+ const item = document.createElement('div');
888
+ item.className = 'dn-menu-info';
889
+ item.textContent = key + ' (' + dim.type + ', ' + divCount + ' div' + (divCount !== 1 ? 's' : '') + ')';
890
+ details.appendChild(item);
891
+ });
892
+
893
+ if (dimKeys.length === 0) {
894
+ const empty = document.createElement('div');
895
+ empty.className = 'dn-menu-info dn-menu-empty';
896
+ empty.textContent = '(none)';
897
+ details.appendChild(empty);
898
+ }
899
+
900
+ return details;
901
+ }
902
+
903
+ function buildDivisionsSection(dimensions) {
904
+ const allDivisions = [];
905
+ if (dimensions) {
906
+ Object.keys(dimensions).forEach(dimKey => {
907
+ const dim = dimensions[dimKey];
908
+ Object.keys(dim.divisions || {}).forEach(divId => {
909
+ const div = dim.divisions[divId];
910
+ const count = Object.keys(div.values || {}).length;
911
+ allDivisions.push({ dimKey, divId, count });
912
+ });
913
+ });
914
+ }
915
+
916
+ const details = makeDetails('Divisions (' + allDivisions.length + ')');
917
+
918
+ allDivisions.forEach(({ dimKey, divId, count }) => {
919
+ const item = document.createElement('div');
920
+ item.className = 'dn-menu-info';
921
+ item.textContent = dimKey + ': ' + divId + ' (' + count + ')';
922
+ details.appendChild(item);
923
+ });
924
+
925
+ if (allDivisions.length === 0) {
926
+ const empty = document.createElement('div');
927
+ empty.className = 'dn-menu-info dn-menu-empty';
928
+ empty.textContent = '(none)';
929
+ details.appendChild(empty);
930
+ }
931
+
932
+ return details;
933
+ }
934
+
935
+ function truncate(str, max) {
936
+ if (str.length <= max) return str;
937
+ return str.slice(0, max - 1) + '\u2026';
938
+ }