laminark 2.21.7 → 2.21.9

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 (52) hide show
  1. package/README.md +36 -71
  2. package/package.json +9 -7
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/CLAUDE.md +10 -0
  5. package/plugin/commands/recall.md +55 -0
  6. package/plugin/commands/remember.md +34 -0
  7. package/plugin/commands/resume.md +45 -0
  8. package/plugin/commands/stash.md +34 -0
  9. package/plugin/commands/status.md +33 -0
  10. package/plugin/dist/hooks/handler.d.ts +3 -1
  11. package/plugin/dist/hooks/handler.d.ts.map +1 -1
  12. package/plugin/dist/hooks/handler.js +312 -23
  13. package/plugin/dist/hooks/handler.js.map +1 -1
  14. package/plugin/dist/index.d.ts +3 -1
  15. package/plugin/dist/index.d.ts.map +1 -1
  16. package/plugin/dist/index.js +2111 -525
  17. package/plugin/dist/index.js.map +1 -1
  18. package/plugin/dist/{observations-Ch0nc47i.d.mts → observations-CorAAc1A.d.mts} +23 -1
  19. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  20. package/plugin/dist/{tool-registry-CZ3mJ4iR.mjs → tool-registry-D8un_AcG.mjs} +932 -13
  21. package/plugin/dist/tool-registry-D8un_AcG.mjs.map +1 -0
  22. package/plugin/hooks/hooks.json +6 -6
  23. package/plugin/laminark.db +0 -0
  24. package/plugin/package.json +17 -0
  25. package/plugin/scripts/README.md +19 -1
  26. package/plugin/scripts/bump-version.sh +24 -19
  27. package/plugin/scripts/dev-sync.sh +58 -0
  28. package/plugin/scripts/ensure-deps.sh +5 -2
  29. package/plugin/scripts/install.sh +115 -39
  30. package/plugin/scripts/local-install.sh +93 -58
  31. package/plugin/scripts/uninstall.sh +76 -38
  32. package/plugin/scripts/update.sh +20 -69
  33. package/plugin/scripts/verify-install.sh +69 -25
  34. package/plugin/ui/activity.js +12 -0
  35. package/plugin/ui/app.js +24 -54
  36. package/plugin/ui/graph.js +413 -186
  37. package/plugin/ui/help/activity-feed.png +0 -0
  38. package/plugin/ui/help/analysis-panel.png +0 -0
  39. package/plugin/ui/help/graph-toolbar.png +0 -0
  40. package/plugin/ui/help/graph-view.png +0 -0
  41. package/plugin/ui/help/settings.png +0 -0
  42. package/plugin/ui/help/timeline.png +0 -0
  43. package/plugin/ui/help.js +876 -172
  44. package/plugin/ui/index.html +506 -242
  45. package/plugin/ui/settings.js +781 -17
  46. package/plugin/ui/styles.css +990 -44
  47. package/plugin/ui/timeline.js +2 -2
  48. package/plugin/ui/tools.js +826 -0
  49. package/.claude-plugin/marketplace.json +0 -15
  50. package/plugin/dist/observations-Ch0nc47i.d.mts.map +0 -1
  51. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +0 -1
  52. package/plugin/scripts/setup-tmpdir.sh +0 -65
@@ -31,17 +31,8 @@ const ENTITY_STYLES = {
31
31
  Reference: { color: '#f0883e', shape: 'hexagon' },
32
32
  };
33
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
- };
34
+ // Pathfinder highlight color
35
+ var PATHFINDER_COLOR = '#f0883e';
45
36
 
46
37
  // Relationship type colors for edge coloring
47
38
  var EDGE_TYPE_COLORS = {
@@ -197,10 +188,17 @@ var hiddenEdgeLabelTypes = new Set(
197
188
  JSON.parse(localStorage.getItem('laminark-hidden-edge-types') || '[]')
198
189
  );
199
190
 
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?}] }
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;
204
202
 
205
203
  // ---------------------------------------------------------------------------
206
204
  // initGraph
