laminark 0.1.0

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