@data-navigator/inspector 1.0.3 → 1.1.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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Builders for each section of the console menu.
3
+ * Each function returns an HTMLElement to be appended to the menu.
4
+ */
5
+
6
+ import { EVENTS, dispatch } from './menu-events.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Utility: collapsible section with caret indicator
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Create a <details> with a compact caret-styled <summary>.
14
+ */
15
+ function makeDetails(summaryText, opts = {}) {
16
+ const details = document.createElement('details');
17
+ if (opts.className) details.className = opts.className;
18
+ if (opts.open) details.open = true;
19
+
20
+ const summary = document.createElement('summary');
21
+ summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
22
+ summary.textContent = summaryText;
23
+ details.appendChild(summary);
24
+
25
+ return details;
26
+ }
27
+
28
+ /**
29
+ * Create a <details> with a group checkbox in the <summary>.
30
+ * The checkbox checks/unchecks all child items and syncs bidirectionally.
31
+ *
32
+ * @param {string} summaryText - Label text for the summary
33
+ * @param {Array<{type: string, id: string}>} childItems - Items this group controls
34
+ * @param {Object} state - The menu state object
35
+ * @param {HTMLElement} container - Container for event dispatching
36
+ * @param {Object} [opts] - Options passed to details (className, open, summaryClass)
37
+ * @returns {HTMLDetailsElement}
38
+ */
39
+ function makeGroupDetails(summaryText, childItems, state, container, opts = {}) {
40
+ const details = document.createElement('details');
41
+ if (opts.className) details.className = opts.className;
42
+ if (opts.open) details.open = true;
43
+
44
+ const summary = document.createElement('summary');
45
+ summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
46
+
47
+ const checkbox = document.createElement('input');
48
+ checkbox.type = 'checkbox';
49
+ checkbox.className = 'dn-menu-group-checkbox';
50
+ checkbox.addEventListener('click', (e) => {
51
+ e.stopPropagation(); // Don't toggle <details> when clicking checkbox
52
+ });
53
+ checkbox.addEventListener('change', () => {
54
+ const shouldCheck = checkbox.checked;
55
+ childItems.forEach(item => {
56
+ if (shouldCheck) {
57
+ state.check(item.type, item.id);
58
+ } else {
59
+ state.uncheck(item.type, item.id);
60
+ }
61
+ });
62
+ const allChecked = state.getChecked();
63
+ dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
64
+ });
65
+ summary.appendChild(checkbox);
66
+
67
+ const labelSpan = document.createElement('span');
68
+ labelSpan.textContent = summaryText;
69
+ summary.appendChild(labelSpan);
70
+
71
+ details.appendChild(summary);
72
+
73
+ // Sync checkbox state when child items change
74
+ function syncGroupCheckbox() {
75
+ const checkedCount = childItems.filter(item => state.isChecked(item.type, item.id)).length;
76
+ checkbox.checked = checkedCount === childItems.length && childItems.length > 0;
77
+ checkbox.indeterminate = checkedCount > 0 && checkedCount < childItems.length;
78
+ }
79
+
80
+ const unsub = state.subscribe((changeType) => {
81
+ if (changeType === 'check' || changeType === 'uncheck') {
82
+ syncGroupCheckbox();
83
+ }
84
+ });
85
+ details._unsub = unsub;
86
+
87
+ return details;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Utility: inline expandable array "label: [...](N)"
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Create an inline expandable array display.
96
+ * Collapsed: "label: [...](N)"
97
+ * Expanded: "label: [item1, item2, item3]" (inline, no new lines)
98
+ * Each item is interactive (hoverable via state).
99
+ */
100
+ function buildInlineArray({ label, items, state, container, structure }) {
101
+ const wrapper = document.createElement('div');
102
+ wrapper.className = 'dn-menu-inline-array';
103
+
104
+ const toggle = document.createElement('span');
105
+ toggle.className = 'dn-menu-array-toggle';
106
+ toggle.textContent = label + ': ';
107
+ wrapper.appendChild(toggle);
108
+
109
+ const collapsed = document.createElement('span');
110
+ collapsed.className = 'dn-menu-array-collapsed';
111
+ collapsed.textContent = '[...](' + items.length + ')';
112
+ wrapper.appendChild(collapsed);
113
+
114
+ const expanded = document.createElement('span');
115
+ expanded.className = 'dn-menu-array-expanded dn-menu-hidden';
116
+ expanded.textContent = '[';
117
+
118
+ items.forEach((item, i) => {
119
+ const chip = document.createElement('span');
120
+ chip.className = 'dn-menu-array-chip';
121
+ chip.textContent = item.label;
122
+ chip.dataset.type = item.type;
123
+ chip.dataset.id = item.id;
124
+
125
+ chip.addEventListener('mouseenter', () => {
126
+ state.setHover(item.type, item.id);
127
+ dispatch(container, EVENTS.ITEM_HOVER, {
128
+ type: item.type, id: item.id,
129
+ sourceData: getSourceData(item.type, item.id, structure)
130
+ });
131
+ });
132
+ chip.addEventListener('mouseleave', () => {
133
+ state.clearHover();
134
+ dispatch(container, EVENTS.ITEM_UNHOVER, { type: item.type, id: item.id });
135
+ });
136
+
137
+ expanded.appendChild(chip);
138
+ if (i < items.length - 1) {
139
+ expanded.appendChild(document.createTextNode(', '));
140
+ }
141
+ });
142
+
143
+ expanded.appendChild(document.createTextNode(']'));
144
+ wrapper.appendChild(expanded);
145
+
146
+ const toggleFn = () => {
147
+ const isCollapsed = !collapsed.classList.contains('dn-menu-hidden');
148
+ collapsed.classList.toggle('dn-menu-hidden', isCollapsed);
149
+ expanded.classList.toggle('dn-menu-hidden', !isCollapsed);
150
+ };
151
+ collapsed.addEventListener('click', toggleFn);
152
+ toggle.addEventListener('click', toggleFn);
153
+ toggle.style.cursor = 'pointer';
154
+ collapsed.style.cursor = 'pointer';
155
+
156
+ return wrapper;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Shared helper: buildMenuItem
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Creates a menu item row with checkbox, label, and optional "log" button.
165
+ */
166
+ export function buildMenuItem({ type, id, label, state, container, showLog, logFn, consoleListEl, structure }) {
167
+ const row = document.createElement('div');
168
+ row.className = 'dn-menu-item';
169
+ row.dataset.type = type;
170
+ row.dataset.id = id;
171
+
172
+ const checkbox = document.createElement('input');
173
+ checkbox.type = 'checkbox';
174
+ checkbox.checked = state.isChecked(type, id);
175
+ checkbox.addEventListener('change', () => {
176
+ state.toggle(type, id);
177
+ const allChecked = state.getChecked();
178
+ if (state.isChecked(type, id)) {
179
+ dispatch(container, EVENTS.ITEM_CHECK, { type, id, allChecked });
180
+ } else {
181
+ dispatch(container, EVENTS.ITEM_UNCHECK, { type, id, allChecked });
182
+ }
183
+ dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
184
+ });
185
+ row.appendChild(checkbox);
186
+
187
+ const labelSpan = document.createElement('span');
188
+ labelSpan.className = 'dn-menu-item-label';
189
+ labelSpan.textContent = label;
190
+ row.appendChild(labelSpan);
191
+
192
+ if (showLog && logFn) {
193
+ const logBtn = document.createElement('button');
194
+ logBtn.className = 'dn-menu-log-btn';
195
+ logBtn.textContent = 'log';
196
+ logBtn.addEventListener('click', (e) => {
197
+ e.stopPropagation();
198
+ const result = logFn();
199
+ if (result && consoleListEl) {
200
+ // Open the Console section and all ancestor <details> so it's visible
201
+ let el = consoleListEl;
202
+ while (el) {
203
+ if (el.tagName === 'DETAILS') el.open = true;
204
+ el = el.parentElement;
205
+ }
206
+ appendConsoleLogGroup(result, state, container, consoleListEl, structure);
207
+ dispatch(container, EVENTS.ITEM_LOG, {
208
+ type: result.mainEntry.type,
209
+ id: result.mainEntry.id,
210
+ loggedData: result.mainEntry.data
211
+ });
212
+ }
213
+ });
214
+ row.appendChild(logBtn);
215
+ }
216
+
217
+ row.addEventListener('mouseenter', () => {
218
+ state.setHover(type, id);
219
+ dispatch(container, EVENTS.ITEM_HOVER, {
220
+ type, id, sourceData: getSourceData(type, id, structure)
221
+ });
222
+ });
223
+ row.addEventListener('mouseleave', () => {
224
+ state.clearHover();
225
+ dispatch(container, EVENTS.ITEM_UNHOVER, { type, id });
226
+ });
227
+
228
+ const unsub = state.subscribe((changeType, payload) => {
229
+ if ((changeType === 'check' || changeType === 'uncheck') &&
230
+ payload.type === type && payload.id === id) {
231
+ checkbox.checked = state.isChecked(type, id);
232
+ }
233
+ });
234
+ row._unsub = unsub;
235
+
236
+ return row;
237
+ }
238
+
239
+ function getSourceData(type, id, structure) {
240
+ if (!structure) return null;
241
+ if (type === 'node') return structure.nodes[id] || null;
242
+ if (type === 'edge') return structure.edges[id] || null;
243
+ if (type === 'rule') return structure.navigationRules?.[id] || null;
244
+ return null;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Console log rendering (with inline expandable arrays)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function appendConsoleLogGroup(result, state, container, consoleListEl, structure) {
252
+ const { mainEntry, relatedArrays } = result;
253
+
254
+ const mainRow = buildMenuItem({
255
+ type: mainEntry.type,
256
+ id: mainEntry.id,
257
+ label: mainEntry.label,
258
+ state, container, showLog: false, structure
259
+ });
260
+ mainRow.classList.add('dn-menu-console-entry');
261
+ consoleListEl.appendChild(mainRow);
262
+
263
+ state.addLogEntry(mainEntry);
264
+
265
+ if (relatedArrays) {
266
+ relatedArrays.forEach(arr => {
267
+ const inlineEl = buildInlineArray({
268
+ label: arr.label,
269
+ items: arr.items,
270
+ state, container, structure
271
+ });
272
+ inlineEl.classList.add('dn-menu-console-entry');
273
+ inlineEl.style.paddingLeft = '24px';
274
+ consoleListEl.appendChild(inlineEl);
275
+ });
276
+ }
277
+
278
+ consoleListEl.scrollTop = consoleListEl.scrollHeight;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Log functions: return { mainEntry, relatedArrays }
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function buildNodeLogResult(nodeId, structure) {
286
+ const node = structure.nodes[nodeId];
287
+ if (!node) return null;
288
+
289
+ const edgeItems = (node.edges || [])
290
+ .map(eId => {
291
+ const edge = structure.edges[eId];
292
+ if (!edge) return null;
293
+ const src = typeof edge.source === 'function' ? '?' : edge.source;
294
+ const tgt = typeof edge.target === 'function' ? '?' : edge.target;
295
+ return { type: 'edge', id: eId, label: src + '\u2192' + tgt };
296
+ })
297
+ .filter(Boolean);
298
+
299
+ return {
300
+ mainEntry: {
301
+ type: 'node', id: nodeId,
302
+ label: 'node: ' + nodeId,
303
+ data: node, timestamp: Date.now()
304
+ },
305
+ relatedArrays: edgeItems.length > 0 ? [{
306
+ label: 'edges',
307
+ items: edgeItems
308
+ }] : []
309
+ };
310
+ }
311
+
312
+ function buildEdgeLogResult(edgeKey, structure) {
313
+ const edge = structure.edges[edgeKey];
314
+ if (!edge) return null;
315
+
316
+ const src = typeof edge.source === 'function' ? null : edge.source;
317
+ const tgt = typeof edge.target === 'function' ? null : edge.target;
318
+
319
+ const relatedArrays = [];
320
+ if (src && structure.nodes[src]) {
321
+ relatedArrays.push({ label: 'source', items: [{ type: 'node', id: src, label: src }] });
322
+ }
323
+ if (tgt && structure.nodes[tgt]) {
324
+ relatedArrays.push({ label: 'target', items: [{ type: 'node', id: tgt, label: tgt }] });
325
+ }
326
+
327
+ return {
328
+ mainEntry: {
329
+ type: 'edge', id: edgeKey,
330
+ label: 'edge: ' + (src || '?') + ' \u2192 ' + (tgt || '?'),
331
+ data: edge, timestamp: Date.now()
332
+ },
333
+ relatedArrays
334
+ };
335
+ }
336
+
337
+ function buildNavRuleLogResult(ruleId, structure) {
338
+ const rule = structure.navigationRules?.[ruleId];
339
+
340
+ const matchingEdgeItems = [];
341
+ const matchingNodeIds = new Set();
342
+
343
+ Object.keys(structure.edges).forEach(edgeKey => {
344
+ const edge = structure.edges[edgeKey];
345
+ if (edge.navigationRules && edge.navigationRules.includes(ruleId)) {
346
+ const src = typeof edge.source === 'function' ? '?' : edge.source;
347
+ const tgt = typeof edge.target === 'function' ? '?' : edge.target;
348
+ matchingEdgeItems.push({ type: 'edge', id: edgeKey, label: src + '\u2192' + tgt });
349
+ if (typeof edge.source === 'string') matchingNodeIds.add(edge.source);
350
+ if (typeof edge.target === 'string') matchingNodeIds.add(edge.target);
351
+ }
352
+ });
353
+
354
+ const matchingNodeItems = [...matchingNodeIds]
355
+ .filter(nid => structure.nodes[nid])
356
+ .map(nid => ({ type: 'node', id: nid, label: nid }));
357
+
358
+ const relatedArrays = [];
359
+ if (matchingEdgeItems.length > 0) {
360
+ relatedArrays.push({ label: 'edges', items: matchingEdgeItems });
361
+ }
362
+ if (matchingNodeItems.length > 0) {
363
+ relatedArrays.push({ label: 'nodes', items: matchingNodeItems });
364
+ }
365
+
366
+ return {
367
+ mainEntry: {
368
+ type: 'rule', id: ruleId,
369
+ label: 'rule: ' + ruleId,
370
+ data: rule || ruleId, timestamp: Date.now()
371
+ },
372
+ relatedArrays
373
+ };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Helper: build an edge menu item
378
+ // ---------------------------------------------------------------------------
379
+
380
+ function buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl) {
381
+ const edge = structure.edges[edgeKey];
382
+ const src = typeof edge.source === 'function' ? '(fn)' : edge.source;
383
+ const tgt = typeof edge.target === 'function' ? '(fn)' : edge.target;
384
+ const rules = (edge.navigationRules || []).join(',');
385
+ const label = src + ' \u2192 ' + tgt + (rules ? ' [' + rules + ']' : '');
386
+ return buildMenuItem({
387
+ type: 'edge', id: edgeKey, label,
388
+ state, container, showLog: true,
389
+ logFn: () => buildEdgeLogResult(edgeKey, structure),
390
+ consoleListEl, structure
391
+ });
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Section builders
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * Console section (collapsed by default, sits at top of menu).
400
+ * Includes a "clear" button on the summary row.
401
+ */
402
+ export function buildConsoleSection() {
403
+ const details = document.createElement('details');
404
+ details.className = 'dn-menu-console';
405
+
406
+ const summary = document.createElement('summary');
407
+ summary.className = 'dn-menu-summary dn-menu-summary-top';
408
+ summary.textContent = 'Console';
409
+ details.appendChild(summary);
410
+
411
+ // Clear button (appended after the summary text, inside summary)
412
+ const clearBtn = document.createElement('button');
413
+ clearBtn.className = 'dn-menu-log-btn dn-menu-clear-btn';
414
+ clearBtn.textContent = 'clear';
415
+ clearBtn.addEventListener('click', (e) => {
416
+ e.stopPropagation();
417
+ e.preventDefault();
418
+ listEl.innerHTML = '';
419
+ });
420
+ summary.appendChild(clearBtn);
421
+
422
+ const listEl = document.createElement('div');
423
+ listEl.className = 'dn-menu-console-list';
424
+ details.appendChild(listEl);
425
+
426
+ return { element: details, listEl };
427
+ }
428
+
429
+ /**
430
+ * Rendered Elements section with grouped Nodes and grouped Edges.
431
+ *
432
+ * Nodes are grouped by dimension > division, then "All Nodes" flat list.
433
+ * Edges are grouped by navigation rule, then "All Edges" flat list.
434
+ */
435
+ export function buildRenderedElementsSection(structure, state, container, consoleListEl, buildLabelFn) {
436
+ const details = makeDetails('Rendered Elements', { summaryClass: 'dn-menu-summary-top' });
437
+
438
+ // --- Nodes ---
439
+ 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
+ const allEdgeKeys = Object.keys(structure.edges || {});
548
+ const edgesDetails = makeDetails('Edges (' + allEdgeKeys.length + ')');
549
+
550
+ // Group edges by navigation rule
551
+ const ruleToEdges = new Map();
552
+ allEdgeKeys.forEach(edgeKey => {
553
+ const edge = structure.edges[edgeKey];
554
+ const rules = edge.navigationRules || [];
555
+ rules.forEach(rule => {
556
+ if (!ruleToEdges.has(rule)) ruleToEdges.set(rule, []);
557
+ ruleToEdges.get(rule).push(edgeKey);
558
+ });
559
+ });
560
+
561
+ // Sort rule names for consistent ordering
562
+ 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
+
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);
586
+
587
+ return details;
588
+ }
589
+
590
+ /**
591
+ * Find the node ID for a division node given dimension key and division ID.
592
+ */
593
+ function findDivisionNodeId(structure, dimKey, divId) {
594
+ // Division nodes have derivedNode === dimKey and data[dimKey] matching
595
+ const nodeKeys = Object.keys(structure.nodes);
596
+ for (let i = 0; i < nodeKeys.length; i++) {
597
+ const node = structure.nodes[nodeKeys[i]];
598
+ if (node.derivedNode === dimKey && node.dimensionLevel === 2) {
599
+ // Check if this node's data value matches divId
600
+ if (node.data && node.data[dimKey] !== undefined && String(node.data[dimKey]) === String(divId)) {
601
+ return nodeKeys[i];
602
+ }
603
+ // Also check by ID pattern — sometimes the node ID is the divId itself
604
+ if (nodeKeys[i] === divId) {
605
+ return divId;
606
+ }
607
+ }
608
+ }
609
+ return null;
610
+ }
611
+
612
+ /**
613
+ * Source Input section: wraps Data, Props, Dimensions, Divisions.
614
+ * (Console is now separate at top; nav rules are grouped under Edges)
615
+ */
616
+ export function buildSourceInputSection(structure, showConsoleMenu) {
617
+ const details = makeDetails('Source Input', { summaryClass: 'dn-menu-summary-top' });
618
+
619
+ // Data
620
+ if (showConsoleMenu.data) {
621
+ details.appendChild(buildDataSection(showConsoleMenu.data));
622
+ }
623
+
624
+ // Props
625
+ const propsEl = buildPropsSection({
626
+ structure: showConsoleMenu.structure,
627
+ input: showConsoleMenu.input,
628
+ rendering: showConsoleMenu.rendering
629
+ });
630
+ if (propsEl) details.appendChild(propsEl);
631
+
632
+ // Dimensions
633
+ details.appendChild(buildDimensionsSection(structure.dimensions));
634
+
635
+ // Divisions
636
+ details.appendChild(buildDivisionsSection(structure.dimensions));
637
+
638
+ return details;
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Simple section builders (Data, Props, Dimensions, Divisions)
643
+ // ---------------------------------------------------------------------------
644
+
645
+ function buildDataSection(data) {
646
+ const count = Array.isArray(data) ? data.length + ' items' : 'object';
647
+ const details = makeDetails('Data (' + count + ')');
648
+
649
+ const pre = document.createElement('pre');
650
+ pre.className = 'dn-menu-pre';
651
+ const code = document.createElement('code');
652
+ code.textContent = JSON.stringify(data, null, 2);
653
+ pre.appendChild(code);
654
+ details.appendChild(pre);
655
+
656
+ return details;
657
+ }
658
+
659
+ function buildPropsSection(propsConfig) {
660
+ const { structure: s, input: i, rendering: r } = propsConfig;
661
+ if (!s && !i && !r) return null;
662
+
663
+ const details = makeDetails('Props');
664
+
665
+ if (s) {
666
+ const sub = makeDetails('structure');
667
+ const pre = document.createElement('pre');
668
+ pre.className = 'dn-menu-pre';
669
+ pre.textContent = JSON.stringify(s, null, 2);
670
+ sub.appendChild(pre);
671
+ details.appendChild(sub);
672
+ }
673
+ if (i) {
674
+ const sub = makeDetails('input');
675
+ const pre = document.createElement('pre');
676
+ pre.className = 'dn-menu-pre';
677
+ pre.textContent = JSON.stringify(i, null, 2);
678
+ sub.appendChild(pre);
679
+ details.appendChild(sub);
680
+ }
681
+ if (r) {
682
+ const sub = makeDetails('rendering');
683
+ const pre = document.createElement('pre');
684
+ pre.className = 'dn-menu-pre';
685
+ pre.textContent = JSON.stringify(r, null, 2);
686
+ sub.appendChild(pre);
687
+ details.appendChild(sub);
688
+ }
689
+
690
+ return details;
691
+ }
692
+
693
+ function buildDimensionsSection(dimensions) {
694
+ const dimKeys = dimensions ? Object.keys(dimensions) : [];
695
+ const details = makeDetails('Dimensions (' + dimKeys.length + ')');
696
+
697
+ dimKeys.forEach(key => {
698
+ const dim = dimensions[key];
699
+ const divCount = Object.keys(dim.divisions || {}).length;
700
+ const item = document.createElement('div');
701
+ item.className = 'dn-menu-info';
702
+ item.textContent = key + ' (' + dim.type + ', ' + divCount + ' div' + (divCount !== 1 ? 's' : '') + ')';
703
+ details.appendChild(item);
704
+ });
705
+
706
+ if (dimKeys.length === 0) {
707
+ const empty = document.createElement('div');
708
+ empty.className = 'dn-menu-info dn-menu-empty';
709
+ empty.textContent = '(none)';
710
+ details.appendChild(empty);
711
+ }
712
+
713
+ return details;
714
+ }
715
+
716
+ function buildDivisionsSection(dimensions) {
717
+ const allDivisions = [];
718
+ if (dimensions) {
719
+ Object.keys(dimensions).forEach(dimKey => {
720
+ const dim = dimensions[dimKey];
721
+ Object.keys(dim.divisions || {}).forEach(divId => {
722
+ const div = dim.divisions[divId];
723
+ const count = Object.keys(div.values || {}).length;
724
+ allDivisions.push({ dimKey, divId, count });
725
+ });
726
+ });
727
+ }
728
+
729
+ const details = makeDetails('Divisions (' + allDivisions.length + ')');
730
+
731
+ allDivisions.forEach(({ dimKey, divId, count }) => {
732
+ const item = document.createElement('div');
733
+ item.className = 'dn-menu-info';
734
+ item.textContent = dimKey + ': ' + divId + ' (' + count + ')';
735
+ details.appendChild(item);
736
+ });
737
+
738
+ if (allDivisions.length === 0) {
739
+ const empty = document.createElement('div');
740
+ empty.className = 'dn-menu-info dn-menu-empty';
741
+ empty.textContent = '(none)';
742
+ details.appendChild(empty);
743
+ }
744
+
745
+ return details;
746
+ }
747
+
748
+ function truncate(str, max) {
749
+ if (str.length <= max) return str;
750
+ return str.slice(0, max - 1) + '\u2026';
751
+ }