@@ -252,7 +250,6 @@ function initGraph(containerId) {
252
250
  // Layer groups in paint order (back to front)
253
251
  edgesGroup = svgG.append('g').attr('class', 'edges-group');
254
252
  edgeLabelsGroup = svgG.append('g').attr('class', 'edge-labels-group');
255
- pathOverlayGroup = svgG.append('g').attr('class', 'path-overlay-group');
256
253
  nodesGroup = svgG.append('g').attr('class', 'nodes-group');
257
254
  nodeLabelsGroup = svgG.append('g').attr('class', 'node-labels-group');
258
255
 
@@ -263,16 +260,16 @@ function initGraph(containerId) {
263
260
  svgG.attr('transform', event.transform);
264
261
  currentZoom = event.transform.k;
265
262
  updateLevelOfDetail();
266
- renderPathOverlay();
267
263
  });
268
264
  svg.call(zoomBehavior);
269
265
 
270
- // Background click: deselect + hide detail panel
266
+ // Background click: deselect + hide detail panel + clear pathfinder
271
267
  svg.on('click', function (event) {
272
268
  if (event.target === svg.node() || event.target.closest('.graph-zoom-group') === svgG.node() && !event.target.closest('.node-group')) {
273
269
  hideDetailPanel();
274
270
  selectedNodeId = null;
275
271
  if (nodesGroup) nodesGroup.selectAll('.node-group').classed('selected', false);
272
+ clearPathfinder();
276
273
  }
277
274
  });
278
275
 
