laminark 2.21.6

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