laminark 2.21.6
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/.claude-plugin/marketplace.json +15 -0
- package/README.md +182 -0
- package/package.json +63 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/dist/analysis/worker.d.ts +1 -0
- package/plugin/dist/analysis/worker.js +233 -0
- package/plugin/dist/analysis/worker.js.map +1 -0
- package/plugin/dist/config-t8LZeB-u.mjs +90 -0
- package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
- package/plugin/dist/hooks/handler.d.ts +284 -0
- package/plugin/dist/hooks/handler.d.ts.map +1 -0
- package/plugin/dist/hooks/handler.js +2125 -0
- package/plugin/dist/hooks/handler.js.map +1 -0
- package/plugin/dist/index.d.ts +445 -0
- package/plugin/dist/index.d.ts.map +1 -0
- package/plugin/dist/index.js +5831 -0
- package/plugin/dist/index.js.map +1 -0
- package/plugin/dist/observations-Ch0nc47i.d.mts +170 -0
- package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
- package/plugin/dist/tool-registry-CZ3mJ4iR.mjs +2655 -0
- package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
- package/plugin/hooks/hooks.json +78 -0
- package/plugin/scripts/README.md +47 -0
- package/plugin/scripts/bump-version.sh +44 -0
- package/plugin/scripts/ensure-deps.sh +12 -0
- package/plugin/scripts/install.sh +63 -0
- package/plugin/scripts/local-install.sh +103 -0
- package/plugin/scripts/setup-tmpdir.sh +65 -0
- package/plugin/scripts/uninstall.sh +95 -0
- package/plugin/scripts/update.sh +88 -0
- package/plugin/scripts/verify-install.sh +43 -0
- package/plugin/ui/activity.js +185 -0
- package/plugin/ui/app.js +1642 -0
- package/plugin/ui/graph.js +2333 -0
- package/plugin/ui/help.js +228 -0
- package/plugin/ui/index.html +492 -0
- package/plugin/ui/settings.js +650 -0
- package/plugin/ui/styles.css +2910 -0
- package/plugin/ui/timeline.js +652 -0
|
@@ -0,0 +1,2333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laminark Knowledge Graph Visualization (D3.js)
|
|
3
|
+
*
|
|
4
|
+
* Renders the knowledge graph as an interactive D3.js force-directed SVG.
|
|
5
|
+
* Entities appear as colored/shaped nodes by type. Relationships render as
|
|
6
|
+
* labeled directed edges. Level-of-detail reduces visual complexity at low
|
|
7
|
+
* zoom levels. Force-collide prevents the hairball problem.
|
|
8
|
+
*
|
|
9
|
+
* @module graph
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Debounce utility
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function debounce(fn, ms) {
|
|
17
|
+
var timer;
|
|
18
|
+
return function () { clearTimeout(timer); timer = setTimeout(fn, ms); };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Entity type visual map
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const ENTITY_STYLES = {
|
|
26
|
+
Project: { color: '#58a6ff', shape: 'round-rectangle' },
|
|
27
|
+
File: { color: '#3fb950', shape: 'rectangle' },
|
|
28
|
+
Decision: { color: '#d29922', shape: 'diamond' },
|
|
29
|
+
Problem: { color: '#f85149', shape: 'triangle' },
|
|
30
|
+
Solution: { color: '#a371f7', shape: 'star' },
|
|
31
|
+
Reference: { color: '#f0883e', shape: 'hexagon' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Waypoint type colors for path overlay
|
|
35
|
+
var WAYPOINT_TYPE_COLORS = {
|
|
36
|
+
error: '#f85149',
|
|
37
|
+
attempt: '#d29922',
|
|
38
|
+
failure: '#f0883e',
|
|
39
|
+
success: '#3fb950',
|
|
40
|
+
pivot: '#a371f7',
|
|
41
|
+
revert: '#79c0ff',
|
|
42
|
+
discovery: '#58a6ff',
|
|
43
|
+
resolution: '#3fb950',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Relationship type colors for edge coloring
|
|
47
|
+
var EDGE_TYPE_COLORS = {
|
|
48
|
+
related_to: '#8b949e',
|
|
49
|
+
solved_by: '#3fb950',
|
|
50
|
+
caused_by: '#f85149',
|
|
51
|
+
modifies: '#58a6ff',
|
|
52
|
+
informed_by: '#d2a8ff',
|
|
53
|
+
references: '#f0883e',
|
|
54
|
+
verified_by: '#d29922',
|
|
55
|
+
preceded_by: '#79c0ff',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// D3 symbol generators per entity type
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
var nodeSizeScale = d3.scaleSqrt().domain([0, 50]).range([15, 40]).clamp(true);
|
|
63
|
+
var degreeSizeScale = d3.scaleSqrt().domain([0, 20]).range([0, 20]).clamp(true);
|
|
64
|
+
|
|
65
|
+
function getNodeSize(d) {
|
|
66
|
+
var base = nodeSizeScale(d.observationCount || 0);
|
|
67
|
+
var degreeBonus = degreeSizeScale(d._degree || 0);
|
|
68
|
+
return base + degreeBonus;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Custom hexagon symbol
|
|
72
|
+
var hexagonSymbol = {
|
|
73
|
+
draw: function (context, size) {
|
|
74
|
+
var r = Math.sqrt(size / (1.5 * Math.sqrt(3)));
|
|
75
|
+
for (var i = 0; i < 6; i++) {
|
|
76
|
+
var angle = (Math.PI / 3) * i - Math.PI / 2;
|
|
77
|
+
var x = r * Math.cos(angle);
|
|
78
|
+
var y = r * Math.sin(angle);
|
|
79
|
+
if (i === 0) context.moveTo(x, y);
|
|
80
|
+
else context.lineTo(x, y);
|
|
81
|
+
}
|
|
82
|
+
context.closePath();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Custom rounded rectangle symbol
|
|
87
|
+
var roundRectSymbol = {
|
|
88
|
+
draw: function (context, size) {
|
|
89
|
+
var s = Math.sqrt(size) * 0.9;
|
|
90
|
+
var r = s * 0.2;
|
|
91
|
+
var hs = s / 2;
|
|
92
|
+
context.moveTo(-hs + r, -hs);
|
|
93
|
+
context.lineTo(hs - r, -hs);
|
|
94
|
+
context.quadraticCurveTo(hs, -hs, hs, -hs + r);
|
|
95
|
+
context.lineTo(hs, hs - r);
|
|
96
|
+
context.quadraticCurveTo(hs, hs, hs - r, hs);
|
|
97
|
+
context.lineTo(-hs + r, hs);
|
|
98
|
+
context.quadraticCurveTo(-hs, hs, -hs, hs - r);
|
|
99
|
+
context.lineTo(-hs, -hs + r);
|
|
100
|
+
context.quadraticCurveTo(-hs, -hs, -hs + r, -hs);
|
|
101
|
+
context.closePath();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function getSymbolType(type) {
|
|
106
|
+
switch (type) {
|
|
107
|
+
case 'Project': return roundRectSymbol;
|
|
108
|
+
case 'File': return d3.symbolSquare;
|
|
109
|
+
case 'Decision': return d3.symbolDiamond;
|
|
110
|
+
case 'Problem': return d3.symbolTriangle;
|
|
111
|
+
case 'Solution': return d3.symbolStar;
|
|
112
|
+
case 'Reference': return hexagonSymbol;
|
|
113
|
+
default: return d3.symbolCircle;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getSymbolPath(type, size) {
|
|
118
|
+
var area = size * size * 2.5;
|
|
119
|
+
return d3.symbol().type(getSymbolType(type)).size(area)();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Module state
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
var svg = null;
|
|
127
|
+
var svgG = null; // Main group that receives zoom transforms
|
|
128
|
+
var simulation = null;
|
|
129
|
+
var zoomBehavior = null;
|
|
130
|
+
var containerEl = null;
|
|
131
|
+
|
|
132
|
+
// Data arrays (the simulation operates on these directly)
|
|
133
|
+
var nodeData = [];
|
|
134
|
+
var edgeData = [];
|
|
135
|
+
|
|
136
|
+
// D3 selections
|
|
137
|
+
var edgeSelection = null;
|
|
138
|
+
var edgeLabelSelection = null;
|
|
139
|
+
var nodeGroupSelection = null;
|
|
140
|
+
var nodeLabelSelection = null;
|
|
141
|
+
|
|
142
|
+
// Layer groups
|
|
143
|
+
var edgesGroup = null;
|
|
144
|
+
var edgeLabelsGroup = null;
|
|
145
|
+
var nodesGroup = null;
|
|
146
|
+
var nodeLabelsGroup = null;
|
|
147
|
+
|
|
148
|
+
var activeEntityTypes = new Set(Object.keys(ENTITY_STYLES));
|
|
149
|
+
|
|
150
|
+
// Level-of-detail state
|
|
151
|
+
var currentLodLevel = 0;
|
|
152
|
+
var currentZoom = 1;
|
|
153
|
+
|
|
154
|
+
// Performance stats overlay state
|
|
155
|
+
var perfOverlayVisible = false;
|
|
156
|
+
var perfOverlayEl = null;
|
|
157
|
+
var perfFrameCount = 0;
|
|
158
|
+
var perfLastFpsTime = 0;
|
|
159
|
+
var perfFps = 0;
|
|
160
|
+
var perfRafId = null;
|
|
161
|
+
|
|
162
|
+
// Focus mode state
|
|
163
|
+
var focusStack = [];
|
|
164
|
+
var isFocusMode = false;
|
|
165
|
+
var cachedFullData = null;
|
|
166
|
+
|
|
167
|
+
// Current layout setting
|
|
168
|
+
var currentLayout = localStorage.getItem('laminark-layout') || 'clustered';
|
|
169
|
+
var isStaticLayout = false; // True when using hierarchical/concentric (no simulation)
|
|
170
|
+
|
|
171
|
+
// Batch update queue for SSE events
|
|
172
|
+
var batchQueue = [];
|
|
173
|
+
var batchFlushTimer = null;
|
|
174
|
+
var BATCH_DELAY_MS = 200;
|
|
175
|
+
|
|
176
|
+
// Context menu state
|
|
177
|
+
var contextMenuEl = null;
|
|
178
|
+
var contextMenuVisible = false;
|
|
179
|
+
var contextMenuTargetNode = null;
|
|
180
|
+
|
|
181
|
+
// Time range state
|
|
182
|
+
var activeTimeRange = { from: null, to: null };
|
|
183
|
+
|
|
184
|
+
// Selected node
|
|
185
|
+
var selectedNodeId = null;
|
|
186
|
+
|
|
187
|
+
// Tooltip element
|
|
188
|
+
var tooltipEl = null;
|
|
189
|
+
|
|
190
|
+
// Community data
|
|
191
|
+
var communityNodeMap = {};
|
|
192
|
+
var communityColorMap = {};
|
|
193
|
+
|
|
194
|
+
// Edge label visibility (per-type)
|
|
195
|
+
var edgeLabelsVisible = localStorage.getItem('laminark-edge-labels') !== 'false';
|
|
196
|
+
var hiddenEdgeLabelTypes = new Set(
|
|
197
|
+
JSON.parse(localStorage.getItem('laminark-hidden-edge-types') || '[]')
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Path overlay state
|
|
201
|
+
var pathOverlayGroup = null;
|
|
202
|
+
var pathOverlayVisible = localStorage.getItem('laminark-path-overlay') !== 'false';
|
|
203
|
+
var pathData = []; // Array of { id, status, triggerSummary, waypoints: [{id, type, summary, nodeId?}] }
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// initGraph
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function initGraph(containerId) {
|
|
210
|
+
containerEl = document.getElementById(containerId);
|
|
211
|
+
if (!containerEl) {
|
|
212
|
+
console.error('[laminark:graph] Container not found:', containerId);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Clear any previous content
|
|
217
|
+
containerEl.innerHTML = '';
|
|
218
|
+
|
|
219
|
+
var width = containerEl.clientWidth || 800;
|
|
220
|
+
var height = containerEl.clientHeight || 600;
|
|
221
|
+
|
|
222
|
+
svg = d3.select(containerEl)
|
|
223
|
+
.append('svg')
|
|
224
|
+
.attr('width', '100%')
|
|
225
|
+
.attr('height', '100%')
|
|
226
|
+
.attr('viewBox', [0, 0, width, height].join(' '))
|
|
227
|
+
.attr('class', 'graph-svg');
|
|
228
|
+
|
|
229
|
+
// Arrow marker definitions (one per edge type color)
|
|
230
|
+
var defs = svg.append('defs');
|
|
231
|
+
var markerColors = {};
|
|
232
|
+
Object.keys(EDGE_TYPE_COLORS).forEach(function (k) { markerColors[k] = EDGE_TYPE_COLORS[k]; });
|
|
233
|
+
markerColors['default'] = '#8b949e';
|
|
234
|
+
|
|
235
|
+
Object.keys(markerColors).forEach(function (key) {
|
|
236
|
+
defs.append('marker')
|
|
237
|
+
.attr('id', 'arrow-' + key)
|
|
238
|
+
.attr('viewBox', '0 -5 10 10')
|
|
239
|
+
.attr('refX', 10)
|
|
240
|
+
.attr('refY', 0)
|
|
241
|
+
.attr('markerWidth', 8)
|
|
242
|
+
.attr('markerHeight', 8)
|
|
243
|
+
.attr('orient', 'auto')
|
|
244
|
+
.append('path')
|
|
245
|
+
.attr('d', 'M0,-4L10,0L0,4')
|
|
246
|
+
.attr('fill', markerColors[key]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Main zoom group
|
|
250
|
+
svgG = svg.append('g').attr('class', 'graph-zoom-group');
|
|
251
|
+
|
|
252
|
+
// Layer groups in paint order (back to front)
|
|
253
|
+
edgesGroup = svgG.append('g').attr('class', 'edges-group');
|
|
254
|
+
edgeLabelsGroup = svgG.append('g').attr('class', 'edge-labels-group');
|
|
255
|
+
pathOverlayGroup = svgG.append('g').attr('class', 'path-overlay-group');
|
|
256
|
+
nodesGroup = svgG.append('g').attr('class', 'nodes-group');
|
|
257
|
+
nodeLabelsGroup = svgG.append('g').attr('class', 'node-labels-group');
|
|
258
|
+
|
|
259
|
+
// Zoom behavior
|
|
260
|
+
zoomBehavior = d3.zoom()
|
|
261
|
+
.scaleExtent([0.1, 3.0])
|
|
262
|
+
.on('zoom', function (event) {
|
|
263
|
+
svgG.attr('transform', event.transform);
|
|
264
|
+
currentZoom = event.transform.k;
|
|
265
|
+
updateLevelOfDetail();
|
|
266
|
+
renderPathOverlay();
|
|
267
|
+
});
|
|
268
|
+
svg.call(zoomBehavior);
|
|
269
|
+
|
|
270
|
+
// Background click: deselect + hide detail panel
|
|
271
|
+
svg.on('click', function (event) {
|
|
272
|
+
if (event.target === svg.node() || event.target.closest('.graph-zoom-group') === svgG.node() && !event.target.closest('.node-group')) {
|
|
273
|
+
hideDetailPanel();
|
|
274
|
+
selectedNodeId = null;
|
|
275
|
+
if (nodesGroup) nodesGroup.selectAll('.node-group').classed('selected', false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Right-click on background
|
|
280
|
+
svg.on('contextmenu', function (event) {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
// Check if click is on a node
|
|
283
|
+
var nodeGroup = event.target.closest('.node-group');
|
|
284
|
+
if (nodeGroup) return; // Handled by node's own contextmenu handler
|
|
285
|
+
|
|
286
|
+
contextMenuTargetNode = null;
|
|
287
|
+
var items = [
|
|
288
|
+
{ type: 'header', label: 'Filter' },
|
|
289
|
+
{ type: 'item', label: 'Reset filters (show all)', action: 'reset-filters' },
|
|
290
|
+
{ type: 'divider' },
|
|
291
|
+
{ type: 'header', label: 'Arrange' },
|
|
292
|
+
{ type: 'item', label: 'Re-layout graph', action: 'relayout' },
|
|
293
|
+
{ type: 'item', label: 'Fit to view', action: 'fit' },
|
|
294
|
+
];
|
|
295
|
+
showContextMenu(event.pageX, event.pageY, items);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Performance stats keyboard shortcut: Ctrl+Shift+P
|
|
299
|
+
document.addEventListener('keydown', function (e) {
|
|
300
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
togglePerfOverlay();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
initContextMenu();
|
|
307
|
+
initEdgeLabelToggle();
|
|
308
|
+
initPathOverlayToggle();
|
|
309
|
+
|
|
310
|
+
// Create tooltip element
|
|
311
|
+
tooltipEl = document.createElement('div');
|
|
312
|
+
tooltipEl.className = 'graph-tooltip hidden';
|
|
313
|
+
containerEl.appendChild(tooltipEl);
|
|
314
|
+
|
|
315
|
+
console.log('[laminark:graph] D3 initialized with force simulation');
|
|
316
|
+
return svg;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Resolve edge source/target from string IDs to node object references
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function resolveEdgeReferences() {
|
|
324
|
+
var nodeMap = {};
|
|
325
|
+
nodeData.forEach(function (d) { nodeMap[d.id] = d; });
|
|
326
|
+
edgeData.forEach(function (d) {
|
|
327
|
+
if (typeof d.source === 'string') d.source = nodeMap[d.source] || d.source;
|
|
328
|
+
if (typeof d.target === 'string') d.target = nodeMap[d.target] || d.target;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Force simulation setup
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function computeDegrees() {
|
|
337
|
+
var degreeMap = {};
|
|
338
|
+
edgeData.forEach(function (d) {
|
|
339
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
340
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
341
|
+
degreeMap[srcId] = (degreeMap[srcId] || 0) + 1;
|
|
342
|
+
degreeMap[tgtId] = (degreeMap[tgtId] || 0) + 1;
|
|
343
|
+
});
|
|
344
|
+
nodeData.forEach(function (d) {
|
|
345
|
+
d._degree = degreeMap[d.id] || 0;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function createSimulation() {
|
|
350
|
+
if (simulation) simulation.stop();
|
|
351
|
+
|
|
352
|
+
computeDegrees();
|
|
353
|
+
|
|
354
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
355
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
356
|
+
|
|
357
|
+
var visibleEdges = edgeData.filter(function (d) {
|
|
358
|
+
return !d.source.hidden && !d.target.hidden;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Degree-scaled repulsion: more links = stronger push away
|
|
362
|
+
var chargeScale = d3.scaleLinear().domain([0, 20]).range([-200, -1200]).clamp(true);
|
|
363
|
+
|
|
364
|
+
simulation = d3.forceSimulation(nodeData.filter(function (d) { return !d.hidden; }))
|
|
365
|
+
.force('link', d3.forceLink(visibleEdges)
|
|
366
|
+
.id(function (d) { return d.id; })
|
|
367
|
+
.distance(function (d) {
|
|
368
|
+
// Longer links between high-degree nodes so they spread out
|
|
369
|
+
var srcDeg = (typeof d.source === 'object' ? d.source._degree : 0) || 0;
|
|
370
|
+
var tgtDeg = (typeof d.target === 'object' ? d.target._degree : 0) || 0;
|
|
371
|
+
return 100 + Math.sqrt(srcDeg + tgtDeg) * 20;
|
|
372
|
+
}))
|
|
373
|
+
.force('charge', d3.forceManyBody().strength(function (d) {
|
|
374
|
+
return chargeScale(d._degree || 0);
|
|
375
|
+
}))
|
|
376
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
377
|
+
.force('collide', d3.forceCollide().radius(function (d) {
|
|
378
|
+
return getNodeSize(d) + 12;
|
|
379
|
+
}).strength(0.8))
|
|
380
|
+
.force('x', d3.forceX(width / 2).strength(0.03))
|
|
381
|
+
.force('y', d3.forceY(height / 2).strength(0.03))
|
|
382
|
+
.alphaDecay(0.02)
|
|
383
|
+
.velocityDecay(0.35)
|
|
384
|
+
.on('tick', ticked);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function ticked() {
|
|
388
|
+
if (edgeSelection) {
|
|
389
|
+
edgeSelection
|
|
390
|
+
.attr('x1', function (d) { return (d.source && d.source.x) || 0; })
|
|
391
|
+
.attr('y1', function (d) { return (d.source && d.source.y) || 0; })
|
|
392
|
+
.attr('x2', function (d) {
|
|
393
|
+
if (!d.source || !d.target || d.source.x == null || d.target.x == null) return 0;
|
|
394
|
+
return shortenLine(d.source, d.target, getNodeSize(d.target) + 5).x;
|
|
395
|
+
})
|
|
396
|
+
.attr('y2', function (d) {
|
|
397
|
+
if (!d.source || !d.target || d.source.y == null || d.target.y == null) return 0;
|
|
398
|
+
return shortenLine(d.source, d.target, getNodeSize(d.target) + 5).y;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (edgeLabelSelection) {
|
|
403
|
+
edgeLabelSelection
|
|
404
|
+
.attr('x', function (d) { var sx = (d.source && d.source.x) || 0; var tx = (d.target && d.target.x) || 0; return (sx + tx) / 2; })
|
|
405
|
+
.attr('y', function (d) { var sy = (d.source && d.source.y) || 0; var ty = (d.target && d.target.y) || 0; return (sy + ty) / 2; });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (nodeGroupSelection) {
|
|
409
|
+
nodeGroupSelection.attr('transform', function (d) {
|
|
410
|
+
return 'translate(' + (d.x || 0) + ',' + (d.y || 0) + ')';
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (nodeLabelSelection) {
|
|
415
|
+
nodeLabelSelection
|
|
416
|
+
.attr('x', function (d) { return d.x || 0; })
|
|
417
|
+
.attr('y', function (d) { return (d.y || 0) + getNodeSize(d) + 12; });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Shorten line endpoint to stop at node boundary
|
|
422
|
+
function shortenLine(source, target, offset) {
|
|
423
|
+
var sx = source.x || 0, sy = source.y || 0;
|
|
424
|
+
var tx = target.x || 0, ty = target.y || 0;
|
|
425
|
+
var dx = tx - sx;
|
|
426
|
+
var dy = ty - sy;
|
|
427
|
+
var dist = Math.sqrt(dx * dx + dy * dy);
|
|
428
|
+
if (dist === 0) return { x: tx, y: ty };
|
|
429
|
+
var ratio = (dist - offset) / dist;
|
|
430
|
+
return {
|
|
431
|
+
x: sx + dx * ratio,
|
|
432
|
+
y: sy + dy * ratio,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// renderGraph - D3 data join
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
function renderGraph() {
|
|
441
|
+
if (!svg) return;
|
|
442
|
+
computeDegrees();
|
|
443
|
+
|
|
444
|
+
var visibleNodes = nodeData.filter(function (d) { return !d.hidden; });
|
|
445
|
+
var visibleNodeIds = new Set(visibleNodes.map(function (d) { return d.id; }));
|
|
446
|
+
var visibleEdges = edgeData.filter(function (d) {
|
|
447
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
448
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
449
|
+
return visibleNodeIds.has(srcId) && visibleNodeIds.has(tgtId);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// --- Edges ---
|
|
453
|
+
edgeSelection = edgesGroup.selectAll('line.edge')
|
|
454
|
+
.data(visibleEdges, function (d) { return d.id; });
|
|
455
|
+
edgeSelection.exit().remove();
|
|
456
|
+
edgeSelection = edgeSelection.enter()
|
|
457
|
+
.append('line')
|
|
458
|
+
.attr('class', 'edge')
|
|
459
|
+
.merge(edgeSelection);
|
|
460
|
+
edgeSelection
|
|
461
|
+
.attr('stroke', function (d) { return EDGE_TYPE_COLORS[d.type] || '#8b949e'; })
|
|
462
|
+
.attr('marker-end', function (d) {
|
|
463
|
+
var key = EDGE_TYPE_COLORS[d.type] ? d.type : 'default';
|
|
464
|
+
return 'url(#arrow-' + key + ')';
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// --- Edge labels ---
|
|
468
|
+
edgeLabelSelection = edgeLabelsGroup.selectAll('text.edge-label')
|
|
469
|
+
.data(visibleEdges, function (d) { return d.id; });
|
|
470
|
+
edgeLabelSelection.exit().remove();
|
|
471
|
+
edgeLabelSelection = edgeLabelSelection.enter()
|
|
472
|
+
.append('text')
|
|
473
|
+
.attr('class', 'edge-label')
|
|
474
|
+
.merge(edgeLabelSelection);
|
|
475
|
+
edgeLabelSelection
|
|
476
|
+
.text(function (d) { return d.type; });
|
|
477
|
+
|
|
478
|
+
// --- Node groups ---
|
|
479
|
+
nodeGroupSelection = nodesGroup.selectAll('g.node-group')
|
|
480
|
+
.data(visibleNodes, function (d) { return d.id; });
|
|
481
|
+
nodeGroupSelection.exit().remove();
|
|
482
|
+
var nodeEnter = nodeGroupSelection.enter()
|
|
483
|
+
.append('g')
|
|
484
|
+
.attr('class', 'node-group')
|
|
485
|
+
.call(d3.drag()
|
|
486
|
+
.on('start', dragStarted)
|
|
487
|
+
.on('drag', dragged)
|
|
488
|
+
.on('end', dragEnded));
|
|
489
|
+
|
|
490
|
+
nodeEnter.append('path').attr('class', 'node-shape');
|
|
491
|
+
nodeEnter.append('text').attr('class', 'node-degree-label');
|
|
492
|
+
|
|
493
|
+
nodeGroupSelection = nodeEnter.merge(nodeGroupSelection);
|
|
494
|
+
|
|
495
|
+
// Update shapes and colors
|
|
496
|
+
nodeGroupSelection.select('path.node-shape')
|
|
497
|
+
.attr('d', function (d) { return getSymbolPath(d.type, getNodeSize(d)); })
|
|
498
|
+
.attr('fill', function (d) {
|
|
499
|
+
if (communityColorMap[d.id]) return communityColorMap[d.id];
|
|
500
|
+
return ENTITY_STYLES[d.type] ? ENTITY_STYLES[d.type].color : '#8b949e';
|
|
501
|
+
})
|
|
502
|
+
.attr('stroke', 'none');
|
|
503
|
+
|
|
504
|
+
// Degree count centered in node
|
|
505
|
+
nodeGroupSelection.select('text.node-degree-label')
|
|
506
|
+
.text(function (d) { return d._degree || ''; })
|
|
507
|
+
.attr('text-anchor', 'middle')
|
|
508
|
+
.attr('dominant-baseline', 'central')
|
|
509
|
+
.attr('font-size', function (d) { return Math.max(9, getNodeSize(d) * 0.55) + 'px'; })
|
|
510
|
+
.attr('fill', '#fff')
|
|
511
|
+
.attr('font-weight', '700')
|
|
512
|
+
.attr('pointer-events', 'none');
|
|
513
|
+
|
|
514
|
+
// Update selection state
|
|
515
|
+
nodeGroupSelection.classed('selected', function (d) { return d.id === selectedNodeId; });
|
|
516
|
+
nodeGroupSelection.classed('focus-root', function (d) {
|
|
517
|
+
return isFocusMode && focusStack.length > 0 && focusStack[focusStack.length - 1].nodeId === d.id;
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Node interactions
|
|
521
|
+
nodeGroupSelection
|
|
522
|
+
.on('click', function (event, d) {
|
|
523
|
+
event.stopPropagation();
|
|
524
|
+
handleNodeClick(d);
|
|
525
|
+
})
|
|
526
|
+
.on('dblclick', function (event, d) {
|
|
527
|
+
event.stopPropagation();
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
enterFocusMode(d.id, d.label);
|
|
530
|
+
})
|
|
531
|
+
.on('contextmenu', function (event, d) {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
event.stopPropagation();
|
|
534
|
+
handleNodeContextMenu(event, d);
|
|
535
|
+
})
|
|
536
|
+
.on('mouseenter', function (event, d) {
|
|
537
|
+
showTooltip(event, d);
|
|
538
|
+
})
|
|
539
|
+
.on('mousemove', function (event) {
|
|
540
|
+
moveTooltip(event);
|
|
541
|
+
})
|
|
542
|
+
.on('mouseleave', function () {
|
|
543
|
+
hideTooltip();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// --- Node labels ---
|
|
547
|
+
nodeLabelSelection = nodeLabelsGroup.selectAll('text.node-label')
|
|
548
|
+
.data(visibleNodes, function (d) { return d.id; });
|
|
549
|
+
nodeLabelSelection.exit().remove();
|
|
550
|
+
nodeLabelSelection = nodeLabelSelection.enter()
|
|
551
|
+
.append('text')
|
|
552
|
+
.attr('class', 'node-label')
|
|
553
|
+
.merge(nodeLabelSelection);
|
|
554
|
+
nodeLabelSelection
|
|
555
|
+
.text(function (d) {
|
|
556
|
+
var label = d.label || '';
|
|
557
|
+
return label.length > 24 ? label.substring(0, 22) + '...' : label;
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Restart simulation only for force-directed layouts
|
|
561
|
+
if (!isStaticLayout) {
|
|
562
|
+
createSimulation();
|
|
563
|
+
} else {
|
|
564
|
+
// For static layouts, resolve edge references and position elements
|
|
565
|
+
resolveEdgeReferences();
|
|
566
|
+
ticked();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
updateLevelOfDetail();
|
|
570
|
+
updateGraphStatsFromData();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Drag handlers
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
function dragStarted(event, d) {
|
|
578
|
+
if (!event.active && simulation) simulation.alphaTarget(0.3).restart();
|
|
579
|
+
d.fx = d.x;
|
|
580
|
+
d.fy = d.y;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function dragged(event, d) {
|
|
584
|
+
d.fx = event.x;
|
|
585
|
+
d.fy = event.y;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function dragEnded(event, d) {
|
|
589
|
+
if (!event.active && simulation) simulation.alphaTarget(0);
|
|
590
|
+
// Keep pinned: d.fx and d.fy remain set
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Node interaction handlers
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
async function handleNodeClick(d) {
|
|
598
|
+
selectedNodeId = d.id;
|
|
599
|
+
if (nodesGroup) {
|
|
600
|
+
nodesGroup.selectAll('.node-group').classed('selected', function (n) { return n.id === d.id; });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (window.laminarkApp && window.laminarkApp.fetchNodeDetails) {
|
|
604
|
+
var details = await window.laminarkApp.fetchNodeDetails(d.id);
|
|
605
|
+
if (details && window.laminarkApp.showNodeDetails) {
|
|
606
|
+
window.laminarkApp.showNodeDetails(details);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function handleNodeContextMenu(event, d) {
|
|
612
|
+
contextMenuTargetNode = { id: d.id, label: d.label, type: d.type };
|
|
613
|
+
|
|
614
|
+
var items = [
|
|
615
|
+
{ type: 'header', label: 'Filter' },
|
|
616
|
+
{ type: 'item', label: 'This type only (' + d.type + ')',
|
|
617
|
+
action: 'filter-type:' + d.type,
|
|
618
|
+
color: ENTITY_STYLES[d.type] ? ENTITY_STYLES[d.type].color : null },
|
|
619
|
+
{ type: 'item', label: 'Focus on this node', action: 'focus' },
|
|
620
|
+
{ type: 'divider' },
|
|
621
|
+
{ type: 'header', label: 'Show Linked' },
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
Object.keys(ENTITY_STYLES).forEach(function (t) {
|
|
625
|
+
if (t !== d.type) {
|
|
626
|
+
items.push({
|
|
627
|
+
type: 'item',
|
|
628
|
+
label: t,
|
|
629
|
+
action: 'show-linked:' + t,
|
|
630
|
+
color: ENTITY_STYLES[t].color,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
items.push({ type: 'divider' });
|
|
636
|
+
items.push({ type: 'header', label: 'Arrange' });
|
|
637
|
+
items.push({ type: 'item', label: 'Re-layout graph', action: 'relayout' });
|
|
638
|
+
items.push({ type: 'item', label: 'Fit to view', action: 'fit' });
|
|
639
|
+
|
|
640
|
+
showContextMenu(event.pageX, event.pageY, items);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// loadGraphData
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
async function loadGraphData(filters) {
|
|
648
|
+
if (!svg) {
|
|
649
|
+
console.error('[laminark:graph] D3 not initialized');
|
|
650
|
+
return { nodeCount: 0, edgeCount: 0 };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Don't reload full graph data while in focus mode — it would
|
|
654
|
+
// replace the neighborhood data and corrupt breadcrumbs/state.
|
|
655
|
+
// SSE reconnects and tab switches should not interrupt focus.
|
|
656
|
+
if (isFocusMode) {
|
|
657
|
+
console.log('[laminark:graph] Skipping loadGraphData (focus mode active)');
|
|
658
|
+
return { nodeCount: nodeData.length, edgeCount: edgeData.length };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
var data;
|
|
662
|
+
if (window.laminarkApp && window.laminarkApp.fetchGraphData) {
|
|
663
|
+
data = await window.laminarkApp.fetchGraphData(filters);
|
|
664
|
+
} else {
|
|
665
|
+
var params = new URLSearchParams();
|
|
666
|
+
if (filters && filters.type) params.set('type', filters.type);
|
|
667
|
+
if (filters && filters.since) params.set('since', filters.since);
|
|
668
|
+
if (filters && filters.until) params.set('until', filters.until);
|
|
669
|
+
var url = '/api/graph' + (params.toString() ? '?' + params.toString() : '');
|
|
670
|
+
try {
|
|
671
|
+
var res = await fetch(url);
|
|
672
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
673
|
+
data = await res.json();
|
|
674
|
+
} catch (err) {
|
|
675
|
+
console.error('[laminark:graph] Failed to fetch graph data:', err);
|
|
676
|
+
data = { nodes: [], edges: [] };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (!data.nodes.length && !data.edges.length) {
|
|
681
|
+
nodeData = [];
|
|
682
|
+
edgeData = [];
|
|
683
|
+
renderGraph();
|
|
684
|
+
updateGraphStats(0, 0);
|
|
685
|
+
showEmptyState();
|
|
686
|
+
return { nodeCount: 0, edgeCount: 0 };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
hideEmptyState();
|
|
690
|
+
|
|
691
|
+
// Build data arrays
|
|
692
|
+
nodeData = data.nodes.map(function (node) {
|
|
693
|
+
return {
|
|
694
|
+
id: node.id,
|
|
695
|
+
label: node.label,
|
|
696
|
+
type: node.type,
|
|
697
|
+
observationCount: node.observationCount || 0,
|
|
698
|
+
createdAt: node.createdAt,
|
|
699
|
+
hidden: false,
|
|
700
|
+
};
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
edgeData = data.edges.map(function (edge) {
|
|
704
|
+
return {
|
|
705
|
+
id: edge.id,
|
|
706
|
+
source: edge.source,
|
|
707
|
+
target: edge.target,
|
|
708
|
+
type: edge.type,
|
|
709
|
+
label: edge.label || edge.type,
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// For static layouts, re-apply layout positioning after fresh data load
|
|
714
|
+
if (isStaticLayout) {
|
|
715
|
+
// Reset to force-directed first so renderGraph creates a simulation
|
|
716
|
+
isStaticLayout = false;
|
|
717
|
+
renderGraph();
|
|
718
|
+
// Re-apply the current static layout (which sets isStaticLayout back to true)
|
|
719
|
+
if (currentLayout === 'hierarchical') {
|
|
720
|
+
setTimeout(function () { applyHierarchicalLayout(); }, 100);
|
|
721
|
+
} else if (currentLayout === 'concentric') {
|
|
722
|
+
setTimeout(function () { applyConcentricLayout(); }, 100);
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
renderGraph();
|
|
726
|
+
// Fit to view after simulation settles a bit
|
|
727
|
+
setTimeout(function () { fitToView(); }, 800);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Load path overlay after graph data
|
|
731
|
+
if (pathOverlayVisible) {
|
|
732
|
+
setTimeout(function () { loadPathOverlay(); }, 1000);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
var counts = { nodeCount: data.nodes.length, edgeCount: data.edges.length };
|
|
736
|
+
updateGraphStats(counts.nodeCount, counts.edgeCount);
|
|
737
|
+
console.log('[laminark:graph] Loaded', counts.nodeCount, 'nodes,', counts.edgeCount, 'edges');
|
|
738
|
+
return counts;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
// Incremental updates
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
function addNode(nodeDataIn) {
|
|
746
|
+
if (!svg) return;
|
|
747
|
+
|
|
748
|
+
var existing = nodeData.find(function (d) { return d.id === nodeDataIn.id; });
|
|
749
|
+
if (existing) {
|
|
750
|
+
Object.assign(existing, nodeDataIn);
|
|
751
|
+
} else {
|
|
752
|
+
nodeData.push({
|
|
753
|
+
id: nodeDataIn.id,
|
|
754
|
+
label: nodeDataIn.label,
|
|
755
|
+
type: nodeDataIn.type,
|
|
756
|
+
observationCount: nodeDataIn.observationCount || 0,
|
|
757
|
+
createdAt: nodeDataIn.createdAt,
|
|
758
|
+
hidden: false,
|
|
759
|
+
});
|
|
760
|
+
hideEmptyState();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
renderGraph();
|
|
764
|
+
if (!isStaticLayout && simulation) simulation.alpha(0.3).restart();
|
|
765
|
+
updateGraphStatsFromData();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function addEdge(edgeDataIn) {
|
|
769
|
+
if (!svg) return;
|
|
770
|
+
|
|
771
|
+
var existing = edgeData.find(function (d) { return d.id === edgeDataIn.id; });
|
|
772
|
+
if (existing) return;
|
|
773
|
+
|
|
774
|
+
var srcExists = nodeData.find(function (d) { return d.id === edgeDataIn.source; });
|
|
775
|
+
var tgtExists = nodeData.find(function (d) { return d.id === edgeDataIn.target; });
|
|
776
|
+
if (!srcExists || !tgtExists) return;
|
|
777
|
+
|
|
778
|
+
edgeData.push({
|
|
779
|
+
id: edgeDataIn.id,
|
|
780
|
+
source: edgeDataIn.source,
|
|
781
|
+
target: edgeDataIn.target,
|
|
782
|
+
type: edgeDataIn.type,
|
|
783
|
+
label: edgeDataIn.label || edgeDataIn.type,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
renderGraph();
|
|
787
|
+
if (!isStaticLayout && simulation) simulation.alpha(0.3).restart();
|
|
788
|
+
updateGraphStatsFromData();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function removeElements(ids) {
|
|
792
|
+
if (!svg) return;
|
|
793
|
+
var idSet = new Set(ids);
|
|
794
|
+
|
|
795
|
+
edgeData = edgeData.filter(function (d) { return !idSet.has(d.id); });
|
|
796
|
+
nodeData = nodeData.filter(function (d) { return !idSet.has(d.id); });
|
|
797
|
+
// Also remove edges connected to removed nodes
|
|
798
|
+
edgeData = edgeData.filter(function (d) {
|
|
799
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
800
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
801
|
+
return !idSet.has(srcId) && !idSet.has(tgtId);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
renderGraph();
|
|
805
|
+
updateGraphStatsFromData();
|
|
806
|
+
|
|
807
|
+
if (nodeData.length === 0) showEmptyState();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Fit to view
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
function fitToView() {
|
|
815
|
+
if (!svg || !svgG || !containerEl) return;
|
|
816
|
+
|
|
817
|
+
var visibleNodes = nodeData.filter(function (d) { return !d.hidden; });
|
|
818
|
+
if (visibleNodes.length === 0) return;
|
|
819
|
+
|
|
820
|
+
var width = containerEl.clientWidth || 800;
|
|
821
|
+
var height = containerEl.clientHeight || 600;
|
|
822
|
+
|
|
823
|
+
var xExtent = d3.extent(visibleNodes, function (d) { return d.x; });
|
|
824
|
+
var yExtent = d3.extent(visibleNodes, function (d) { return d.y; });
|
|
825
|
+
|
|
826
|
+
if (xExtent[0] == null || yExtent[0] == null) return;
|
|
827
|
+
|
|
828
|
+
var padding = 60;
|
|
829
|
+
var graphWidth = (xExtent[1] - xExtent[0]) || 1;
|
|
830
|
+
var graphHeight = (yExtent[1] - yExtent[0]) || 1;
|
|
831
|
+
var scale = Math.min(
|
|
832
|
+
(width - padding * 2) / graphWidth,
|
|
833
|
+
(height - padding * 2) / graphHeight,
|
|
834
|
+
2.0
|
|
835
|
+
);
|
|
836
|
+
scale = Math.max(scale, 0.1);
|
|
837
|
+
|
|
838
|
+
var cx = (xExtent[0] + xExtent[1]) / 2;
|
|
839
|
+
var cy = (yExtent[0] + yExtent[1]) / 2;
|
|
840
|
+
|
|
841
|
+
var transform = d3.zoomIdentity
|
|
842
|
+
.translate(width / 2, height / 2)
|
|
843
|
+
.scale(scale)
|
|
844
|
+
.translate(-cx, -cy);
|
|
845
|
+
|
|
846
|
+
svg.transition().duration(500).call(zoomBehavior.transform, transform);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// Filter handling
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
function applyFilter(types) {
|
|
854
|
+
if (!types) {
|
|
855
|
+
nodeData.forEach(function (d) { d.hidden = false; });
|
|
856
|
+
} else {
|
|
857
|
+
var typeSet = new Set(types);
|
|
858
|
+
nodeData.forEach(function (d) {
|
|
859
|
+
d.hidden = !typeSet.has(d.type);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
renderGraph();
|
|
863
|
+
updateGraphStatsFromData();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function filterByType(type) {
|
|
867
|
+
if (activeEntityTypes.has(type)) {
|
|
868
|
+
activeEntityTypes.delete(type);
|
|
869
|
+
} else {
|
|
870
|
+
activeEntityTypes.add(type);
|
|
871
|
+
}
|
|
872
|
+
applyActiveFilters();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function resetFilters() {
|
|
876
|
+
Object.keys(ENTITY_STYLES).forEach(function (type) {
|
|
877
|
+
activeEntityTypes.add(type);
|
|
878
|
+
});
|
|
879
|
+
activeTimeRange.from = null;
|
|
880
|
+
activeTimeRange.to = null;
|
|
881
|
+
applyActiveFilters();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function setActiveTypes(types) {
|
|
885
|
+
activeEntityTypes.clear();
|
|
886
|
+
if (!types) {
|
|
887
|
+
Object.keys(ENTITY_STYLES).forEach(function (t) { activeEntityTypes.add(t); });
|
|
888
|
+
} else {
|
|
889
|
+
types.forEach(function (t) { activeEntityTypes.add(t); });
|
|
890
|
+
}
|
|
891
|
+
applyActiveFilters();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function applyActiveFilters() {
|
|
895
|
+
var allActive = activeEntityTypes.size === Object.keys(ENTITY_STYLES).length;
|
|
896
|
+
var hasTimeFilter = activeTimeRange.from || activeTimeRange.to;
|
|
897
|
+
|
|
898
|
+
nodeData.forEach(function (d) {
|
|
899
|
+
var typeOk = activeEntityTypes.has(d.type);
|
|
900
|
+
var timeOk = true;
|
|
901
|
+
if (hasTimeFilter && d.createdAt) {
|
|
902
|
+
if (activeTimeRange.from && d.createdAt < activeTimeRange.from) timeOk = false;
|
|
903
|
+
if (activeTimeRange.to && d.createdAt > activeTimeRange.to) timeOk = false;
|
|
904
|
+
}
|
|
905
|
+
d.hidden = !(typeOk && timeOk);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
renderGraph();
|
|
909
|
+
updateGraphStatsFromData();
|
|
910
|
+
updateFilterCounts();
|
|
911
|
+
|
|
912
|
+
// Fit visible elements
|
|
913
|
+
setTimeout(function () {
|
|
914
|
+
var hasVisible = nodeData.some(function (d) { return !d.hidden; });
|
|
915
|
+
if (hasVisible) fitToView();
|
|
916
|
+
}, 600);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function filterByTimeRange(from, to) {
|
|
920
|
+
activeTimeRange.from = from || null;
|
|
921
|
+
activeTimeRange.to = to || null;
|
|
922
|
+
applyActiveFilters();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// Type counts
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
function getTypeCounts() {
|
|
930
|
+
var counts = {};
|
|
931
|
+
Object.keys(ENTITY_STYLES).forEach(function (type) {
|
|
932
|
+
counts[type] = { total: 0, visible: 0 };
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
nodeData.forEach(function (d) {
|
|
936
|
+
if (counts[d.type]) {
|
|
937
|
+
counts[d.type].total++;
|
|
938
|
+
if (!d.hidden) counts[d.type].visible++;
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
return counts;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function updateFilterCounts() {
|
|
946
|
+
var counts = getTypeCounts();
|
|
947
|
+
Object.keys(counts).forEach(function (type) {
|
|
948
|
+
var pill = document.querySelector('.filter-pill[data-type="' + type + '"]');
|
|
949
|
+
if (pill) {
|
|
950
|
+
var countEl = pill.querySelector('.count');
|
|
951
|
+
if (countEl) countEl.textContent = counts[type].visible;
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
var allPill = document.querySelector('.filter-pill[data-type="all"]');
|
|
956
|
+
if (allPill) {
|
|
957
|
+
var allCountEl = allPill.querySelector('.count');
|
|
958
|
+
if (allCountEl) {
|
|
959
|
+
var totalVisible = 0;
|
|
960
|
+
Object.keys(counts).forEach(function (type) { totalVisible += counts[type].visible; });
|
|
961
|
+
allCountEl.textContent = totalVisible;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// Empty state
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
function showEmptyState() {
|
|
971
|
+
if (!containerEl) return;
|
|
972
|
+
var existing = containerEl.querySelector('.graph-empty-state');
|
|
973
|
+
if (existing) { existing.style.display = ''; return; }
|
|
974
|
+
|
|
975
|
+
var msg = document.createElement('div');
|
|
976
|
+
msg.className = 'graph-empty-state';
|
|
977
|
+
msg.textContent = 'No graph data yet. Observations will appear here as they are processed.';
|
|
978
|
+
containerEl.appendChild(msg);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function hideEmptyState() {
|
|
982
|
+
if (!containerEl) return;
|
|
983
|
+
var existing = containerEl.querySelector('.graph-empty-state');
|
|
984
|
+
if (existing) existing.style.display = 'none';
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ---------------------------------------------------------------------------
|
|
988
|
+
// Stats display
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
function updateGraphStats(nodeCount, edgeCount) {
|
|
992
|
+
var el = document.getElementById('graph-stats');
|
|
993
|
+
if (el) el.textContent = nodeCount + ' nodes, ' + edgeCount + ' edges';
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function updateGraphStatsFromData() {
|
|
997
|
+
var visibleNodes = nodeData.filter(function (d) { return !d.hidden; });
|
|
998
|
+
var visibleNodeIds = new Set(visibleNodes.map(function (d) { return d.id; }));
|
|
999
|
+
var visibleEdges = edgeData.filter(function (d) {
|
|
1000
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
1001
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
1002
|
+
return visibleNodeIds.has(srcId) && visibleNodeIds.has(tgtId);
|
|
1003
|
+
});
|
|
1004
|
+
updateGraphStats(visibleNodes.length, visibleEdges.length);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
// Tooltip
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
function buildTooltipContent(d) {
|
|
1012
|
+
var degree = d._degree || 0;
|
|
1013
|
+
|
|
1014
|
+
// Gather connected node names by relationship type
|
|
1015
|
+
var connections = {};
|
|
1016
|
+
edgeData.forEach(function (e) {
|
|
1017
|
+
var srcId = typeof e.source === 'object' ? e.source.id : e.source;
|
|
1018
|
+
var tgtId = typeof e.target === 'object' ? e.target.id : e.target;
|
|
1019
|
+
var linkedId = null;
|
|
1020
|
+
if (srcId === d.id) linkedId = tgtId;
|
|
1021
|
+
else if (tgtId === d.id) linkedId = srcId;
|
|
1022
|
+
if (!linkedId) return;
|
|
1023
|
+
|
|
1024
|
+
var linked = nodeData.find(function (n) { return n.id === linkedId; });
|
|
1025
|
+
if (!linked) return;
|
|
1026
|
+
var relType = e.type || 'related_to';
|
|
1027
|
+
if (!connections[relType]) connections[relType] = [];
|
|
1028
|
+
connections[relType].push(linked.label || linkedId);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
var html = '<div class="tooltip-header">'
|
|
1032
|
+
+ '<span class="tooltip-type" style="color:' + (ENTITY_STYLES[d.type] ? ENTITY_STYLES[d.type].color : '#8b949e') + '">' + d.type + '</span>'
|
|
1033
|
+
+ '</div>'
|
|
1034
|
+
+ '<div class="tooltip-name">' + escapeHtml(d.label || '') + '</div>'
|
|
1035
|
+
+ '<div class="tooltip-stat">' + degree + ' connection' + (degree !== 1 ? 's' : '') + '</div>';
|
|
1036
|
+
|
|
1037
|
+
var relTypes = Object.keys(connections);
|
|
1038
|
+
if (relTypes.length > 0) {
|
|
1039
|
+
html += '<div class="tooltip-connections">';
|
|
1040
|
+
relTypes.forEach(function (rel) {
|
|
1041
|
+
var names = connections[rel];
|
|
1042
|
+
var display = names.slice(0, 3).map(escapeHtml).join(', ');
|
|
1043
|
+
if (names.length > 3) display += ' +' + (names.length - 3) + ' more';
|
|
1044
|
+
html += '<div class="tooltip-rel"><span class="tooltip-rel-type">' + rel.replace(/_/g, ' ') + ':</span> ' + display + '</div>';
|
|
1045
|
+
});
|
|
1046
|
+
html += '</div>';
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return html;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function showTooltip(event, d) {
|
|
1053
|
+
if (!tooltipEl) return;
|
|
1054
|
+
tooltipEl.innerHTML = buildTooltipContent(d);
|
|
1055
|
+
tooltipEl.classList.remove('hidden');
|
|
1056
|
+
positionTooltip(event.pageX, event.pageY);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function moveTooltip(event) {
|
|
1060
|
+
if (!tooltipEl || tooltipEl.classList.contains('hidden')) return;
|
|
1061
|
+
positionTooltip(event.pageX, event.pageY);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function positionTooltip(px, py) {
|
|
1065
|
+
var offset = 12;
|
|
1066
|
+
var x = px + offset;
|
|
1067
|
+
var y = py + offset;
|
|
1068
|
+
var rect = tooltipEl.getBoundingClientRect();
|
|
1069
|
+
var vw = window.innerWidth;
|
|
1070
|
+
var vh = window.innerHeight;
|
|
1071
|
+
if (x + rect.width > vw - 8) x = px - rect.width - offset;
|
|
1072
|
+
if (y + rect.height > vh - 8) y = py - rect.height - offset;
|
|
1073
|
+
if (x < 8) x = 8;
|
|
1074
|
+
if (y < 8) y = 8;
|
|
1075
|
+
tooltipEl.style.left = x + 'px';
|
|
1076
|
+
tooltipEl.style.top = y + 'px';
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function hideTooltip() {
|
|
1080
|
+
if (tooltipEl) tooltipEl.classList.add('hidden');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
// Level-of-detail (LOD)
|
|
1085
|
+
// ---------------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
function updateLevelOfDetail() {
|
|
1088
|
+
var newLevel;
|
|
1089
|
+
if (currentZoom < 0.3) {
|
|
1090
|
+
newLevel = 2;
|
|
1091
|
+
} else if (currentZoom < 0.5) {
|
|
1092
|
+
newLevel = 1;
|
|
1093
|
+
} else {
|
|
1094
|
+
newLevel = 0;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (newLevel === currentLodLevel) return;
|
|
1098
|
+
currentLodLevel = newLevel;
|
|
1099
|
+
|
|
1100
|
+
if (nodeLabelsGroup) {
|
|
1101
|
+
nodeLabelsGroup.style('display', newLevel >= 1 ? 'none' : null);
|
|
1102
|
+
}
|
|
1103
|
+
if (edgeLabelsGroup) {
|
|
1104
|
+
edgeLabelsGroup.style('display', (newLevel >= 1 || !edgeLabelsVisible) ? 'none' : null);
|
|
1105
|
+
}
|
|
1106
|
+
if (edgesGroup) {
|
|
1107
|
+
edgesGroup.style('display', newLevel >= 2 ? 'none' : null);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
// Edge label toggle (with per-type dropdown)
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
function initEdgeLabelToggle() {
|
|
1116
|
+
var btn = document.getElementById('edge-labels-btn');
|
|
1117
|
+
if (!btn) return;
|
|
1118
|
+
|
|
1119
|
+
btn.classList.toggle('active', edgeLabelsVisible);
|
|
1120
|
+
applyEdgeLabelVisibility();
|
|
1121
|
+
|
|
1122
|
+
// Build dropdown
|
|
1123
|
+
var dropdown = document.createElement('div');
|
|
1124
|
+
dropdown.className = 'edge-labels-dropdown hidden';
|
|
1125
|
+
dropdown.id = 'edge-labels-dropdown';
|
|
1126
|
+
|
|
1127
|
+
// "All" toggle row
|
|
1128
|
+
var allRow = document.createElement('div');
|
|
1129
|
+
allRow.className = 'edge-labels-dropdown-item edge-labels-all-toggle';
|
|
1130
|
+
var allCheck = document.createElement('input');
|
|
1131
|
+
allCheck.type = 'checkbox';
|
|
1132
|
+
allCheck.checked = edgeLabelsVisible;
|
|
1133
|
+
allCheck.id = 'edge-labels-all-check';
|
|
1134
|
+
var allLabel = document.createElement('label');
|
|
1135
|
+
allLabel.textContent = 'All labels';
|
|
1136
|
+
allLabel.setAttribute('for', 'edge-labels-all-check');
|
|
1137
|
+
allLabel.style.fontWeight = '600';
|
|
1138
|
+
allRow.appendChild(allCheck);
|
|
1139
|
+
allRow.appendChild(allLabel);
|
|
1140
|
+
dropdown.appendChild(allRow);
|
|
1141
|
+
|
|
1142
|
+
var divider = document.createElement('div');
|
|
1143
|
+
divider.className = 'edge-labels-dropdown-divider';
|
|
1144
|
+
dropdown.appendChild(divider);
|
|
1145
|
+
|
|
1146
|
+
// Per-type rows
|
|
1147
|
+
Object.keys(EDGE_TYPE_COLORS).forEach(function (type) {
|
|
1148
|
+
var row = document.createElement('div');
|
|
1149
|
+
row.className = 'edge-labels-dropdown-item';
|
|
1150
|
+
|
|
1151
|
+
var dot = document.createElement('span');
|
|
1152
|
+
dot.className = 'edge-type-dot';
|
|
1153
|
+
dot.style.background = EDGE_TYPE_COLORS[type];
|
|
1154
|
+
row.appendChild(dot);
|
|
1155
|
+
|
|
1156
|
+
var check = document.createElement('input');
|
|
1157
|
+
check.type = 'checkbox';
|
|
1158
|
+
check.checked = !hiddenEdgeLabelTypes.has(type);
|
|
1159
|
+
check.setAttribute('data-edge-type', type);
|
|
1160
|
+
check.id = 'edge-type-' + type;
|
|
1161
|
+
row.appendChild(check);
|
|
1162
|
+
|
|
1163
|
+
var label = document.createElement('label');
|
|
1164
|
+
label.textContent = type;
|
|
1165
|
+
label.setAttribute('for', 'edge-type-' + type);
|
|
1166
|
+
row.appendChild(label);
|
|
1167
|
+
|
|
1168
|
+
check.addEventListener('change', function () {
|
|
1169
|
+
if (check.checked) {
|
|
1170
|
+
hiddenEdgeLabelTypes.delete(type);
|
|
1171
|
+
} else {
|
|
1172
|
+
hiddenEdgeLabelTypes.add(type);
|
|
1173
|
+
}
|
|
1174
|
+
persistHiddenEdgeTypes();
|
|
1175
|
+
applyEdgeLabelVisibility();
|
|
1176
|
+
updateAllCheckState();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
dropdown.appendChild(row);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// Insert dropdown after button
|
|
1183
|
+
btn.parentElement.style.position = 'relative';
|
|
1184
|
+
btn.insertAdjacentElement('afterend', dropdown);
|
|
1185
|
+
|
|
1186
|
+
// Toggle dropdown on click
|
|
1187
|
+
btn.addEventListener('click', function (e) {
|
|
1188
|
+
e.stopPropagation();
|
|
1189
|
+
var isHidden = dropdown.classList.contains('hidden');
|
|
1190
|
+
dropdown.classList.toggle('hidden', !isHidden);
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// All toggle handler
|
|
1194
|
+
allCheck.addEventListener('change', function () {
|
|
1195
|
+
edgeLabelsVisible = allCheck.checked;
|
|
1196
|
+
localStorage.setItem('laminark-edge-labels', edgeLabelsVisible ? 'true' : 'false');
|
|
1197
|
+
btn.classList.toggle('active', edgeLabelsVisible);
|
|
1198
|
+
applyEdgeLabelVisibility();
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
// Close on outside click
|
|
1202
|
+
document.addEventListener('click', function (e) {
|
|
1203
|
+
if (!btn.contains(e.target) && !dropdown.contains(e.target)) {
|
|
1204
|
+
dropdown.classList.add('hidden');
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
function updateAllCheckState() {
|
|
1209
|
+
var anyHidden = hiddenEdgeLabelTypes.size > 0;
|
|
1210
|
+
allCheck.checked = edgeLabelsVisible;
|
|
1211
|
+
allCheck.indeterminate = edgeLabelsVisible && anyHidden;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function persistHiddenEdgeTypes() {
|
|
1216
|
+
localStorage.setItem('laminark-hidden-edge-types', JSON.stringify(Array.from(hiddenEdgeLabelTypes)));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function applyEdgeLabelVisibility() {
|
|
1220
|
+
if (!edgeLabelsGroup) return;
|
|
1221
|
+
// Hide entire group if master toggle off or LOD too low
|
|
1222
|
+
if (!edgeLabelsVisible || currentLodLevel >= 1) {
|
|
1223
|
+
edgeLabelsGroup.style('display', 'none');
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
edgeLabelsGroup.style('display', null);
|
|
1227
|
+
|
|
1228
|
+
// Per-type visibility
|
|
1229
|
+
if (hiddenEdgeLabelTypes.size > 0) {
|
|
1230
|
+
edgeLabelsGroup.selectAll('.edge-label')
|
|
1231
|
+
.style('display', function (d) {
|
|
1232
|
+
return hiddenEdgeLabelTypes.has(d.type) ? 'none' : null;
|
|
1233
|
+
});
|
|
1234
|
+
} else {
|
|
1235
|
+
edgeLabelsGroup.selectAll('.edge-label').style('display', null);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
// Detail panel helpers
|
|
1241
|
+
// ---------------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
function hideDetailPanel() {
|
|
1244
|
+
var panel = document.getElementById('detail-panel');
|
|
1245
|
+
if (panel) panel.classList.add('hidden');
|
|
1246
|
+
selectedNodeId = null;
|
|
1247
|
+
if (nodesGroup) nodesGroup.selectAll('.node-group').classed('selected', false);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function selectAndCenterNode(nodeId) {
|
|
1251
|
+
if (!svg) return;
|
|
1252
|
+
var node = nodeData.find(function (d) { return d.id === nodeId; });
|
|
1253
|
+
if (!node || node.x == null) return;
|
|
1254
|
+
|
|
1255
|
+
selectedNodeId = nodeId;
|
|
1256
|
+
if (nodesGroup) {
|
|
1257
|
+
nodesGroup.selectAll('.node-group').classed('selected', function (d) { return d.id === nodeId; });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Center on node
|
|
1261
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
1262
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
1263
|
+
var transform = d3.zoomIdentity
|
|
1264
|
+
.translate(width / 2, height / 2)
|
|
1265
|
+
.scale(currentZoom || 1)
|
|
1266
|
+
.translate(-node.x, -node.y);
|
|
1267
|
+
svg.transition().duration(300).call(zoomBehavior.transform, transform);
|
|
1268
|
+
|
|
1269
|
+
// Fetch and show details
|
|
1270
|
+
if (window.laminarkApp && window.laminarkApp.fetchNodeDetails) {
|
|
1271
|
+
window.laminarkApp.fetchNodeDetails(nodeId).then(function (details) {
|
|
1272
|
+
if (details && window.laminarkApp.showNodeDetails) {
|
|
1273
|
+
window.laminarkApp.showNodeDetails(details);
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
// Search functions
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
|
|
1283
|
+
function searchNodes(query) {
|
|
1284
|
+
if (!query) return [];
|
|
1285
|
+
var lowerQuery = query.toLowerCase();
|
|
1286
|
+
var results = [];
|
|
1287
|
+
|
|
1288
|
+
nodeData.forEach(function (d) {
|
|
1289
|
+
var label = (d.label || '').toLowerCase();
|
|
1290
|
+
if (label.indexOf(lowerQuery) >= 0) {
|
|
1291
|
+
results.push({ id: d.id, label: d.label, type: d.type });
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
results.sort(function (a, b) {
|
|
1296
|
+
var aLower = a.label.toLowerCase();
|
|
1297
|
+
var bLower = b.label.toLowerCase();
|
|
1298
|
+
if (aLower === lowerQuery && bLower !== lowerQuery) return -1;
|
|
1299
|
+
if (aLower !== lowerQuery && bLower === lowerQuery) return 1;
|
|
1300
|
+
if (aLower.startsWith(lowerQuery) && !bLower.startsWith(lowerQuery)) return -1;
|
|
1301
|
+
if (!aLower.startsWith(lowerQuery) && bLower.startsWith(lowerQuery)) return 1;
|
|
1302
|
+
return 0;
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
return results.slice(0, 20);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function highlightSearchMatches(matchIds) {
|
|
1309
|
+
if (!nodesGroup || !edgesGroup) return;
|
|
1310
|
+
var idSet = new Set(matchIds);
|
|
1311
|
+
|
|
1312
|
+
nodesGroup.selectAll('.node-group')
|
|
1313
|
+
.classed('search-match', function (d) { return idSet.has(d.id); })
|
|
1314
|
+
.classed('search-dimmed', function (d) { return !idSet.has(d.id); });
|
|
1315
|
+
|
|
1316
|
+
nodeLabelsGroup.selectAll('.node-label')
|
|
1317
|
+
.classed('search-dimmed', function (d) { return !idSet.has(d.id); });
|
|
1318
|
+
|
|
1319
|
+
edgesGroup.selectAll('.edge')
|
|
1320
|
+
.classed('search-dimmed', function (d) {
|
|
1321
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
1322
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
1323
|
+
return !(idSet.has(srcId) && idSet.has(tgtId));
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
edgeLabelsGroup.selectAll('.edge-label')
|
|
1327
|
+
.classed('search-dimmed', function (d) {
|
|
1328
|
+
var srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
1329
|
+
var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
1330
|
+
return !(idSet.has(srcId) && idSet.has(tgtId));
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function clearSearchHighlight() {
|
|
1335
|
+
if (!nodesGroup) return;
|
|
1336
|
+
nodesGroup.selectAll('.node-group').classed('search-match', false).classed('search-dimmed', false);
|
|
1337
|
+
nodeLabelsGroup.selectAll('.node-label').classed('search-dimmed', false);
|
|
1338
|
+
edgesGroup.selectAll('.edge').classed('search-dimmed', false);
|
|
1339
|
+
edgeLabelsGroup.selectAll('.edge-label').classed('search-dimmed', false);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function highlightCluster(nodeIds) {
|
|
1343
|
+
highlightSearchMatches(nodeIds);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ---------------------------------------------------------------------------
|
|
1347
|
+
// Performance stats overlay
|
|
1348
|
+
// ---------------------------------------------------------------------------
|
|
1349
|
+
|
|
1350
|
+
function togglePerfOverlay() {
|
|
1351
|
+
perfOverlayVisible = !perfOverlayVisible;
|
|
1352
|
+
if (perfOverlayVisible) showPerfOverlay();
|
|
1353
|
+
else hidePerfOverlay();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function showPerfOverlay() {
|
|
1357
|
+
if (!containerEl) return;
|
|
1358
|
+
if (!perfOverlayEl) {
|
|
1359
|
+
perfOverlayEl = document.createElement('div');
|
|
1360
|
+
perfOverlayEl.className = 'perf-overlay';
|
|
1361
|
+
containerEl.appendChild(perfOverlayEl);
|
|
1362
|
+
}
|
|
1363
|
+
perfOverlayEl.style.display = '';
|
|
1364
|
+
perfLastFpsTime = performance.now();
|
|
1365
|
+
perfFrameCount = 0;
|
|
1366
|
+
updatePerfOverlay();
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function hidePerfOverlay() {
|
|
1370
|
+
if (perfOverlayEl) perfOverlayEl.style.display = 'none';
|
|
1371
|
+
if (perfRafId) { cancelAnimationFrame(perfRafId); perfRafId = null; }
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function updatePerfOverlay() {
|
|
1375
|
+
if (!perfOverlayVisible || !perfOverlayEl) return;
|
|
1376
|
+
|
|
1377
|
+
perfFrameCount++;
|
|
1378
|
+
var now = performance.now();
|
|
1379
|
+
if (now - perfLastFpsTime >= 1000) {
|
|
1380
|
+
perfFps = Math.round((perfFrameCount * 1000) / (now - perfLastFpsTime));
|
|
1381
|
+
perfFrameCount = 0;
|
|
1382
|
+
perfLastFpsTime = now;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
var total = nodeData.length;
|
|
1386
|
+
var visible = nodeData.filter(function (d) { return !d.hidden; }).length;
|
|
1387
|
+
var totalEdges = edgeData.length;
|
|
1388
|
+
var lodText = currentLodLevel === 0 ? 'Full' : currentLodLevel === 1 ? 'No labels' : 'Minimal';
|
|
1389
|
+
|
|
1390
|
+
perfOverlayEl.textContent =
|
|
1391
|
+
'Nodes: ' + visible + '/' + total +
|
|
1392
|
+
' | Edges: ' + totalEdges +
|
|
1393
|
+
' | FPS: ' + perfFps +
|
|
1394
|
+
' | Zoom: ' + currentZoom.toFixed(2) +
|
|
1395
|
+
' | LOD: ' + lodText;
|
|
1396
|
+
|
|
1397
|
+
perfRafId = requestAnimationFrame(updatePerfOverlay);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ---------------------------------------------------------------------------
|
|
1401
|
+
// Batch update optimization for SSE events
|
|
1402
|
+
// ---------------------------------------------------------------------------
|
|
1403
|
+
|
|
1404
|
+
function queueBatchUpdate(update) {
|
|
1405
|
+
batchQueue.push(update);
|
|
1406
|
+
if (batchFlushTimer) clearTimeout(batchFlushTimer);
|
|
1407
|
+
batchFlushTimer = setTimeout(flushBatchUpdates, BATCH_DELAY_MS);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function flushBatchUpdates() {
|
|
1411
|
+
if (!svg || batchQueue.length === 0) return;
|
|
1412
|
+
|
|
1413
|
+
var newNodes = 0;
|
|
1414
|
+
var newEdges = 0;
|
|
1415
|
+
|
|
1416
|
+
batchQueue.forEach(function (update) {
|
|
1417
|
+
if (update.type === 'addNode') {
|
|
1418
|
+
var existing = nodeData.find(function (d) { return d.id === update.data.id; });
|
|
1419
|
+
if (existing) {
|
|
1420
|
+
Object.assign(existing, update.data);
|
|
1421
|
+
} else {
|
|
1422
|
+
nodeData.push({
|
|
1423
|
+
id: update.data.id,
|
|
1424
|
+
label: update.data.label,
|
|
1425
|
+
type: update.data.type,
|
|
1426
|
+
observationCount: update.data.observationCount || 0,
|
|
1427
|
+
createdAt: update.data.createdAt,
|
|
1428
|
+
hidden: false,
|
|
1429
|
+
});
|
|
1430
|
+
newNodes++;
|
|
1431
|
+
}
|
|
1432
|
+
} else if (update.type === 'addEdge') {
|
|
1433
|
+
var edgeExists = edgeData.find(function (d) { return d.id === update.data.id; });
|
|
1434
|
+
var srcExists = nodeData.find(function (d) { return d.id === update.data.source; });
|
|
1435
|
+
var tgtExists = nodeData.find(function (d) { return d.id === update.data.target; });
|
|
1436
|
+
if (!edgeExists && srcExists && tgtExists) {
|
|
1437
|
+
edgeData.push({
|
|
1438
|
+
id: update.data.id,
|
|
1439
|
+
source: update.data.source,
|
|
1440
|
+
target: update.data.target,
|
|
1441
|
+
type: update.data.type,
|
|
1442
|
+
label: update.data.label || update.data.type,
|
|
1443
|
+
});
|
|
1444
|
+
newEdges++;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
batchQueue = [];
|
|
1450
|
+
batchFlushTimer = null;
|
|
1451
|
+
|
|
1452
|
+
if (newNodes > 0 || newEdges > 0) {
|
|
1453
|
+
hideEmptyState();
|
|
1454
|
+
renderGraph();
|
|
1455
|
+
if (!isStaticLayout && simulation) simulation.alpha(0.3).restart();
|
|
1456
|
+
console.log('[laminark:graph] Batch update: added ' + newNodes + ' nodes, ' + newEdges + ' edges');
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
updateGraphStatsFromData();
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ---------------------------------------------------------------------------
|
|
1463
|
+
// Focus mode (drill-down)
|
|
1464
|
+
// ---------------------------------------------------------------------------
|
|
1465
|
+
|
|
1466
|
+
var _focusFetching = false;
|
|
1467
|
+
|
|
1468
|
+
async function enterFocusMode(nodeId, label) {
|
|
1469
|
+
if (!svg || _focusFetching) return;
|
|
1470
|
+
|
|
1471
|
+
if (!isFocusMode) {
|
|
1472
|
+
cachedFullData = {
|
|
1473
|
+
nodes: nodeData.map(function (d) {
|
|
1474
|
+
var copy = Object.assign({}, d);
|
|
1475
|
+
delete copy.x; delete copy.y; delete copy.vx; delete copy.vy;
|
|
1476
|
+
delete copy.fx; delete copy.fy; delete copy.index;
|
|
1477
|
+
return copy;
|
|
1478
|
+
}),
|
|
1479
|
+
edges: edgeData.map(function (d) {
|
|
1480
|
+
return {
|
|
1481
|
+
id: d.id,
|
|
1482
|
+
source: typeof d.source === 'object' ? d.source.id : d.source,
|
|
1483
|
+
target: typeof d.target === 'object' ? d.target.id : d.target,
|
|
1484
|
+
type: d.type,
|
|
1485
|
+
label: d.label,
|
|
1486
|
+
};
|
|
1487
|
+
}),
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
_focusFetching = true;
|
|
1492
|
+
var data;
|
|
1493
|
+
try {
|
|
1494
|
+
var res = await fetch('/api/node/' + encodeURIComponent(nodeId) + '/neighborhood?depth=1');
|
|
1495
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1496
|
+
data = await res.json();
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
console.error('[laminark:graph] Failed to fetch neighborhood:', err);
|
|
1499
|
+
_focusFetching = false;
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (!data.nodes || data.nodes.length === 0) { _focusFetching = false; return; }
|
|
1504
|
+
|
|
1505
|
+
isFocusMode = true;
|
|
1506
|
+
// Prevent duplicate consecutive breadcrumb entries
|
|
1507
|
+
var top = focusStack.length > 0 ? focusStack[focusStack.length - 1] : null;
|
|
1508
|
+
if (!top || top.nodeId !== nodeId) {
|
|
1509
|
+
focusStack.push({ nodeId: nodeId, label: label });
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
nodeData = data.nodes.map(function (node) {
|
|
1513
|
+
return {
|
|
1514
|
+
id: node.id,
|
|
1515
|
+
label: node.label,
|
|
1516
|
+
type: node.type,
|
|
1517
|
+
observationCount: node.observationCount || 0,
|
|
1518
|
+
createdAt: node.createdAt,
|
|
1519
|
+
hidden: false,
|
|
1520
|
+
};
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
edgeData = data.edges.map(function (edge) {
|
|
1524
|
+
return {
|
|
1525
|
+
id: edge.id,
|
|
1526
|
+
source: edge.source,
|
|
1527
|
+
target: edge.target,
|
|
1528
|
+
type: edge.type,
|
|
1529
|
+
label: edge.type,
|
|
1530
|
+
};
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
renderGraph();
|
|
1534
|
+
setTimeout(function () { fitToView(); }, 600);
|
|
1535
|
+
|
|
1536
|
+
_focusFetching = false;
|
|
1537
|
+
updateBreadcrumbs();
|
|
1538
|
+
updateGraphStatsFromData();
|
|
1539
|
+
console.log('[laminark:graph] Focus mode: centered on', label, '(' + data.nodes.length + ' nodes)');
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function exitFocusMode() {
|
|
1543
|
+
if (!svg || !isFocusMode) return;
|
|
1544
|
+
|
|
1545
|
+
isFocusMode = false;
|
|
1546
|
+
focusStack = [];
|
|
1547
|
+
|
|
1548
|
+
if (cachedFullData) {
|
|
1549
|
+
nodeData = cachedFullData.nodes;
|
|
1550
|
+
edgeData = cachedFullData.edges;
|
|
1551
|
+
cachedFullData = null;
|
|
1552
|
+
renderGraph();
|
|
1553
|
+
setTimeout(function () { fitToView(); }, 600);
|
|
1554
|
+
} else {
|
|
1555
|
+
loadGraphData();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
updateBreadcrumbs();
|
|
1559
|
+
updateGraphStatsFromData();
|
|
1560
|
+
console.log('[laminark:graph] Exited focus mode');
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function navigateBreadcrumb(index) {
|
|
1564
|
+
if (index < 0) { exitFocusMode(); return; }
|
|
1565
|
+
var target = focusStack[index];
|
|
1566
|
+
if (!target) return;
|
|
1567
|
+
// Trim stack to just before the target — enterFocusMode will re-push it.
|
|
1568
|
+
// Keep isFocusMode true so enterFocusMode doesn't overwrite cachedFullData.
|
|
1569
|
+
focusStack = focusStack.slice(0, index);
|
|
1570
|
+
enterFocusMode(target.nodeId, target.label);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function updateBreadcrumbs() {
|
|
1574
|
+
var bar = document.getElementById('graph-breadcrumbs');
|
|
1575
|
+
if (!bar) return;
|
|
1576
|
+
|
|
1577
|
+
if (!isFocusMode || focusStack.length === 0) {
|
|
1578
|
+
bar.classList.add('hidden');
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
bar.classList.remove('hidden');
|
|
1583
|
+
bar.innerHTML = '';
|
|
1584
|
+
|
|
1585
|
+
var rootBtn = document.createElement('button');
|
|
1586
|
+
rootBtn.className = 'breadcrumb-item';
|
|
1587
|
+
rootBtn.textContent = 'Full Graph';
|
|
1588
|
+
rootBtn.addEventListener('click', function () { exitFocusMode(); });
|
|
1589
|
+
bar.appendChild(rootBtn);
|
|
1590
|
+
|
|
1591
|
+
focusStack.forEach(function (item, idx) {
|
|
1592
|
+
var sep = document.createElement('span');
|
|
1593
|
+
sep.className = 'breadcrumb-separator';
|
|
1594
|
+
sep.textContent = '>';
|
|
1595
|
+
bar.appendChild(sep);
|
|
1596
|
+
|
|
1597
|
+
var btn = document.createElement('button');
|
|
1598
|
+
btn.className = 'breadcrumb-item';
|
|
1599
|
+
if (idx === focusStack.length - 1) btn.classList.add('current');
|
|
1600
|
+
btn.textContent = item.label;
|
|
1601
|
+
btn.addEventListener('click', (function (i) {
|
|
1602
|
+
return function () {
|
|
1603
|
+
if (i < focusStack.length - 1) navigateBreadcrumb(i);
|
|
1604
|
+
};
|
|
1605
|
+
})(idx));
|
|
1606
|
+
bar.appendChild(btn);
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ---------------------------------------------------------------------------
|
|
1611
|
+
// Layout selector
|
|
1612
|
+
// ---------------------------------------------------------------------------
|
|
1613
|
+
|
|
1614
|
+
function setLayout(layoutName) {
|
|
1615
|
+
var validLayouts = ['clustered', 'hierarchical', 'concentric', 'communities'];
|
|
1616
|
+
if (validLayouts.indexOf(layoutName) === -1) return;
|
|
1617
|
+
|
|
1618
|
+
var previousLayout = currentLayout;
|
|
1619
|
+
currentLayout = layoutName;
|
|
1620
|
+
localStorage.setItem('laminark-layout', layoutName);
|
|
1621
|
+
|
|
1622
|
+
var btns = document.querySelectorAll('.layout-btn');
|
|
1623
|
+
btns.forEach(function (btn) {
|
|
1624
|
+
btn.classList.toggle('active', btn.getAttribute('data-layout') === layoutName);
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
if (previousLayout === 'communities' && layoutName !== 'communities') {
|
|
1628
|
+
clearCommunityColors();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (!isFocusMode && nodeData.length > 0) {
|
|
1632
|
+
if (layoutName === 'communities') {
|
|
1633
|
+
applyCommunitiesLayout();
|
|
1634
|
+
} else if (layoutName === 'hierarchical') {
|
|
1635
|
+
applyHierarchicalLayout();
|
|
1636
|
+
} else if (layoutName === 'concentric') {
|
|
1637
|
+
applyConcentricLayout();
|
|
1638
|
+
} else {
|
|
1639
|
+
applyClusteredLayout();
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function applyClusteredLayout() {
|
|
1645
|
+
isStaticLayout = false;
|
|
1646
|
+
// Release any fixed positions from other layouts
|
|
1647
|
+
nodeData.forEach(function (d) { d.fx = null; d.fy = null; });
|
|
1648
|
+
renderGraph();
|
|
1649
|
+
if (simulation) simulation.alpha(1).restart();
|
|
1650
|
+
setTimeout(function () { fitToView(); }, 800);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function applyHierarchicalLayout() {
|
|
1654
|
+
isStaticLayout = true;
|
|
1655
|
+
if (simulation) simulation.stop();
|
|
1656
|
+
|
|
1657
|
+
var visibleNodes = nodeData.filter(function (d) { return !d.hidden; });
|
|
1658
|
+
if (visibleNodes.length === 0) return;
|
|
1659
|
+
|
|
1660
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
1661
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
1662
|
+
|
|
1663
|
+
// Find root nodes (Project type or no incoming edges)
|
|
1664
|
+
var incomingSet = new Set();
|
|
1665
|
+
edgeData.forEach(function (e) {
|
|
1666
|
+
var tgtId = typeof e.target === 'object' ? e.target.id : e.target;
|
|
1667
|
+
incomingSet.add(tgtId);
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
var roots = visibleNodes.filter(function (d) {
|
|
1671
|
+
return d.type === 'Project' || !incomingSet.has(d.id);
|
|
1672
|
+
});
|
|
1673
|
+
if (roots.length === 0) roots = [visibleNodes[0]];
|
|
1674
|
+
|
|
1675
|
+
// BFS to assign depth layers
|
|
1676
|
+
var nodeDepth = {};
|
|
1677
|
+
var visited = new Set();
|
|
1678
|
+
var queue = [];
|
|
1679
|
+
roots.forEach(function (r) {
|
|
1680
|
+
nodeDepth[r.id] = 0;
|
|
1681
|
+
visited.add(r.id);
|
|
1682
|
+
queue.push(r.id);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
// Build adjacency (both directions for better BFS coverage)
|
|
1686
|
+
var adj = {};
|
|
1687
|
+
edgeData.forEach(function (e) {
|
|
1688
|
+
var srcId = typeof e.source === 'object' ? e.source.id : e.source;
|
|
1689
|
+
var tgtId = typeof e.target === 'object' ? e.target.id : e.target;
|
|
1690
|
+
if (!adj[srcId]) adj[srcId] = [];
|
|
1691
|
+
adj[srcId].push(tgtId);
|
|
1692
|
+
if (!adj[tgtId]) adj[tgtId] = [];
|
|
1693
|
+
adj[tgtId].push(srcId);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
while (queue.length > 0) {
|
|
1697
|
+
var current = queue.shift();
|
|
1698
|
+
var neighbors = adj[current] || [];
|
|
1699
|
+
neighbors.forEach(function (n) {
|
|
1700
|
+
if (!visited.has(n)) {
|
|
1701
|
+
visited.add(n);
|
|
1702
|
+
nodeDepth[n] = (nodeDepth[current] || 0) + 1;
|
|
1703
|
+
queue.push(n);
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Assign depth 0 to unvisited (truly disconnected) nodes
|
|
1709
|
+
visibleNodes.forEach(function (d) {
|
|
1710
|
+
if (nodeDepth[d.id] == null) nodeDepth[d.id] = 0;
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
// Group by depth
|
|
1714
|
+
var layers = {};
|
|
1715
|
+
visibleNodes.forEach(function (d) {
|
|
1716
|
+
var depth = nodeDepth[d.id];
|
|
1717
|
+
if (!layers[depth]) layers[depth] = [];
|
|
1718
|
+
layers[depth].push(d);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
var layerKeys = Object.keys(layers).map(Number).sort(function (a, b) { return a - b; });
|
|
1722
|
+
var nodeGap = 60;
|
|
1723
|
+
var rowGap = 50;
|
|
1724
|
+
var layerGap = 100;
|
|
1725
|
+
var maxRowWidth = Math.max(width * 1.5, 800);
|
|
1726
|
+
var cx = width / 2;
|
|
1727
|
+
var currentY = 80;
|
|
1728
|
+
|
|
1729
|
+
layerKeys.forEach(function (depth) {
|
|
1730
|
+
var nodesInLayer = layers[depth];
|
|
1731
|
+
// Calculate columns per row to fit within maxRowWidth
|
|
1732
|
+
var cols = Math.max(1, Math.floor(maxRowWidth / nodeGap));
|
|
1733
|
+
var rows = Math.ceil(nodesInLayer.length / cols);
|
|
1734
|
+
var actualCols = Math.min(cols, nodesInLayer.length);
|
|
1735
|
+
var layerWidth = actualCols * nodeGap;
|
|
1736
|
+
var startX = cx - layerWidth / 2 + nodeGap / 2;
|
|
1737
|
+
|
|
1738
|
+
nodesInLayer.forEach(function (d, i) {
|
|
1739
|
+
var col = i % cols;
|
|
1740
|
+
var row = Math.floor(i / cols);
|
|
1741
|
+
d.x = startX + col * nodeGap;
|
|
1742
|
+
d.y = currentY + row * rowGap;
|
|
1743
|
+
d.fx = d.x;
|
|
1744
|
+
d.fy = d.y;
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
currentY += rows * rowGap + layerGap;
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
// Re-render with fixed positions
|
|
1751
|
+
renderGraph();
|
|
1752
|
+
setTimeout(function () { fitToView(); }, 200);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function applyConcentricLayout() {
|
|
1756
|
+
isStaticLayout = true;
|
|
1757
|
+
if (simulation) simulation.stop();
|
|
1758
|
+
|
|
1759
|
+
var visibleNodes = nodeData.filter(function (d) { return !d.hidden; });
|
|
1760
|
+
if (visibleNodes.length === 0) return;
|
|
1761
|
+
|
|
1762
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
1763
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
1764
|
+
var cx = width / 2;
|
|
1765
|
+
var cy = height / 2;
|
|
1766
|
+
|
|
1767
|
+
var typePriority = { Project: 0, File: 1, Reference: 2, Decision: 3, Problem: 4, Solution: 4 };
|
|
1768
|
+
|
|
1769
|
+
// Group by ring
|
|
1770
|
+
var rings = {};
|
|
1771
|
+
visibleNodes.forEach(function (d) {
|
|
1772
|
+
var ring = typePriority[d.type] != null ? typePriority[d.type] : 4;
|
|
1773
|
+
if (!rings[ring]) rings[ring] = [];
|
|
1774
|
+
rings[ring].push(d);
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
var ringKeys = Object.keys(rings).map(Number).sort(function (a, b) { return a - b; });
|
|
1778
|
+
// Dynamic ring spacing: ensure nodes don't overlap on each ring
|
|
1779
|
+
var baseSpacing = 100;
|
|
1780
|
+
|
|
1781
|
+
ringKeys.forEach(function (ring, ringIndex) {
|
|
1782
|
+
var nodesInRing = rings[ring];
|
|
1783
|
+
// Ensure minimum arc spacing between nodes on each ring
|
|
1784
|
+
var minArcGap = 30;
|
|
1785
|
+
var minRadius = (nodesInRing.length * minArcGap) / (2 * Math.PI);
|
|
1786
|
+
var radius = Math.max((ringIndex + 1) * baseSpacing, minRadius);
|
|
1787
|
+
nodesInRing.forEach(function (d, i) {
|
|
1788
|
+
var angle = (2 * Math.PI * i) / nodesInRing.length;
|
|
1789
|
+
d.x = cx + radius * Math.cos(angle);
|
|
1790
|
+
d.y = cy + radius * Math.sin(angle);
|
|
1791
|
+
d.fx = d.x;
|
|
1792
|
+
d.fy = d.y;
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
// Re-render with fixed positions
|
|
1797
|
+
renderGraph();
|
|
1798
|
+
setTimeout(function () { fitToView(); }, 200);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function applyCommunitiesLayout() {
|
|
1802
|
+
isStaticLayout = false;
|
|
1803
|
+
var params = new URLSearchParams();
|
|
1804
|
+
if (window.laminarkState && window.laminarkState.currentProject) {
|
|
1805
|
+
params.set('project', window.laminarkState.currentProject);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
fetch('/api/graph/communities' + (params.toString() ? '?' + params.toString() : ''))
|
|
1809
|
+
.then(function (res) { return res.json(); })
|
|
1810
|
+
.then(function (data) {
|
|
1811
|
+
if (data.communities) {
|
|
1812
|
+
applyCommunityColors(data.communities);
|
|
1813
|
+
|
|
1814
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
1815
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
1816
|
+
var cx = width / 2;
|
|
1817
|
+
var cy = height / 2;
|
|
1818
|
+
|
|
1819
|
+
// Arrange community centers in a circle
|
|
1820
|
+
var communities = data.communities;
|
|
1821
|
+
var commRadius = Math.min(width, height) * 0.3;
|
|
1822
|
+
|
|
1823
|
+
communities.forEach(function (comm, i) {
|
|
1824
|
+
var angle = (2 * Math.PI * i) / communities.length;
|
|
1825
|
+
var commCx = cx + commRadius * Math.cos(angle);
|
|
1826
|
+
var commCy = cy + commRadius * Math.sin(angle);
|
|
1827
|
+
comm.nodeIds.forEach(function (nodeId) {
|
|
1828
|
+
var node = nodeData.find(function (d) { return d.id === nodeId; });
|
|
1829
|
+
if (node) {
|
|
1830
|
+
// Set initial position near community center with some jitter
|
|
1831
|
+
node.x = commCx + (Math.random() - 0.5) * 60;
|
|
1832
|
+
node.y = commCy + (Math.random() - 0.5) * 60;
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Reset fixed positions and re-render
|
|
1839
|
+
nodeData.forEach(function (d) { d.fx = null; d.fy = null; });
|
|
1840
|
+
renderGraph();
|
|
1841
|
+
setTimeout(function () { fitToView(); }, 800);
|
|
1842
|
+
})
|
|
1843
|
+
.catch(function (err) {
|
|
1844
|
+
console.error('[laminark:graph] Failed to fetch communities:', err);
|
|
1845
|
+
applyClusteredLayout();
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function initLayoutSelector() {
|
|
1850
|
+
var btns = document.querySelectorAll('.layout-btn');
|
|
1851
|
+
btns.forEach(function (btn) {
|
|
1852
|
+
btn.classList.toggle('active', btn.getAttribute('data-layout') === currentLayout);
|
|
1853
|
+
btn.addEventListener('click', function () {
|
|
1854
|
+
setLayout(btn.getAttribute('data-layout'));
|
|
1855
|
+
});
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// ---------------------------------------------------------------------------
|
|
1860
|
+
// Community coloring
|
|
1861
|
+
// ---------------------------------------------------------------------------
|
|
1862
|
+
|
|
1863
|
+
function applyCommunityColors(communities) {
|
|
1864
|
+
communityNodeMap = {};
|
|
1865
|
+
communityColorMap = {};
|
|
1866
|
+
communities.forEach(function (comm) {
|
|
1867
|
+
comm.nodeIds.forEach(function (nodeId) {
|
|
1868
|
+
communityNodeMap[nodeId] = comm.id;
|
|
1869
|
+
communityColorMap[nodeId] = comm.color;
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
// Update node colors
|
|
1874
|
+
if (nodesGroup) {
|
|
1875
|
+
nodesGroup.selectAll('.node-group path.node-shape')
|
|
1876
|
+
.attr('fill', function (d) {
|
|
1877
|
+
if (communityColorMap[d.id]) return communityColorMap[d.id];
|
|
1878
|
+
return ENTITY_STYLES[d.type] ? ENTITY_STYLES[d.type].color : '#8b949e';
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function clearCommunityColors() {
|
|
1884
|
+
communityNodeMap = {};
|
|
1885
|
+
communityColorMap = {};
|
|
1886
|
+
if (nodesGroup) {
|
|
1887
|
+
nodesGroup.selectAll('.node-group path.node-shape')
|
|
1888
|
+
.attr('fill', function (d) {
|
|
1889
|
+
return ENTITY_STYLES[d.type] ? ENTITY_STYLES[d.type].color : '#8b949e';
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// ---------------------------------------------------------------------------
|
|
1895
|
+
// Show linked nodes of type (context menu action)
|
|
1896
|
+
// ---------------------------------------------------------------------------
|
|
1897
|
+
|
|
1898
|
+
async function showLinkedNodesOfType(nodeId, nodeLabel, filterType) {
|
|
1899
|
+
if (!svg || _focusFetching) return;
|
|
1900
|
+
|
|
1901
|
+
if (!isFocusMode) {
|
|
1902
|
+
cachedFullData = {
|
|
1903
|
+
nodes: nodeData.map(function (d) {
|
|
1904
|
+
var copy = Object.assign({}, d);
|
|
1905
|
+
delete copy.x; delete copy.y; delete copy.vx; delete copy.vy;
|
|
1906
|
+
delete copy.fx; delete copy.fy; delete copy.index;
|
|
1907
|
+
return copy;
|
|
1908
|
+
}),
|
|
1909
|
+
edges: edgeData.map(function (d) {
|
|
1910
|
+
return {
|
|
1911
|
+
id: d.id,
|
|
1912
|
+
source: typeof d.source === 'object' ? d.source.id : d.source,
|
|
1913
|
+
target: typeof d.target === 'object' ? d.target.id : d.target,
|
|
1914
|
+
type: d.type,
|
|
1915
|
+
label: d.label,
|
|
1916
|
+
};
|
|
1917
|
+
}),
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
var data;
|
|
1922
|
+
try {
|
|
1923
|
+
var res = await fetch('/api/node/' + encodeURIComponent(nodeId) + '/neighborhood?depth=1');
|
|
1924
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1925
|
+
data = await res.json();
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
console.error('[laminark:graph] Failed to fetch neighborhood:', err);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (!data.nodes || data.nodes.length === 0) return;
|
|
1932
|
+
|
|
1933
|
+
var keepIds = new Set();
|
|
1934
|
+
keepIds.add(nodeId);
|
|
1935
|
+
data.nodes.forEach(function (n) {
|
|
1936
|
+
if (n.id === nodeId || n.type === filterType) keepIds.add(n.id);
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
var filteredNodes = data.nodes.filter(function (n) { return keepIds.has(n.id); });
|
|
1940
|
+
if (filteredNodes.length <= 1) {
|
|
1941
|
+
console.log('[laminark:graph] No linked ' + filterType + ' nodes found');
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
isFocusMode = true;
|
|
1946
|
+
focusStack.push({ nodeId: nodeId, label: nodeLabel + ' \u2192 ' + filterType });
|
|
1947
|
+
|
|
1948
|
+
nodeData = filteredNodes.map(function (node) {
|
|
1949
|
+
return {
|
|
1950
|
+
id: node.id,
|
|
1951
|
+
label: node.label,
|
|
1952
|
+
type: node.type,
|
|
1953
|
+
observationCount: node.observationCount || 0,
|
|
1954
|
+
createdAt: node.createdAt,
|
|
1955
|
+
hidden: false,
|
|
1956
|
+
};
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
edgeData = data.edges
|
|
1960
|
+
.filter(function (edge) { return keepIds.has(edge.source) && keepIds.has(edge.target); })
|
|
1961
|
+
.map(function (edge) {
|
|
1962
|
+
return { id: edge.id, source: edge.source, target: edge.target, type: edge.type, label: edge.type };
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
renderGraph();
|
|
1966
|
+
setTimeout(function () { fitToView(); }, 600);
|
|
1967
|
+
|
|
1968
|
+
updateBreadcrumbs();
|
|
1969
|
+
updateGraphStatsFromData();
|
|
1970
|
+
console.log('[laminark:graph] Show linked: ' + filterType + ' from', nodeLabel,
|
|
1971
|
+
'(' + filteredNodes.length + ' nodes)');
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// ---------------------------------------------------------------------------
|
|
1975
|
+
// Context menu
|
|
1976
|
+
// ---------------------------------------------------------------------------
|
|
1977
|
+
|
|
1978
|
+
function initContextMenu() {
|
|
1979
|
+
if (!containerEl) return;
|
|
1980
|
+
|
|
1981
|
+
contextMenuEl = document.createElement('div');
|
|
1982
|
+
contextMenuEl.className = 'graph-context-menu hidden';
|
|
1983
|
+
containerEl.appendChild(contextMenuEl);
|
|
1984
|
+
|
|
1985
|
+
document.addEventListener('mousedown', function (e) {
|
|
1986
|
+
if (contextMenuVisible && contextMenuEl && !contextMenuEl.contains(e.target)) {
|
|
1987
|
+
hideContextMenu();
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
document.addEventListener('keydown', function (e) {
|
|
1992
|
+
if (e.key === 'Escape' && contextMenuVisible) hideContextMenu();
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function showContextMenu(x, y, items) {
|
|
1997
|
+
if (!contextMenuEl) return;
|
|
1998
|
+
|
|
1999
|
+
var html = '';
|
|
2000
|
+
items.forEach(function (item) {
|
|
2001
|
+
if (item.type === 'header') {
|
|
2002
|
+
html += '<div class="context-menu-header">' + escapeHtml(item.label) + '</div>';
|
|
2003
|
+
} else if (item.type === 'divider') {
|
|
2004
|
+
html += '<div class="context-menu-divider"></div>';
|
|
2005
|
+
} else if (item.type === 'item') {
|
|
2006
|
+
var dot = item.color
|
|
2007
|
+
? '<span class="type-dot" style="background:' + item.color + '"></span>'
|
|
2008
|
+
: '';
|
|
2009
|
+
html += '<div class="context-menu-item" data-action="' + escapeHtml(item.action) + '">'
|
|
2010
|
+
+ dot + escapeHtml(item.label) + '</div>';
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
contextMenuEl.innerHTML = html;
|
|
2014
|
+
|
|
2015
|
+
contextMenuEl.onclick = function (e) {
|
|
2016
|
+
var target = e.target.closest('.context-menu-item');
|
|
2017
|
+
if (target) {
|
|
2018
|
+
var action = target.getAttribute('data-action');
|
|
2019
|
+
var savedTarget = contextMenuTargetNode;
|
|
2020
|
+
hideContextMenu();
|
|
2021
|
+
contextMenuTargetNode = savedTarget;
|
|
2022
|
+
handleContextMenuAction(action);
|
|
2023
|
+
contextMenuTargetNode = null;
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
|
|
2027
|
+
contextMenuEl.classList.remove('hidden');
|
|
2028
|
+
contextMenuVisible = true;
|
|
2029
|
+
|
|
2030
|
+
var rect = contextMenuEl.getBoundingClientRect();
|
|
2031
|
+
var vw = window.innerWidth;
|
|
2032
|
+
var vh = window.innerHeight;
|
|
2033
|
+
if (x + rect.width > vw) x = vw - rect.width - 8;
|
|
2034
|
+
if (y + rect.height > vh) y = vh - rect.height - 8;
|
|
2035
|
+
if (x < 0) x = 8;
|
|
2036
|
+
if (y < 0) y = 8;
|
|
2037
|
+
|
|
2038
|
+
contextMenuEl.style.left = x + 'px';
|
|
2039
|
+
contextMenuEl.style.top = y + 'px';
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function escapeHtml(str) {
|
|
2043
|
+
var div = document.createElement('div');
|
|
2044
|
+
div.textContent = str;
|
|
2045
|
+
return div.innerHTML;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function hideContextMenu() {
|
|
2049
|
+
if (contextMenuEl) contextMenuEl.classList.add('hidden');
|
|
2050
|
+
contextMenuVisible = false;
|
|
2051
|
+
contextMenuTargetNode = null;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function handleContextMenuAction(action) {
|
|
2055
|
+
if (!action) return;
|
|
2056
|
+
|
|
2057
|
+
if (action.startsWith('filter-type:')) {
|
|
2058
|
+
var type = action.split(':')[1];
|
|
2059
|
+
setActiveTypes([type]);
|
|
2060
|
+
syncFilterPills();
|
|
2061
|
+
} else if (action.startsWith('show-linked:')) {
|
|
2062
|
+
var filterType = action.split(':')[1];
|
|
2063
|
+
if (contextMenuTargetNode) {
|
|
2064
|
+
showLinkedNodesOfType(contextMenuTargetNode.id, contextMenuTargetNode.label, filterType);
|
|
2065
|
+
}
|
|
2066
|
+
} else if (action === 'focus') {
|
|
2067
|
+
if (contextMenuTargetNode) {
|
|
2068
|
+
enterFocusMode(contextMenuTargetNode.id, contextMenuTargetNode.label);
|
|
2069
|
+
}
|
|
2070
|
+
} else if (action === 'relayout') {
|
|
2071
|
+
setLayout(currentLayout);
|
|
2072
|
+
} else if (action === 'reset-filters') {
|
|
2073
|
+
resetFilters();
|
|
2074
|
+
syncFilterPills();
|
|
2075
|
+
} else if (action === 'fit') {
|
|
2076
|
+
fitToView();
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function syncFilterPills() {
|
|
2081
|
+
var allTypes = Object.keys(ENTITY_STYLES);
|
|
2082
|
+
var allActive = activeEntityTypes.size === allTypes.length;
|
|
2083
|
+
|
|
2084
|
+
allTypes.forEach(function (type) {
|
|
2085
|
+
var pill = document.querySelector('.filter-pill[data-type="' + type + '"]');
|
|
2086
|
+
if (pill) pill.classList.toggle('active', activeEntityTypes.has(type));
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
var allPill = document.querySelector('.filter-pill[data-type="all"]');
|
|
2090
|
+
if (allPill) allPill.classList.toggle('active', allActive);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// Initialize layout selector when DOM is ready
|
|
2094
|
+
if (document.readyState === 'loading') {
|
|
2095
|
+
document.addEventListener('DOMContentLoaded', initLayoutSelector);
|
|
2096
|
+
} else {
|
|
2097
|
+
initLayoutSelector();
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// ---------------------------------------------------------------------------
|
|
2101
|
+
// Path overlay
|
|
2102
|
+
// ---------------------------------------------------------------------------
|
|
2103
|
+
|
|
2104
|
+
async function loadPathOverlay() {
|
|
2105
|
+
if (!svg || !pathOverlayVisible) return;
|
|
2106
|
+
|
|
2107
|
+
try {
|
|
2108
|
+
var params = new URLSearchParams();
|
|
2109
|
+
if (window.laminarkState && window.laminarkState.currentProject) {
|
|
2110
|
+
params.set('project', window.laminarkState.currentProject);
|
|
2111
|
+
}
|
|
2112
|
+
params.set('limit', '10');
|
|
2113
|
+
var url = '/api/paths' + (params.toString() ? '?' + params.toString() : '');
|
|
2114
|
+
var res = await fetch(url);
|
|
2115
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
2116
|
+
var data = await res.json();
|
|
2117
|
+
pathData = (data.paths || []).filter(function(p) { return p.status === 'active' || p.status === 'resolved'; });
|
|
2118
|
+
|
|
2119
|
+
// For each path, fetch waypoints
|
|
2120
|
+
for (var i = 0; i < pathData.length; i++) {
|
|
2121
|
+
try {
|
|
2122
|
+
var detailRes = await fetch('/api/paths/' + encodeURIComponent(pathData[i].id));
|
|
2123
|
+
if (detailRes.ok) {
|
|
2124
|
+
var detail = await detailRes.json();
|
|
2125
|
+
pathData[i].waypoints = detail.waypoints || [];
|
|
2126
|
+
}
|
|
2127
|
+
} catch (e) { pathData[i].waypoints = []; }
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
renderPathOverlay();
|
|
2131
|
+
} catch (err) {
|
|
2132
|
+
console.error('[laminark:graph] Failed to load path overlay:', err);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function renderPathOverlay() {
|
|
2137
|
+
if (!pathOverlayGroup || !pathOverlayVisible) {
|
|
2138
|
+
if (pathOverlayGroup) pathOverlayGroup.style('display', 'none');
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
pathOverlayGroup.style('display', null);
|
|
2142
|
+
pathOverlayGroup.selectAll('*').remove();
|
|
2143
|
+
|
|
2144
|
+
if (pathData.length === 0) return;
|
|
2145
|
+
|
|
2146
|
+
var width = containerEl ? containerEl.clientWidth : 800;
|
|
2147
|
+
var height = containerEl ? containerEl.clientHeight : 600;
|
|
2148
|
+
|
|
2149
|
+
// Get current transform to position in screen space
|
|
2150
|
+
var transform = d3.zoomTransform(svg.node());
|
|
2151
|
+
|
|
2152
|
+
pathData.forEach(function(path, pathIndex) {
|
|
2153
|
+
if (!path.waypoints || path.waypoints.length === 0) return;
|
|
2154
|
+
|
|
2155
|
+
var pathGroup = pathOverlayGroup.append('g')
|
|
2156
|
+
.attr('class', 'path-trail')
|
|
2157
|
+
.attr('data-path-id', path.id);
|
|
2158
|
+
|
|
2159
|
+
// Position waypoints evenly spaced along a line
|
|
2160
|
+
var waypoints = path.waypoints;
|
|
2161
|
+
var margin = 80;
|
|
2162
|
+
var yBase = (height - 60) / transform.k - transform.y / transform.k;
|
|
2163
|
+
var xStart = (-transform.x / transform.k) + margin / transform.k;
|
|
2164
|
+
var xEnd = (-transform.x / transform.k) + (width - margin) / transform.k;
|
|
2165
|
+
var spacing = waypoints.length > 1 ? (xEnd - xStart) / (waypoints.length - 1) : 0;
|
|
2166
|
+
|
|
2167
|
+
var points = waypoints.map(function(wp, i) {
|
|
2168
|
+
return { x: xStart + i * spacing, y: yBase + pathIndex * 40 / transform.k };
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
// Draw connecting line (animated dashed)
|
|
2172
|
+
if (points.length > 1) {
|
|
2173
|
+
var lineGen = d3.line()
|
|
2174
|
+
.x(function(d) { return d.x; })
|
|
2175
|
+
.y(function(d) { return d.y; })
|
|
2176
|
+
.curve(d3.curveCatmullRom.alpha(0.5));
|
|
2177
|
+
|
|
2178
|
+
pathGroup.append('path')
|
|
2179
|
+
.attr('class', 'path-line' + (path.status === 'active' ? ' path-line-active' : ''))
|
|
2180
|
+
.attr('d', lineGen(points))
|
|
2181
|
+
.attr('fill', 'none')
|
|
2182
|
+
.attr('stroke', path.status === 'resolved' ? '#3fb950' : '#d29922')
|
|
2183
|
+
.attr('stroke-width', 2.5 / transform.k)
|
|
2184
|
+
.attr('stroke-dasharray', (6 / transform.k) + ' ' + (4 / transform.k))
|
|
2185
|
+
.attr('opacity', 0.8);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// Draw waypoint markers
|
|
2189
|
+
points.forEach(function(pt, i) {
|
|
2190
|
+
var wp = waypoints[i];
|
|
2191
|
+
var color = WAYPOINT_TYPE_COLORS[wp.waypoint_type] || '#8b949e';
|
|
2192
|
+
var radius = 6 / transform.k;
|
|
2193
|
+
|
|
2194
|
+
var marker = pathGroup.append('g')
|
|
2195
|
+
.attr('class', 'waypoint-marker')
|
|
2196
|
+
.attr('transform', 'translate(' + pt.x + ',' + pt.y + ')')
|
|
2197
|
+
.style('cursor', 'pointer');
|
|
2198
|
+
|
|
2199
|
+
marker.append('circle')
|
|
2200
|
+
.attr('r', radius)
|
|
2201
|
+
.attr('fill', color)
|
|
2202
|
+
.attr('stroke', '#0d1117')
|
|
2203
|
+
.attr('stroke-width', 1.5 / transform.k);
|
|
2204
|
+
|
|
2205
|
+
// Sequence number
|
|
2206
|
+
marker.append('text')
|
|
2207
|
+
.attr('text-anchor', 'middle')
|
|
2208
|
+
.attr('dominant-baseline', 'central')
|
|
2209
|
+
.attr('font-size', (8 / transform.k) + 'px')
|
|
2210
|
+
.attr('fill', '#fff')
|
|
2211
|
+
.attr('font-weight', '700')
|
|
2212
|
+
.attr('pointer-events', 'none')
|
|
2213
|
+
.text(wp.sequence_order);
|
|
2214
|
+
|
|
2215
|
+
// Tooltip on hover
|
|
2216
|
+
marker.append('title')
|
|
2217
|
+
.text(wp.waypoint_type + ': ' + (wp.summary || '').substring(0, 80));
|
|
2218
|
+
|
|
2219
|
+
// Click to show path detail
|
|
2220
|
+
marker.on('click', function(event) {
|
|
2221
|
+
event.stopPropagation();
|
|
2222
|
+
if (window.laminarkApp && window.laminarkApp.fetchPathDetail) {
|
|
2223
|
+
window.laminarkApp.fetchPathDetail(path.id).then(function(detail) {
|
|
2224
|
+
if (detail) {
|
|
2225
|
+
document.dispatchEvent(new CustomEvent('laminark:show_path_detail', { detail: detail }));
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
// Path label
|
|
2233
|
+
if (points.length > 0) {
|
|
2234
|
+
var labelX = points[0].x;
|
|
2235
|
+
var labelY = points[0].y - 12 / transform.k;
|
|
2236
|
+
pathGroup.append('text')
|
|
2237
|
+
.attr('class', 'path-label')
|
|
2238
|
+
.attr('x', labelX)
|
|
2239
|
+
.attr('y', labelY)
|
|
2240
|
+
.attr('font-size', (10 / transform.k) + 'px')
|
|
2241
|
+
.attr('fill', path.status === 'resolved' ? '#3fb950' : '#d29922')
|
|
2242
|
+
.attr('opacity', 0.9)
|
|
2243
|
+
.text((path.trigger_summary || 'Debug Path').substring(0, 40));
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function addPathOverlay(pathEvent) {
|
|
2249
|
+
loadPathOverlay();
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function updatePathOverlay(waypointEvent) {
|
|
2253
|
+
loadPathOverlay();
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function resolvePathOverlay(resolveEvent) {
|
|
2257
|
+
loadPathOverlay();
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function initPathOverlayToggle() {
|
|
2261
|
+
var btn = document.getElementById('paths-toggle-btn');
|
|
2262
|
+
if (!btn) return;
|
|
2263
|
+
|
|
2264
|
+
btn.classList.toggle('active', pathOverlayVisible);
|
|
2265
|
+
|
|
2266
|
+
btn.addEventListener('click', function() {
|
|
2267
|
+
pathOverlayVisible = !pathOverlayVisible;
|
|
2268
|
+
localStorage.setItem('laminark-path-overlay', pathOverlayVisible ? 'true' : 'false');
|
|
2269
|
+
btn.classList.toggle('active', pathOverlayVisible);
|
|
2270
|
+
|
|
2271
|
+
if (pathOverlayVisible) {
|
|
2272
|
+
loadPathOverlay();
|
|
2273
|
+
} else {
|
|
2274
|
+
if (pathOverlayGroup) pathOverlayGroup.style('display', 'none');
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// ---------------------------------------------------------------------------
|
|
2280
|
+
// Exports
|
|
2281
|
+
// ---------------------------------------------------------------------------
|
|
2282
|
+
|
|
2283
|
+
window.laminarkGraph = {
|
|
2284
|
+
initGraph: initGraph,
|
|
2285
|
+
loadGraphData: loadGraphData,
|
|
2286
|
+
addNode: addNode,
|
|
2287
|
+
addEdge: addEdge,
|
|
2288
|
+
removeElements: removeElements,
|
|
2289
|
+
fitToView: fitToView,
|
|
2290
|
+
applyFilter: applyFilter,
|
|
2291
|
+
filterByType: filterByType,
|
|
2292
|
+
filterByTimeRange: filterByTimeRange,
|
|
2293
|
+
resetFilters: resetFilters,
|
|
2294
|
+
setActiveTypes: setActiveTypes,
|
|
2295
|
+
getTypeCounts: getTypeCounts,
|
|
2296
|
+
updateFilterCounts: updateFilterCounts,
|
|
2297
|
+
hideDetailPanel: hideDetailPanel,
|
|
2298
|
+
selectAndCenterNode: selectAndCenterNode,
|
|
2299
|
+
queueBatchUpdate: queueBatchUpdate,
|
|
2300
|
+
togglePerfOverlay: togglePerfOverlay,
|
|
2301
|
+
enterFocusMode: enterFocusMode,
|
|
2302
|
+
exitFocusMode: exitFocusMode,
|
|
2303
|
+
setLayout: setLayout,
|
|
2304
|
+
isFocusMode: function () { return isFocusMode; },
|
|
2305
|
+
ENTITY_STYLES: ENTITY_STYLES,
|
|
2306
|
+
getCy: function () { return null; }, // Compatibility stub (no longer Cytoscape)
|
|
2307
|
+
searchNodes: searchNodes,
|
|
2308
|
+
highlightSearchMatches: highlightSearchMatches,
|
|
2309
|
+
clearSearchHighlight: clearSearchHighlight,
|
|
2310
|
+
highlightCluster: highlightCluster,
|
|
2311
|
+
applyCommunityColors: applyCommunityColors,
|
|
2312
|
+
clearCommunityColors: clearCommunityColors,
|
|
2313
|
+
showLinkedNodesOfType: showLinkedNodesOfType,
|
|
2314
|
+
hideContextMenu: hideContextMenu,
|
|
2315
|
+
addPathOverlay: addPathOverlay,
|
|
2316
|
+
updatePathOverlay: updatePathOverlay,
|
|
2317
|
+
resolvePathOverlay: resolvePathOverlay,
|
|
2318
|
+
loadPathOverlay: loadPathOverlay,
|
|
2319
|
+
isPathOverlayVisible: function() { return pathOverlayVisible; },
|
|
2320
|
+
toggleEdgeLabels: function (type) {
|
|
2321
|
+
if (type) {
|
|
2322
|
+
if (hiddenEdgeLabelTypes.has(type)) hiddenEdgeLabelTypes.delete(type);
|
|
2323
|
+
else hiddenEdgeLabelTypes.add(type);
|
|
2324
|
+
persistHiddenEdgeTypes();
|
|
2325
|
+
} else {
|
|
2326
|
+
edgeLabelsVisible = !edgeLabelsVisible;
|
|
2327
|
+
localStorage.setItem('laminark-edge-labels', edgeLabelsVisible ? 'true' : 'false');
|
|
2328
|
+
var btn = document.getElementById('edge-labels-btn');
|
|
2329
|
+
if (btn) btn.classList.toggle('active', edgeLabelsVisible);
|
|
2330
|
+
}
|
|
2331
|
+
applyEdgeLabelVisibility();
|
|
2332
|
+
},
|
|
2333
|
+
};
|