@@ -305,7 +302,9 @@ function initGraph(containerId) {
305
302
 
306
303
  initContextMenu();
307
304
  initEdgeLabelToggle();
308
- initPathOverlayToggle();
305
+ initPathfinderToggle();
306
+ initFreezeButton();
307
+ initSyncButton();
309
308
 
310
309
  // Create tooltip element
311
310
  tooltipEl = document.createElement('div');
@@ -354,8 +353,11 @@ function createSimulation() {
354
353
  var width = containerEl ? containerEl.clientWidth : 800;
355
354
  var height = containerEl ? containerEl.clientHeight : 600;
356
355
 
356
+ var simNodeIds = new Set(nodeData.filter(function (d) { return !d.hidden; }).map(function (d) { return d.id; }));
357
357
  var visibleEdges = edgeData.filter(function (d) {
358
- return !d.source.hidden && !d.target.hidden;
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);
359
361
  });
360
362
 
361
363
  // Degree-scaled repulsion: more links = stronger push away
@@ -595,6 +597,12 @@ function dragEnded(event, d) {
595
597
  // ---------------------------------------------------------------------------
596
598
 
597
599
  async function handleNodeClick(d) {
600
+ // Pathfinder mode: collect start/end nodes
601
+ if (pathfinderActive) {
602
+ handlePathfinderClick(d);
603
+ return;
604
+ }
605
+
598
606
  selectedNodeId = d.id;
599
607
  if (nodesGroup) {
600
608
  nodesGroup.selectAll('.node-group').classed('selected', function (n) { return n.id === d.id; });
@@ -658,6 +666,15 @@ async function loadGraphData(filters) {
658
666
  return { nodeCount: nodeData.length, edgeCount: edgeData.length };
659
667
  }
660
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
+
661
678
  var data;
662
679
  if (window.laminarkApp && window.laminarkApp.fetchGraphData) {
663
680
  data = await window.laminarkApp.fetchGraphData(filters);
@@ -682,11 +699,11 @@ async function loadGraphData(filters) {
682
699
  edgeData = [];
683
700
  renderGraph();
684
701
  updateGraphStats(0, 0);
685
- showEmptyState();
702
+ showGraphEmptyState();
686
703
  return { nodeCount: 0, edgeCount: 0 };
687
704
  }
688
705
 
689
- hideEmptyState();
706
+ hideGraphEmptyState();
690
707
 
691
708
  // Build data arrays
692
709
  nodeData = data.nodes.map(function (node) {
@@ -727,11 +744,6 @@ async function loadGraphData(filters) {
727
744
  setTimeout(function () { fitToView(); }, 800);
728
745
  }
729
746
 
730
- // Load path overlay after graph data
731
- if (pathOverlayVisible) {
732
- setTimeout(function () { loadPathOverlay(); }, 1000);
733
- }
734
-
735
747
  var counts = { nodeCount: data.nodes.length, edgeCount: data.edges.length };
736
748
  updateGraphStats(counts.nodeCount, counts.edgeCount);
737
749
  console.log('[laminark:graph] Loaded', counts.nodeCount, 'nodes,', counts.edgeCount, 'edges');
@@ -757,7 +769,7 @@ function addNode(nodeDataIn) {
757
769
  createdAt: nodeDataIn.createdAt,
758
770
  hidden: false,
759
771
  });
760
- hideEmptyState();
772
+ hideGraphEmptyState();
761
773
  }
762
774
 
763
775
  renderGraph();
@@ -804,7 +816,7 @@ function removeElements(ids) {
804
816
  renderGraph();
805
817
  updateGraphStatsFromData();
806
818
 
807
- if (nodeData.length === 0) showEmptyState();
819
+ if (nodeData.length === 0) showGraphEmptyState();
808
820
  }
809
821
 
810
822
  // ---------------------------------------------------------------------------
@@ -967,7 +979,7 @@ function updateFilterCounts() {
967
979
  // Empty state
968
980
  // ---------------------------------------------------------------------------
969
981
 
970
- function showEmptyState() {
982
+ function showGraphEmptyState() {
971
983
  if (!containerEl) return;
972
984
  var existing = containerEl.querySelector('.graph-empty-state');
973
985
  if (existing) { existing.style.display = ''; return; }
@@ -978,7 +990,7 @@ function showEmptyState() {
978
990
  containerEl.appendChild(msg);
979
991
  }
980
992
 
981
- function hideEmptyState() {
993
+ function hideGraphEmptyState() {
982
994
  if (!containerEl) return;
983
995
  var existing = containerEl.querySelector('.graph-empty-state');
984
996
  if (existing) existing.style.display = 'none';
@@ -1179,9 +1191,13 @@ function initEdgeLabelToggle() {
1179
1191
  dropdown.appendChild(row);
1180
1192
  });
1181
1193
 
1182
- // Insert dropdown after button
1183
- btn.parentElement.style.position = 'relative';
1184
- btn.insertAdjacentElement('afterend', dropdown);
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);
1185
1201
 
1186
1202
  // Toggle dropdown on click
1187
1203
  btn.addEventListener('click', function (e) {
@@ -1407,6 +1423,12 @@ function queueBatchUpdate(update) {
1407
1423
  batchFlushTimer = setTimeout(flushBatchUpdates, BATCH_DELAY_MS);
1408
1424
  }
1409
1425
 
1426
+ function clearBatchQueue() {
1427
+ if (batchFlushTimer) clearTimeout(batchFlushTimer);
1428
+ batchFlushTimer = null;
1429
+ batchQueue = [];
1430
+ }
1431
+
1410
1432
  function flushBatchUpdates() {
1411
1433
  if (!svg || batchQueue.length === 0) return;
1412
1434
 
@@ -1450,7 +1472,14 @@ function flushBatchUpdates() {
1450
1472
  batchFlushTimer = null;
1451
1473
 
1452
1474
  if (newNodes > 0 || newEdges > 0) {
1453
- hideEmptyState();
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();
1454
1483
  renderGraph();
1455
1484
  if (!isStaticLayout && simulation) simulation.alpha(0.3).restart();
1456
1485
  console.log('[laminark:graph] Batch update: added ' + newNodes + ' nodes, ' + newEdges + ' edges');
@@ -1612,7 +1641,7 @@ function updateBreadcrumbs() {
1612
1641
  // ---------------------------------------------------------------------------
1613
1642
 
1614
1643
  function setLayout(layoutName) {
1615
- var validLayouts = ['clustered', 'hierarchical', 'concentric', 'communities'];
1644
+ var validLayouts = ['clustered', 'hierarchical', 'concentric', 'communities', 'detangle'];
1616
1645
  if (validLayouts.indexOf(layoutName) === -1) return;
1617
1646
 
1618
1647
  var previousLayout = currentLayout;
@@ -1635,6 +1664,8 @@ function setLayout(layoutName) {
1635
1664
  applyHierarchicalLayout();
1636
1665
  } else if (layoutName === 'concentric') {
1637
1666
  applyConcentricLayout();
1667
+ } else if (layoutName === 'detangle') {
1668
+ applyDetangleLayout();
1638
1669
  } else {
1639
1670
  applyClusteredLayout();
1640
1671
  }
@@ -1846,6 +1877,119 @@ function applyCommunitiesLayout() {
1846
1877
  });
1847
1878
  }
1848
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
+
1849
1993
  function initLayoutSelector() {
1850
1994
  var btns = document.querySelectorAll('.layout-btn');
1851
1995
  btns.forEach(function (btn) {
@@ -2098,182 +2242,265 @@ if (document.readyState === 'loading') {
2098
2242
  }
2099
2243
 
2100
2244
  // ---------------------------------------------------------------------------
2101
- // Path overlay
2245
+ // Freeze button (pauses SSE-driven refreshes) + Sync button (reload stale data)
2102
2246
  // ---------------------------------------------------------------------------
2103
2247
 
2104
- async function loadPathOverlay() {
2105
- if (!svg || !pathOverlayVisible) return;
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
+ }
2106
2269
 
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
- }
2270
+ function toggleGraphFrozen() {
2271
+ graphFrozen = !graphFrozen;
2272
+ updateFreezeButton();
2129
2273
 
2130
- renderPathOverlay();
2131
- } catch (err) {
2132
- console.error('[laminark:graph] Failed to load path overlay:', err);
2274
+ if (!graphFrozen && graphStale) {
2275
+ // Unfreeze: reload pending data
2276
+ graphStale = false;
2277
+ clearPathfinder();
2278
+ loadGraphData();
2279
+ updateSyncButton();
2133
2280
  }
2134
2281
  }
2135
2282
 
2136
- function renderPathOverlay() {
2137
- if (!pathOverlayGroup || !pathOverlayVisible) {
2138
- if (pathOverlayGroup) pathOverlayGroup.style('display', 'none');
2139
- return;
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>';
2140
2297
  }
2141
- pathOverlayGroup.style('display', null);
2142
- pathOverlayGroup.selectAll('*').remove();
2298
+ }
2143
2299
 
2144
- if (pathData.length === 0) return;
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
+ }
2145
2315
 
2146
- var width = containerEl ? containerEl.clientWidth : 800;
2147
- var height = containerEl ? containerEl.clientHeight : 600;
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
+ }
2148
2326
 
2149
- // Get current transform to position in screen space
2150
- var transform = d3.zoomTransform(svg.node());
2327
+ // ---------------------------------------------------------------------------
2328
+ // Pathfinder: shortest path between two nodes
2329
+ // ---------------------------------------------------------------------------
2151
2330
 
2152
- pathData.forEach(function(path, pathIndex) {
2153
- if (!path.waypoints || path.waypoints.length === 0) return;
2331
+ function initPathfinderToggle() {
2332
+ var btn = document.getElementById('paths-toggle-btn');
2333
+ if (!btn) return;
2154
2334
 
2155
- var pathGroup = pathOverlayGroup.append('g')
2156
- .attr('class', 'path-trail')
2157
- .attr('data-path-id', path.id);
2335
+ btn.classList.toggle('active', pathfinderActive);
2158
2336
 
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;
2337
+ btn.addEventListener('click', function () {
2338
+ pathfinderActive = !pathfinderActive;
2339
+ btn.classList.toggle('active', pathfinderActive);
2340
+ if (!pathfinderActive) {
2341
+ clearPathfinder();
2342
+ }
2343
+ });
2344
+ }
2166
2345
 
2167
- var points = waypoints.map(function(wp, i) {
2168
- return { x: xStart + i * spacing, y: yBase + pathIndex * 40 / transform.k };
2169
- });
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
+ }
2170
2367
 
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);
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');
2186
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
+ }
2187
2391
 
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
- });
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);
2228
2422
  }
2229
2423
  });
2230
- });
2424
+ return { nodeIds: nodeIds, edgeIds: edgeIds, path: current.path, relTypes: relTypes };
2425
+ }
2231
2426
 
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));
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
+ }
2244
2437
  }
