@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.
@@ -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
+ }