@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 +1 -1
- package/src/console-menu.js +2 -8
- package/src/force-graph.js +1 -1
- package/src/inspector.js +17 -12
- package/src/menu-events.js +6 -4
- package/src/menu-sections.js +350 -163
- package/src/menu-state.js +1 -1
- package/src/svg-highlight.js +43 -21
- package/src/tree-graph.js +56 -34
- package/style.css +2 -2
package/package.json
CHANGED
package/src/console-menu.js
CHANGED
|
@@ -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)
|
package/src/force-graph.js
CHANGED
|
@@ -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
|
|
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
|
|
154
|
-
const
|
|
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 =
|
|
169
|
-
|
|
170
|
-
{ nodes: nodeArray, links: linkArray },
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
255
|
+
buildLabelFn: node => buildLabel(node, colorBy)
|
|
251
256
|
});
|
|
252
257
|
}
|
|
253
258
|
|
package/src/menu-events.js
CHANGED
|
@@ -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(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
container.dispatchEvent(
|
|
23
|
+
new CustomEvent(eventName, {
|
|
24
|
+
bubbles: true,
|
|
25
|
+
detail
|
|
26
|
+
})
|
|
27
|
+
);
|
|
26
28
|
}
|
package/src/menu-sections.js
CHANGED
|
@@ -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
|
-
*
|
|
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',
|
|
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
|
|
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(
|
|
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,
|
|
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',
|
|
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,
|
|
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,
|
|
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,
|
|
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',
|
|
393
|
+
type: 'node',
|
|
394
|
+
id: nodeId,
|
|
302
395
|
label: 'node: ' + nodeId,
|
|
303
|
-
data: node,
|
|
396
|
+
data: node,
|
|
397
|
+
timestamp: Date.now()
|
|
304
398
|
},
|
|
305
|
-
relatedArrays:
|
|
306
|
-
|
|
307
|
-
|
|
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',
|
|
428
|
+
type: 'edge',
|
|
429
|
+
id: edgeKey,
|
|
330
430
|
label: 'edge: ' + (src || '?') + ' \u2192 ' + (tgt || '?'),
|
|
331
|
-
data: edge,
|
|
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',
|
|
469
|
+
type: 'rule',
|
|
470
|
+
id: ruleId,
|
|
369
471
|
label: 'rule: ' + ruleId,
|
|
370
|
-
data: rule || ruleId,
|
|
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',
|
|
388
|
-
|
|
490
|
+
type: 'edge',
|
|
491
|
+
id: edgeKey,
|
|
492
|
+
label,
|
|
493
|
+
state,
|
|
494
|
+
container,
|
|
495
|
+
showLog: true,
|
|
389
496
|
logFn: () => buildEdgeLogResult(edgeKey, structure),
|
|
390
|
-
consoleListEl,
|
|
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',
|
|
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
|
-
//
|
|
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
|
-
//
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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 = [];
|
|
8
|
+
const logEntries = []; // Array<{type, id, data, relatedItems, timestamp}>
|
|
9
9
|
const listeners = new Set();
|
|
10
10
|
|
|
11
11
|
function notify(changeType, payload) {
|
package/src/svg-highlight.js
CHANGED
|
@@ -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();
|
|
34
|
-
const edgeElMap = new Map();
|
|
35
|
-
const allGraphEls = [];
|
|
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(
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
187
|
-
|
|
188
|
-
|
|
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(
|
|
203
|
-
|
|
204
|
-
|
|
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) {
|
|
294
|
-
|
|
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) => {
|
|
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 => {
|
|
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,
|
|
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 => {
|
|
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) {
|
|
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 => {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
248
|
+
n.y = leafAreaCenter;
|
|
240
249
|
}
|
|
241
250
|
});
|
|
242
251
|
});
|
|
243
252
|
} else {
|
|
244
253
|
// 2+ dimensions — grid layout
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 -
|
|
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 =
|
|
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,
|
|
325
|
-
|
|
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 ?
|
|
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)
|
|
396
|
-
|
|
397
|
-
|
|
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=
|
|
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:
|
|
241
|
+
content: '(empty)';
|
|
242
242
|
color: #999;
|
|
243
243
|
padding: 2px 30px;
|
|
244
244
|
display: block;
|