2245
- });
2246
- }
2438
+ }
2247
2439
 
2248
- function addPathOverlay(pathEvent) {
2249
- loadPathOverlay();
2440
+ return null; // No path found
2250
2441
  }
2251
2442
 
2252
- function updatePathOverlay(waypointEvent) {
2253
- loadPathOverlay();
2254
- }
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); });
2255
2459
 
2256
- function resolvePathOverlay(resolveEvent) {
2257
- loadPathOverlay();
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);
2258
2467
  }
2259
2468
 
2260
- function initPathOverlayToggle() {
2261
- var btn = document.getElementById('paths-toggle-btn');
2262
- if (!btn) return;
2469
+ function clearPathfinder() {
2470
+ pathfinderSelection = [];
2471
+ pathfinderResult = null;
2472
+ clearPathfinderVisuals();
2473
+ hidePathfinderInfo();
2474
+ }
2263
2475
 
2264
- btn.classList.toggle('active', pathOverlayVisible);
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
+ }
2265
2490
 
2266
- btn.addEventListener('click', function() {
2267
- pathOverlayVisible = !pathOverlayVisible;
2268
- localStorage.setItem('laminark-path-overlay', pathOverlayVisible ? 'true' : 'false');
2269
- btn.classList.toggle('active', pathOverlayVisible);
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
+ }
2270
2501
 
2271
- if (pathOverlayVisible) {
2272
- loadPathOverlay();
2273
- } else {
2274
- if (pathOverlayGroup) pathOverlayGroup.style('display', 'none');
2275
- }
2276
- });
2502
+ function hidePathfinderInfo() {
2503
+ if (pathfinderInfoEl) pathfinderInfoEl.style.display = 'none';
2277
2504
  }
