@data-navigator/inspector 1.0.2 → 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.
- package/package.json +4 -2
- package/src/console-menu.js +85 -0
- package/src/force-graph.js +4 -2
- package/src/inspector.js +56 -9
- package/src/menu-events.js +26 -0
- package/src/menu-sections.js +751 -0
- package/src/menu-state.js +89 -0
- package/src/svg-highlight.js +457 -0
- package/src/tree-graph.js +63 -14
- package/style.css +295 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a shared observable state manager for the console menu.
|
|
3
|
+
* Single source of truth for checked items, hover state, and console log entries.
|
|
4
|
+
* Both menu sections and console items read/write through this.
|
|
5
|
+
*/
|
|
6
|
+
export function createMenuState() {
|
|
7
|
+
const checked = new Map(); // Map<"type:id", {type, id}>
|
|
8
|
+
const logEntries = []; // Array<{type, id, data, relatedItems, timestamp}>
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
|
|
11
|
+
function notify(changeType, payload) {
|
|
12
|
+
listeners.forEach(fn => fn(changeType, payload));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
17
|
+
subscribe(fn) {
|
|
18
|
+
listeners.add(fn);
|
|
19
|
+
return () => listeners.delete(fn);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
// --- Checkbox management ---
|
|
23
|
+
|
|
24
|
+
check(type, id) {
|
|
25
|
+
const key = type + ':' + id;
|
|
26
|
+
if (checked.has(key)) return;
|
|
27
|
+
checked.set(key, { type, id });
|
|
28
|
+
notify('check', { type, id });
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
uncheck(type, id) {
|
|
32
|
+
const key = type + ':' + id;
|
|
33
|
+
if (!checked.has(key)) return;
|
|
34
|
+
checked.delete(key);
|
|
35
|
+
notify('uncheck', { type, id });
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
toggle(type, id) {
|
|
39
|
+
const key = type + ':' + id;
|
|
40
|
+
if (checked.has(key)) {
|
|
41
|
+
checked.delete(key);
|
|
42
|
+
notify('uncheck', { type, id });
|
|
43
|
+
} else {
|
|
44
|
+
checked.set(key, { type, id });
|
|
45
|
+
notify('check', { type, id });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
isChecked(type, id) {
|
|
50
|
+
return checked.has(type + ':' + id);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
getChecked() {
|
|
54
|
+
return Array.from(checked.values());
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
hasAnyChecked() {
|
|
58
|
+
return checked.size > 0;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// --- Hover state (one item at a time) ---
|
|
62
|
+
|
|
63
|
+
hoveredItem: null, // {type, id} or null
|
|
64
|
+
|
|
65
|
+
setHover(type, id) {
|
|
66
|
+
this.hoveredItem = { type, id };
|
|
67
|
+
notify('hover', { type, id });
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
clearHover() {
|
|
71
|
+
const prev = this.hoveredItem;
|
|
72
|
+
this.hoveredItem = null;
|
|
73
|
+
if (prev) {
|
|
74
|
+
notify('unhover', prev);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// --- Console log entries ---
|
|
79
|
+
|
|
80
|
+
addLogEntry(entry) {
|
|
81
|
+
logEntries.push(entry);
|
|
82
|
+
notify('log-added', entry);
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
getLogEntries() {
|
|
86
|
+
return logEntries;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG highlight module: manages opacity on SVG elements in response to
|
|
3
|
+
* menu state changes (checkbox selections and hover).
|
|
4
|
+
*
|
|
5
|
+
* Uses inline style.opacity for reliability across SVG rendering contexts,
|
|
6
|
+
* and builds direct element reference maps at setup time to avoid fragile
|
|
7
|
+
* CSS selector lookups.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DIMMED_OPACITY = '0.5';
|
|
11
|
+
const FULL_OPACITY = '';
|
|
12
|
+
const HOVER_STROKE_WIDTH = '3';
|
|
13
|
+
const SELECTED_EDGE_STROKE = '#222';
|
|
14
|
+
const SELECTED_EDGE_WIDTH = '2.5';
|
|
15
|
+
const SELECTED_NODE_STROKE = '#222';
|
|
16
|
+
const SELECTED_NODE_STROKE_WIDTH = '2';
|
|
17
|
+
const DYNAMIC_EDGE_STROKE = '#888';
|
|
18
|
+
const DYNAMIC_EDGE_STROKE_WIDTH = '1.5';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Connect the menu state to SVG highlighting.
|
|
22
|
+
* Builds element maps from the SVG, then subscribes to state changes.
|
|
23
|
+
*
|
|
24
|
+
* @param {SVGElement} svgEl
|
|
25
|
+
* @param {Object} state - The menu state from createMenuState()
|
|
26
|
+
* @param {Object} edgeSvgIdMap - edge key -> array of SVG element IDs
|
|
27
|
+
* @param {Object} structure - data-navigator structure
|
|
28
|
+
* @param {SVGCircleElement} indicatorEl - the focus indicator circle
|
|
29
|
+
* @returns {Function} unsubscribe function
|
|
30
|
+
*/
|
|
31
|
+
export function connectStateToSvg(svgEl, state, edgeSvgIdMap, structure, indicatorEl) {
|
|
32
|
+
// Build direct reference maps at setup time
|
|
33
|
+
const nodeElMap = new Map(); // nodeId -> SVGElement
|
|
34
|
+
const edgeElMap = new Map(); // edgeKey -> SVGElement[]
|
|
35
|
+
const allGraphEls = []; // every node/edge SVG element
|
|
36
|
+
|
|
37
|
+
// Index circles (nodes) by their data ID
|
|
38
|
+
svgEl.querySelectorAll('circle').forEach(el => {
|
|
39
|
+
if (el.classList.contains('dn-inspector-focus-indicator')) return;
|
|
40
|
+
const dataId = el.__data__?.id;
|
|
41
|
+
if (dataId !== undefined) {
|
|
42
|
+
nodeElMap.set(String(dataId), el);
|
|
43
|
+
allGraphEls.push(el);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Index lines and paths (edges) by building a reverse lookup from SVG id
|
|
48
|
+
const svgIdToEdgeKey = new Map();
|
|
49
|
+
Object.keys(edgeSvgIdMap).forEach(edgeKey => {
|
|
50
|
+
const svgIds = edgeSvgIdMap[edgeKey];
|
|
51
|
+
svgIds.forEach(svgId => svgIdToEdgeKey.set(svgId, edgeKey));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
svgEl.querySelectorAll('line, path').forEach(el => {
|
|
55
|
+
if (!el.id) return;
|
|
56
|
+
const edgeKey = svgIdToEdgeKey.get(el.id);
|
|
57
|
+
if (edgeKey !== undefined) {
|
|
58
|
+
if (!edgeElMap.has(edgeKey)) edgeElMap.set(edgeKey, []);
|
|
59
|
+
edgeElMap.get(edgeKey).push(el);
|
|
60
|
+
}
|
|
61
|
+
allGraphEls.push(el);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Store original stroke-widths for edges so we can restore after hover
|
|
65
|
+
const originalStrokeWidths = new Map();
|
|
66
|
+
allGraphEls.forEach(el => {
|
|
67
|
+
if (el.tagName === 'line' || el.tagName === 'path') {
|
|
68
|
+
originalStrokeWidths.set(el, el.getAttribute('stroke-width') || '');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- Dynamic edge management for edges with no pre-rendered SVG elements ---
|
|
73
|
+
// These are edges (like "exit") that connect to many nodes but are excluded
|
|
74
|
+
// from the graph to reduce noise. We draw them on-demand when selected/hovered.
|
|
75
|
+
//
|
|
76
|
+
// For function-based edges (source/target are functions), we find all nodes
|
|
77
|
+
// that reference the edge in their `edges` array, plus any pseudo-nodes (SVG
|
|
78
|
+
// nodes not in structure.nodes, e.g. "exit" from nodeInclusions). Lines are
|
|
79
|
+
// drawn from each referencing node to each pseudo-node.
|
|
80
|
+
const dynamicEdgeEls = new Map(); // edgeKey -> SVGLineElement[]
|
|
81
|
+
|
|
82
|
+
// Identify pseudo-nodes: present in SVG (nodeElMap) but not in structure.nodes
|
|
83
|
+
const pseudoNodeIds = new Set();
|
|
84
|
+
nodeElMap.forEach((_, nodeId) => {
|
|
85
|
+
if (!structure.nodes[nodeId]) pseudoNodeIds.add(nodeId);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if an edge has no pre-rendered SVG elements.
|
|
90
|
+
*/
|
|
91
|
+
function isVirtualEdge(edgeKey) {
|
|
92
|
+
return !edgeElMap.has(edgeKey) || edgeElMap.get(edgeKey).length === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Insert a dynamic SVG line element before circles (so edges appear behind nodes).
|
|
97
|
+
*/
|
|
98
|
+
function insertDynamicLine(x1, y1, x2, y2) {
|
|
99
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
100
|
+
line.setAttribute('x1', x1);
|
|
101
|
+
line.setAttribute('y1', y1);
|
|
102
|
+
line.setAttribute('x2', x2);
|
|
103
|
+
line.setAttribute('y2', y2);
|
|
104
|
+
line.setAttribute('stroke', DYNAMIC_EDGE_STROKE);
|
|
105
|
+
line.setAttribute('stroke-width', DYNAMIC_EDGE_STROKE_WIDTH);
|
|
106
|
+
line.classList.add('dn-inspector-dynamic-edge');
|
|
107
|
+
|
|
108
|
+
// Insert into the same parent as the first circle so it renders behind nodes.
|
|
109
|
+
// Circles may be nested in <g> groups, so we insert into the circle's parent.
|
|
110
|
+
const firstCircle = svgEl.querySelector('circle');
|
|
111
|
+
if (firstCircle && firstCircle.parentNode) {
|
|
112
|
+
firstCircle.parentNode.insertBefore(line, firstCircle);
|
|
113
|
+
} else {
|
|
114
|
+
svgEl.appendChild(line);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
allGraphEls.push(line);
|
|
118
|
+
originalStrokeWidths.set(line, DYNAMIC_EDGE_STROKE_WIDTH);
|
|
119
|
+
return line;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create dynamic SVG lines for a virtual edge.
|
|
124
|
+
*
|
|
125
|
+
* - If source and target are both static strings: draw one line between them.
|
|
126
|
+
* - If source or target (or both) are functions: find all nodes that reference
|
|
127
|
+
* this edge in their `node.edges` array, and draw lines from each such node
|
|
128
|
+
* to each pseudo-node (nodes in the SVG but not in structure.nodes).
|
|
129
|
+
*
|
|
130
|
+
* Returns the array of created line elements.
|
|
131
|
+
*/
|
|
132
|
+
function createDynamicEdges(edgeKey) {
|
|
133
|
+
if (dynamicEdgeEls.has(edgeKey)) return dynamicEdgeEls.get(edgeKey);
|
|
134
|
+
|
|
135
|
+
const edge = structure.edges[edgeKey];
|
|
136
|
+
if (!edge) return [];
|
|
137
|
+
const src = typeof edge.source === 'function' ? null : edge.source;
|
|
138
|
+
const tgt = typeof edge.target === 'function' ? null : edge.target;
|
|
139
|
+
|
|
140
|
+
const lines = [];
|
|
141
|
+
|
|
142
|
+
if (src && tgt) {
|
|
143
|
+
// Both endpoints are static strings
|
|
144
|
+
const srcEl = nodeElMap.get(String(src));
|
|
145
|
+
const tgtEl = nodeElMap.get(String(tgt));
|
|
146
|
+
if (srcEl && tgtEl) {
|
|
147
|
+
lines.push(insertDynamicLine(
|
|
148
|
+
srcEl.getAttribute('cx'), srcEl.getAttribute('cy'),
|
|
149
|
+
tgtEl.getAttribute('cx'), tgtEl.getAttribute('cy')
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
// At least one endpoint is a function — find connected nodes
|
|
154
|
+
// by scanning which nodes have this edgeKey in their edges array
|
|
155
|
+
const connectedNodeIds = [];
|
|
156
|
+
Object.keys(structure.nodes).forEach(nodeId => {
|
|
157
|
+
const node = structure.nodes[nodeId];
|
|
158
|
+
if (node.edges && node.edges.includes(edgeKey)) {
|
|
159
|
+
connectedNodeIds.push(nodeId);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (src) {
|
|
164
|
+
// Source is static, target is function: draw from each connected node to source
|
|
165
|
+
const srcEl = nodeElMap.get(String(src));
|
|
166
|
+
if (srcEl) {
|
|
167
|
+
connectedNodeIds.forEach(nodeId => {
|
|
168
|
+
if (nodeId === src) return;
|
|
169
|
+
const nodeEl = nodeElMap.get(String(nodeId));
|
|
170
|
+
if (nodeEl) {
|
|
171
|
+
lines.push(insertDynamicLine(
|
|
172
|
+
nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
|
|
173
|
+
srcEl.getAttribute('cx'), srcEl.getAttribute('cy')
|
|
174
|
+
));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
} else if (tgt) {
|
|
179
|
+
// Target is static, source is function: draw from each connected node to target
|
|
180
|
+
const tgtEl = nodeElMap.get(String(tgt));
|
|
181
|
+
if (tgtEl) {
|
|
182
|
+
connectedNodeIds.forEach(nodeId => {
|
|
183
|
+
if (nodeId === tgt) return;
|
|
184
|
+
const nodeEl = nodeElMap.get(String(nodeId));
|
|
185
|
+
if (nodeEl) {
|
|
186
|
+
lines.push(insertDynamicLine(
|
|
187
|
+
nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
|
|
188
|
+
tgtEl.getAttribute('cx'), tgtEl.getAttribute('cy')
|
|
189
|
+
));
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Both are functions: draw from each connected node to each pseudo-node
|
|
195
|
+
pseudoNodeIds.forEach(pseudoId => {
|
|
196
|
+
const pseudoEl = nodeElMap.get(pseudoId);
|
|
197
|
+
if (!pseudoEl) return;
|
|
198
|
+
connectedNodeIds.forEach(nodeId => {
|
|
199
|
+
if (nodeId === pseudoId) return;
|
|
200
|
+
const nodeEl = nodeElMap.get(String(nodeId));
|
|
201
|
+
if (nodeEl) {
|
|
202
|
+
lines.push(insertDynamicLine(
|
|
203
|
+
nodeEl.getAttribute('cx'), nodeEl.getAttribute('cy'),
|
|
204
|
+
pseudoEl.getAttribute('cx'), pseudoEl.getAttribute('cy')
|
|
205
|
+
));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (lines.length > 0) {
|
|
213
|
+
dynamicEdgeEls.set(edgeKey, lines);
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Remove all dynamic edge lines for an edge key from the SVG.
|
|
220
|
+
*/
|
|
221
|
+
function removeDynamicEdges(edgeKey) {
|
|
222
|
+
const lines = dynamicEdgeEls.get(edgeKey);
|
|
223
|
+
if (!lines) return;
|
|
224
|
+
lines.forEach(line => {
|
|
225
|
+
if (line.parentNode) line.parentNode.removeChild(line);
|
|
226
|
+
const idx = allGraphEls.indexOf(line);
|
|
227
|
+
if (idx !== -1) allGraphEls.splice(idx, 1);
|
|
228
|
+
originalStrokeWidths.delete(line);
|
|
229
|
+
});
|
|
230
|
+
dynamicEdgeEls.delete(edgeKey);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove all dynamic edges that are no longer checked or hovered.
|
|
235
|
+
*/
|
|
236
|
+
function cleanupDynamicEdges() {
|
|
237
|
+
const checkedEdgeKeys = new Set();
|
|
238
|
+
state.getChecked().forEach(({ type, id }) => {
|
|
239
|
+
if (type === 'edge') checkedEdgeKeys.add(id);
|
|
240
|
+
});
|
|
241
|
+
const hovered = state.hoveredItem;
|
|
242
|
+
const hoveredEdgeKey = hovered && hovered.type === 'edge' ? hovered.id : null;
|
|
243
|
+
|
|
244
|
+
// Iterate over a copy of keys since we mutate during iteration
|
|
245
|
+
[...dynamicEdgeEls.keys()].forEach(edgeKey => {
|
|
246
|
+
if (!checkedEdgeKeys.has(edgeKey) && edgeKey !== hoveredEdgeKey) {
|
|
247
|
+
removeDynamicEdges(edgeKey);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Ensure dynamic edges exist for all checked virtual edges.
|
|
254
|
+
*/
|
|
255
|
+
function ensureDynamicEdgesForChecked() {
|
|
256
|
+
state.getChecked().forEach(({ type, id }) => {
|
|
257
|
+
if (type === 'edge' && isVirtualEdge(id)) {
|
|
258
|
+
createDynamicEdges(id);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Resolve a checked/hovered item to its SVG elements.
|
|
265
|
+
* For virtual edges, creates dynamic lines on-the-fly.
|
|
266
|
+
*/
|
|
267
|
+
function resolveElements(type, id) {
|
|
268
|
+
if (type === 'node') {
|
|
269
|
+
const el = nodeElMap.get(String(id));
|
|
270
|
+
return el ? [el] : [];
|
|
271
|
+
}
|
|
272
|
+
if (type === 'edge') {
|
|
273
|
+
const existing = edgeElMap.get(id) || [];
|
|
274
|
+
if (existing.length > 0) return existing;
|
|
275
|
+
// Virtual edge: create dynamic lines
|
|
276
|
+
return createDynamicEdges(id);
|
|
277
|
+
}
|
|
278
|
+
if (type === 'rule') {
|
|
279
|
+
const els = [];
|
|
280
|
+
Object.keys(structure.edges).forEach(edgeKey => {
|
|
281
|
+
const edge = structure.edges[edgeKey];
|
|
282
|
+
if (edge.navigationRules && edge.navigationRules.includes(id)) {
|
|
283
|
+
const existing = edgeElMap.get(edgeKey) || [];
|
|
284
|
+
if (existing.length > 0) {
|
|
285
|
+
existing.forEach(el => els.push(el));
|
|
286
|
+
} else {
|
|
287
|
+
// Virtual edge under this rule
|
|
288
|
+
createDynamicEdges(edgeKey).forEach(el => els.push(el));
|
|
289
|
+
}
|
|
290
|
+
// Also include connected nodes for highlighting
|
|
291
|
+
const src = typeof edge.source === 'function' ? null : edge.source;
|
|
292
|
+
const tgt = typeof edge.target === 'function' ? null : edge.target;
|
|
293
|
+
if (src) { const el = nodeElMap.get(String(src)); if (el) els.push(el); }
|
|
294
|
+
if (tgt) { const el = nodeElMap.get(String(tgt)); if (el) els.push(el); }
|
|
295
|
+
// For function-based edges, include all nodes that reference this edge
|
|
296
|
+
if (!src || !tgt) {
|
|
297
|
+
Object.keys(structure.nodes).forEach(nodeId => {
|
|
298
|
+
const node = structure.nodes[nodeId];
|
|
299
|
+
if (node.edges && node.edges.includes(edgeKey)) {
|
|
300
|
+
const el = nodeElMap.get(String(nodeId));
|
|
301
|
+
if (el) els.push(el);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// Include pseudo-nodes too
|
|
305
|
+
pseudoNodeIds.forEach(pid => {
|
|
306
|
+
const el = nodeElMap.get(pid);
|
|
307
|
+
if (el) els.push(el);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
return [...new Set(els)];
|
|
313
|
+
}
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Collect all SVG elements for all currently-checked items.
|
|
319
|
+
*/
|
|
320
|
+
function getSelectedElements() {
|
|
321
|
+
const set = new Set();
|
|
322
|
+
state.getChecked().forEach(({ type, id }) => {
|
|
323
|
+
resolveElements(type, id).forEach(el => set.add(el));
|
|
324
|
+
});
|
|
325
|
+
return set;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Dim all graph elements except the given set (which stay full opacity).
|
|
330
|
+
*/
|
|
331
|
+
function dimAllExcept(keepSet) {
|
|
332
|
+
allGraphEls.forEach(el => {
|
|
333
|
+
el.style.opacity = keepSet.has(el) ? FULL_OPACITY : DIMMED_OPACITY;
|
|
334
|
+
el.style.transition = 'opacity 0.15s ease';
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Clear all inline opacity and stroke overrides.
|
|
340
|
+
*/
|
|
341
|
+
function clearAll() {
|
|
342
|
+
allGraphEls.forEach(el => {
|
|
343
|
+
el.style.opacity = FULL_OPACITY;
|
|
344
|
+
el.style.transition = '';
|
|
345
|
+
el.style.stroke = '';
|
|
346
|
+
el.style.strokeWidth = '';
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Apply selection state: dim everything except selected elements.
|
|
352
|
+
* Selected edges get thickened stroke and dark color.
|
|
353
|
+
*/
|
|
354
|
+
function applySelection(selectedEls) {
|
|
355
|
+
allGraphEls.forEach(el => {
|
|
356
|
+
el.style.transition = 'opacity 0.15s ease';
|
|
357
|
+
if (selectedEls.has(el)) {
|
|
358
|
+
el.style.opacity = FULL_OPACITY;
|
|
359
|
+
if (el.tagName === 'line' || el.tagName === 'path') {
|
|
360
|
+
el.style.stroke = SELECTED_EDGE_STROKE;
|
|
361
|
+
el.style.strokeWidth = SELECTED_EDGE_WIDTH;
|
|
362
|
+
} else if (el.tagName === 'circle') {
|
|
363
|
+
el.style.stroke = SELECTED_NODE_STROKE;
|
|
364
|
+
el.style.strokeWidth = SELECTED_NODE_STROKE_WIDTH;
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
el.style.opacity = DIMMED_OPACITY;
|
|
368
|
+
if (el.tagName === 'line' || el.tagName === 'path') {
|
|
369
|
+
el.style.stroke = '';
|
|
370
|
+
el.style.strokeWidth = '';
|
|
371
|
+
} else if (el.tagName === 'circle') {
|
|
372
|
+
el.style.stroke = '';
|
|
373
|
+
el.style.strokeWidth = '';
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return state.subscribe((changeType, payload) => {
|
|
380
|
+
if (changeType === 'check' || changeType === 'uncheck') {
|
|
381
|
+
// Ensure dynamic edges exist for newly checked virtual edges
|
|
382
|
+
ensureDynamicEdgesForChecked();
|
|
383
|
+
// Remove dynamic edges that are no longer needed
|
|
384
|
+
cleanupDynamicEdges();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const selectedEls = getSelectedElements();
|
|
388
|
+
const hasChecked = state.hasAnyChecked();
|
|
389
|
+
|
|
390
|
+
if (changeType === 'check' || changeType === 'uncheck') {
|
|
391
|
+
if (hasChecked) {
|
|
392
|
+
applySelection(selectedEls);
|
|
393
|
+
} else {
|
|
394
|
+
clearAll();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (changeType === 'hover') {
|
|
399
|
+
const { type, id } = payload;
|
|
400
|
+
const hoveredEls = new Set(resolveElements(type, id));
|
|
401
|
+
|
|
402
|
+
if (!hasChecked) {
|
|
403
|
+
// Nothing checked: dim everything except hovered
|
|
404
|
+
dimAllExcept(hoveredEls);
|
|
405
|
+
} else {
|
|
406
|
+
// Items are checked: keep selection dimming, but ensure hovered items are visible
|
|
407
|
+
allGraphEls.forEach(el => {
|
|
408
|
+
el.style.transition = 'opacity 0.15s ease';
|
|
409
|
+
if (hoveredEls.has(el) || selectedEls.has(el)) {
|
|
410
|
+
el.style.opacity = FULL_OPACITY;
|
|
411
|
+
} else {
|
|
412
|
+
el.style.opacity = DIMMED_OPACITY;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Thicken and darken hovered edges
|
|
418
|
+
hoveredEls.forEach(el => {
|
|
419
|
+
if (el.tagName === 'line' || el.tagName === 'path') {
|
|
420
|
+
el.style.strokeWidth = HOVER_STROKE_WIDTH;
|
|
421
|
+
el.style.stroke = SELECTED_EDGE_STROKE;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Move focus indicator to hovered node
|
|
426
|
+
if (type === 'node') {
|
|
427
|
+
const target = nodeElMap.get(String(id));
|
|
428
|
+
if (target && indicatorEl) {
|
|
429
|
+
indicatorEl.setAttribute('cx', target.getAttribute('cx'));
|
|
430
|
+
indicatorEl.setAttribute('cy', target.getAttribute('cy'));
|
|
431
|
+
indicatorEl.classList.remove('dn-inspector-hidden');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (changeType === 'unhover') {
|
|
437
|
+
// Remove dynamic edges that were only shown for hover
|
|
438
|
+
cleanupDynamicEdges();
|
|
439
|
+
|
|
440
|
+
// Restore stroke widths and colors
|
|
441
|
+
originalStrokeWidths.forEach((orig, el) => {
|
|
442
|
+
el.style.strokeWidth = '';
|
|
443
|
+
el.style.stroke = '';
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (hasChecked) {
|
|
447
|
+
applySelection(selectedEls);
|
|
448
|
+
} else {
|
|
449
|
+
clearAll();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (indicatorEl) {
|
|
453
|
+
indicatorEl.classList.add('dn-inspector-hidden');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
}
|