2278
2505
 
2279
2506
  // ---------------------------------------------------------------------------
@@ -2297,6 +2524,7 @@ window.laminarkGraph = {
2297
2524
  hideDetailPanel: hideDetailPanel,
2298
2525
  selectAndCenterNode: selectAndCenterNode,
2299
2526
  queueBatchUpdate: queueBatchUpdate,
2527
+ clearBatchQueue: clearBatchQueue,
2300
2528
  togglePerfOverlay: togglePerfOverlay,
2301
2529
  enterFocusMode: enterFocusMode,
2302
2530
  exitFocusMode: exitFocusMode,
@@ -2312,11 +2540,10 @@ window.laminarkGraph = {
2312
2540
  clearCommunityColors: clearCommunityColors,
2313
2541
  showLinkedNodesOfType: showLinkedNodesOfType,
2314
2542
  hideContextMenu: hideContextMenu,
2315
- addPathOverlay: addPathOverlay,
2316
- updatePathOverlay: updatePathOverlay,
2317
- resolvePathOverlay: resolvePathOverlay,
2318
- loadPathOverlay: loadPathOverlay,
2319
- isPathOverlayVisible: function() { return pathOverlayVisible; },
2543
+ clearPathfinder: clearPathfinder,
2544
+ isPathfinderActive: function () { return pathfinderActive; },
2545
+ isGraphFrozen: function () { return graphFrozen; },
2546
+ toggleGraphFrozen: toggleGraphFrozen,
2320
2547
  toggleEdgeLabels: function (type) {
2321
2548
  if (type) {
2322
2549
  if (hiddenEdgeLabelTypes.has(type)) hiddenEdgeLabelTypes.delete(type);