laminark 0.1.0 → 2.21.7

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/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +71 -36
  3. package/package.json +7 -9
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/dist/hooks/handler.d.ts +1 -3
  6. package/plugin/dist/hooks/handler.d.ts.map +1 -1
  7. package/plugin/dist/hooks/handler.js +22 -310
  8. package/plugin/dist/hooks/handler.js.map +1 -1
  9. package/plugin/dist/index.d.ts +1 -3
  10. package/plugin/dist/index.d.ts.map +1 -1
  11. package/plugin/dist/index.js +392 -1895
  12. package/plugin/dist/index.js.map +1 -1
  13. package/plugin/dist/{observations-CorAAc1A.d.mts → observations-Ch0nc47i.d.mts} +1 -23
  14. package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
  15. package/plugin/dist/{tool-registry-e710BvXq.mjs → tool-registry-CZ3mJ4iR.mjs} +13 -932
  16. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
  17. package/plugin/hooks/hooks.json +6 -6
  18. package/plugin/scripts/README.md +1 -19
  19. package/plugin/scripts/bump-version.sh +3 -1
  20. package/plugin/scripts/ensure-deps.sh +2 -5
  21. package/plugin/scripts/install.sh +39 -115
  22. package/plugin/scripts/local-install.sh +58 -93
  23. package/plugin/scripts/setup-tmpdir.sh +65 -0
  24. package/plugin/scripts/uninstall.sh +38 -76
  25. package/plugin/scripts/update.sh +69 -20
  26. package/plugin/scripts/verify-install.sh +25 -69
  27. package/plugin/ui/activity.js +0 -12
  28. package/plugin/ui/app.js +54 -24
  29. package/plugin/ui/graph.js +186 -413
  30. package/plugin/ui/help.js +172 -876
  31. package/plugin/ui/index.html +242 -506
  32. package/plugin/ui/settings.js +17 -781
  33. package/plugin/ui/styles.css +44 -990
  34. package/plugin/ui/timeline.js +2 -2
  35. package/plugin/CLAUDE.md +0 -10
  36. package/plugin/commands/recall.md +0 -55
  37. package/plugin/commands/remember.md +0 -34
  38. package/plugin/commands/resume.md +0 -45
  39. package/plugin/commands/stash.md +0 -34
  40. package/plugin/commands/status.md +0 -33
  41. package/plugin/dist/observations-CorAAc1A.d.mts.map +0 -1
  42. package/plugin/dist/tool-registry-e710BvXq.mjs.map +0 -1
  43. package/plugin/laminark.db +0 -0
  44. package/plugin/package.json +0 -17
  45. package/plugin/scripts/dev-sync.sh +0 -58
  46. package/plugin/ui/help/activity-feed.png +0 -0
  47. package/plugin/ui/help/analysis-panel.png +0 -0
  48. package/plugin/ui/help/graph-toolbar.png +0 -0
  49. package/plugin/ui/help/graph-view.png +0 -0
  50. package/plugin/ui/help/settings.png +0 -0
  51. package/plugin/ui/help/timeline.png +0 -0
  52. package/plugin/ui/tools.js +0 -826
@@ -31,8 +31,17 @@ const ENTITY_STYLES = {
31
31
  Reference: { color: '#f0883e', shape: 'hexagon' },
32
32
  };
33
33
 
34
- // Pathfinder highlight color
35
- var PATHFINDER_COLOR = '#f0883e';
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
+ };
36
45
 
37
46
  // Relationship type colors for edge coloring
38
47
  var EDGE_TYPE_COLORS = {
@@ -188,17 +197,10 @@ var hiddenEdgeLabelTypes = new Set(
188
197
  JSON.parse(localStorage.getItem('laminark-hidden-edge-types') || '[]')
189
198
  );
190
199
 
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;
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?}] }
202
204
 
203
205
  // ---------------------------------------------------------------------------
204
206
  // initGraph
@@ -250,6 +252,7 @@ function initGraph(containerId) {
250
252
  // Layer groups in paint order (back to front)
251
253
  edgesGroup = svgG.append('g').attr('class', 'edges-group');
252
254
  edgeLabelsGroup = svgG.append('g').attr('class', 'edge-labels-group');
255
+ pathOverlayGroup = svgG.append('g').attr('class', 'path-overlay-group');
253
256
  nodesGroup = svgG.append('g').attr('class', 'nodes-group');
254
257
  nodeLabelsGroup = svgG.append('g').attr('class', 'node-labels-group');
255
258
 
@@ -260,16 +263,16 @@ function initGraph(containerId) {
260
263
  svgG.attr('transform', event.transform);
261
264
  currentZoom = event.transform.k;
262
265
  updateLevelOfDetail();
266
+ renderPathOverlay();
263
267
  });
264
268
  svg.call(zoomBehavior);
265
269
 
266
- // Background click: deselect + hide detail panel + clear pathfinder
270
+ // Background click: deselect + hide detail panel
267
271
  svg.on('click', function (event) {
268
272
  if (event.target === svg.node() || event.target.closest('.graph-zoom-group') === svgG.node() && !event.target.closest('.node-group')) {
269
273
  hideDetailPanel();
270
274
  selectedNodeId = null;
271
275
  if (nodesGroup) nodesGroup.selectAll('.node-group').classed('selected', false);
272
- clearPathfinder();
273
276
  }
274
277
  });
275
278
 
@@ -302,9 +305,7 @@ function initGraph(containerId) {
302
305
 
303
306
  initContextMenu();
304
307
  initEdgeLabelToggle();
305
- initPathfinderToggle();
306
- initFreezeButton();
307
- initSyncButton();
308
+ initPathOverlayToggle();
308
309
 
309
310
  // Create tooltip element
310
311
  tooltipEl = document.createElement('div');
@@ -353,11 +354,8 @@ function createSimulation() {
353
354
  var width = containerEl ? containerEl.clientWidth : 800;
354
355
  var height = containerEl ? containerEl.clientHeight : 600;
355
356
 
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
- 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);
358
+ return !d.source.hidden && !d.target.hidden;
361
359
  });
362
360
 
363
361
  // Degree-scaled repulsion: more links = stronger push away
@@ -597,12 +595,6 @@ function dragEnded(event, d) {
597
595
  // ---------------------------------------------------------------------------
598
596
 
599
597
  async function handleNodeClick(d) {
600
- // Pathfinder mode: collect start/end nodes
601
- if (pathfinderActive) {
602
- handlePathfinderClick(d);
603
- return;
604
- }
605
-
606
598
  selectedNodeId = d.id;
607
599
  if (nodesGroup) {
608
600
  nodesGroup.selectAll('.node-group').classed('selected', function (n) { return n.id === d.id; });
@@ -666,15 +658,6 @@ async function loadGraphData(filters) {
666
658
  return { nodeCount: nodeData.length, edgeCount: edgeData.length };
667
659
  }
668
660
 
669
- // Don't reload while graph is frozen (manual pause or pathfinder result).
670
- // Data will be fetched when the user unfreezes or clicks sync.
671
- if (graphFrozen) {
672
- console.log('[laminark:graph] Skipping loadGraphData (graph frozen)');
673
- graphStale = true;
674
- updateSyncButton();
675
- return { nodeCount: nodeData.length, edgeCount: edgeData.length };
676
- }
677
-
678
661
  var data;
679
662
  if (window.laminarkApp && window.laminarkApp.fetchGraphData) {
680
663
  data = await window.laminarkApp.fetchGraphData(filters);
@@ -699,11 +682,11 @@ async function loadGraphData(filters) {
699
682
  edgeData = [];
700
683
  renderGraph();
701
684
  updateGraphStats(0, 0);
702
- showGraphEmptyState();
685
+ showEmptyState();
703
686
  return { nodeCount: 0, edgeCount: 0 };
704
687
  }
705
688
 
706
- hideGraphEmptyState();
689
+ hideEmptyState();
707
690
 
708
691
  // Build data arrays
709
692
  nodeData = data.nodes.map(function (node) {
@@ -744,6 +727,11 @@ async function loadGraphData(filters) {
744
727
  setTimeout(function () { fitToView(); }, 800);
745
728
  }
746
729
 
730
+ // Load path overlay after graph data
731
+ if (pathOverlayVisible) {
732
+ setTimeout(function () { loadPathOverlay(); }, 1000);
733
+ }
734
+
747
735
  var counts = { nodeCount: data.nodes.length, edgeCount: data.edges.length };
748
736
  updateGraphStats(counts.nodeCount, counts.edgeCount);
749
737
  console.log('[laminark:graph] Loaded', counts.nodeCount, 'nodes,', counts.edgeCount, 'edges');
@@ -769,7 +757,7 @@ function addNode(nodeDataIn) {
769
757
  createdAt: nodeDataIn.createdAt,
770
758
  hidden: false,
771
759
  });
772
- hideGraphEmptyState();
760
+ hideEmptyState();
773
761
  }
774
762
 
775
763
  renderGraph();
@@ -816,7 +804,7 @@ function removeElements(ids) {
816
804
  renderGraph();
817
805
  updateGraphStatsFromData();
818
806
 
819
- if (nodeData.length === 0) showGraphEmptyState();
807
+ if (nodeData.length === 0) showEmptyState();
820
808
  }
821
809
 
822
810
  // ---------------------------------------------------------------------------
@@ -979,7 +967,7 @@ function updateFilterCounts() {
979
967
  // Empty state
980
968
  // ---------------------------------------------------------------------------
981
969
 
982
- function showGraphEmptyState() {
970
+ function showEmptyState() {
983
971
  if (!containerEl) return;
984
972
  var existing = containerEl.querySelector('.graph-empty-state');
985
973
  if (existing) { existing.style.display = ''; return; }
@@ -990,7 +978,7 @@ function showGraphEmptyState() {
990
978
  containerEl.appendChild(msg);
991
979
  }
992
980
 
993
- function hideGraphEmptyState() {
981
+ function hideEmptyState() {
994
982
  if (!containerEl) return;
995
983
  var existing = containerEl.querySelector('.graph-empty-state');
996
984
  if (existing) existing.style.display = 'none';
@@ -1191,13 +1179,9 @@ function initEdgeLabelToggle() {
1191
1179
  dropdown.appendChild(row);
1192
1180
  });
1193
1181
 
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);
1182
+ // Insert dropdown after button
1183
+ btn.parentElement.style.position = 'relative';
1184
+ btn.insertAdjacentElement('afterend', dropdown);
1201
1185
 
1202
1186
  // Toggle dropdown on click
1203
1187
  btn.addEventListener('click', function (e) {
@@ -1423,12 +1407,6 @@ function queueBatchUpdate(update) {
1423
1407
  batchFlushTimer = setTimeout(flushBatchUpdates, BATCH_DELAY_MS);
1424
1408
  }
1425
1409
 
1426
- function clearBatchQueue() {
1427
- if (batchFlushTimer) clearTimeout(batchFlushTimer);
1428
- batchFlushTimer = null;
1429
- batchQueue = [];
1430
- }
1431
-
1432
1410
  function flushBatchUpdates() {
1433
1411
  if (!svg || batchQueue.length === 0) return;
1434
1412
 
@@ -1472,14 +1450,7 @@ function flushBatchUpdates() {
1472
1450
  batchFlushTimer = null;
1473
1451
 
1474
1452
  if (newNodes > 0 || newEdges > 0) {
1475
- // Suppress rendering while graph is frozen
1476
- if (graphFrozen) {
1477
- console.log('[laminark:graph] Batch data accumulated but render suppressed (graph frozen)');
1478
- graphStale = true;
1479
- updateSyncButton();
1480
- return;
1481
- }
1482
- hideGraphEmptyState();
1453
+ hideEmptyState();
1483
1454
  renderGraph();
1484
1455
  if (!isStaticLayout && simulation) simulation.alpha(0.3).restart();
1485
1456
  console.log('[laminark:graph] Batch update: added ' + newNodes + ' nodes, ' + newEdges + ' edges');
@@ -1641,7 +1612,7 @@ function updateBreadcrumbs() {
1641
1612
  // ---------------------------------------------------------------------------
1642
1613
 
1643
1614
  function setLayout(layoutName) {
1644
- var validLayouts = ['clustered', 'hierarchical', 'concentric', 'communities', 'detangle'];
1615
+ var validLayouts = ['clustered', 'hierarchical', 'concentric', 'communities'];
1645
1616
  if (validLayouts.indexOf(layoutName) === -1) return;
1646
1617
 
1647
1618
  var previousLayout = currentLayout;
@@ -1664,8 +1635,6 @@ function setLayout(layoutName) {
1664
1635
  applyHierarchicalLayout();
1665
1636
  } else if (layoutName === 'concentric') {
1666
1637
  applyConcentricLayout();
1667
- } else if (layoutName === 'detangle') {
1668
- applyDetangleLayout();
1669
1638
  } else {
1670
1639
  applyClusteredLayout();
1671
1640
  }
@@ -1877,119 +1846,6 @@ function applyCommunitiesLayout() {
1877
1846
  });
1878
1847
  }
1879
1848
 
1880
- // ---------------------------------------------------------------------------
1881
- // Detangle layout — minimizes edge crossings via custom force
1882
- // ---------------------------------------------------------------------------
1883
-
1884
- /**
1885
- * Tests whether line segment (p1→p2) crosses (p3→p4).
1886
- * Returns true if the segments properly intersect.
1887
- */
1888
- function segmentsIntersect(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y) {
1889
- var d1x = p2x - p1x, d1y = p2y - p1y;
1890
- var d2x = p4x - p3x, d2y = p4y - p3y;
1891
- var cross = d1x * d2y - d1y * d2x;
1892
- if (Math.abs(cross) < 1e-10) return false;
1893
- var t = ((p3x - p1x) * d2y - (p3y - p1y) * d2x) / cross;
1894
- var u = ((p3x - p1x) * d1y - (p3y - p1y) * d1x) / cross;
1895
- return t > 0.01 && t < 0.99 && u > 0.01 && u < 0.99;
1896
- }
1897
-
1898
- /**
1899
- * Custom D3 force that detects edge crossings and pushes nodes apart
1900
- * to untangle the graph. Runs O(E^2) per tick — fine for <1000 edges.
1901
- */
1902
- function forceUncross(edges) {
1903
- var strength = 8;
1904
- var nodes;
1905
-
1906
- function force(alpha) {
1907
- if (!nodes || edges.length < 2) return;
1908
- var effectiveStrength = strength * alpha;
1909
-
1910
- for (var i = 0; i < edges.length; i++) {
1911
- var e1 = edges[i];
1912
- var s1 = e1.source, t1 = e1.target;
1913
- if (!s1 || !t1 || s1.x == null || t1.x == null) continue;
1914
-
1915
- for (var j = i + 1; j < edges.length; j++) {
1916
- var e2 = edges[j];
1917
- var s2 = e2.source, t2 = e2.target;
1918
- if (!s2 || !t2 || s2.x == null || t2.x == null) continue;
1919
-
1920
- // Skip if edges share a node
1921
- if (s1 === s2 || s1 === t2 || t1 === s2 || t1 === t2) continue;
1922
-
1923
- if (segmentsIntersect(s1.x, s1.y, t1.x, t1.y, s2.x, s2.y, t2.x, t2.y)) {
1924
- // Push the midpoints of the two edges apart
1925
- var m1x = (s1.x + t1.x) / 2, m1y = (s1.y + t1.y) / 2;
1926
- var m2x = (s2.x + t2.x) / 2, m2y = (s2.y + t2.y) / 2;
1927
- var dx = m1x - m2x, dy = m1y - m2y;
1928
- var dist = Math.sqrt(dx * dx + dy * dy) || 1;
1929
- var push = effectiveStrength / dist;
1930
- var fx = dx * push, fy = dy * push;
1931
-
1932
- // Apply to non-fixed nodes of edge 1 (push away from edge 2)
1933
- if (s1.fx == null) { s1.vx += fx * 0.5; s1.vy += fy * 0.5; }
1934
- if (t1.fx == null) { t1.vx += fx * 0.5; t1.vy += fy * 0.5; }
1935
- // Apply to non-fixed nodes of edge 2 (push opposite direction)
1936
- if (s2.fx == null) { s2.vx -= fx * 0.5; s2.vy -= fy * 0.5; }
1937
- if (t2.fx == null) { t2.vx -= fx * 0.5; t2.vy -= fy * 0.5; }
1938
- }
1939
- }
1940
- }
1941
- }
1942
-
1943
- force.initialize = function (_nodes) { nodes = _nodes; };
1944
- force.strength = function (s) { if (!arguments.length) return strength; strength = s; return force; };
1945
-
1946
- return force;
1947
- }
1948
-
1949
- function applyDetangleLayout() {
1950
- isStaticLayout = false;
1951
- nodeData.forEach(function (d) { d.fx = null; d.fy = null; });
1952
-
1953
- // Let renderGraph create the standard simulation first (it calls createSimulation)
1954
- renderGraph();
1955
-
1956
- // Now enhance the existing simulation with the uncross force
1957
- if (simulation) {
1958
- var simNodeIds = new Set(nodeData.filter(function (d) { return !d.hidden; }).map(function (d) { return d.id; }));
1959
- var visibleEdges = edgeData.filter(function (d) {
1960
- var srcId = typeof d.source === 'object' ? d.source.id : d.source;
1961
- var tgtId = typeof d.target === 'object' ? d.target.id : d.target;
1962
- return simNodeIds.has(srcId) && simNodeIds.has(tgtId);
1963
- });
1964
-
1965
- var width = containerEl ? containerEl.clientWidth : 800;
1966
- var height = containerEl ? containerEl.clientHeight : 600;
1967
-
1968
- // Widen link distances and strengthen repulsion for more spacing
1969
- simulation
1970
- .force('link', d3.forceLink(visibleEdges)
1971
- .id(function (d) { return d.id; })
1972
- .distance(function (d) {
1973
- var srcDeg = (typeof d.source === 'object' ? d.source._degree : 0) || 0;
1974
- var tgtDeg = (typeof d.target === 'object' ? d.target._degree : 0) || 0;
1975
- return 140 + Math.sqrt(srcDeg + tgtDeg) * 25;
1976
- })
1977
- .strength(0.8))
1978
- .force('collide', d3.forceCollide().radius(function (d) {
1979
- return getNodeSize(d) + 20;
1980
- }).strength(1))
1981
- .force('uncross', forceUncross(visibleEdges).strength(12))
1982
- .force('x', d3.forceX(width / 2).strength(0.02))
1983
- .force('y', d3.forceY(height / 2).strength(0.02))
1984
- .alphaDecay(0.01)
1985
- .velocityDecay(0.4)
1986
- .alpha(1)
1987
- .restart();
1988
- }
1989
-
1990
- setTimeout(function () { fitToView(); }, 1500);
1991
- }
1992
-
1993
1849
  function initLayoutSelector() {
1994
1850
  var btns = document.querySelectorAll('.layout-btn');
1995
1851
  btns.forEach(function (btn) {
@@ -2242,265 +2098,182 @@ if (document.readyState === 'loading') {
2242
2098
  }
2243
2099
 
2244
2100
  // ---------------------------------------------------------------------------
2245
- // Freeze button (pauses SSE-driven refreshes) + Sync button (reload stale data)
2101
+ // Path overlay
2246
2102
  // ---------------------------------------------------------------------------
2247
2103
 
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
- }
2104
+ async function loadPathOverlay() {
2105
+ if (!svg || !pathOverlayVisible) return;
2269
2106
 
2270
- function toggleGraphFrozen() {
2271
- graphFrozen = !graphFrozen;
2272
- updateFreezeButton();
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
+ }
2273
2129
 
2274
- if (!graphFrozen && graphStale) {
2275
- // Unfreeze: reload pending data
2276
- graphStale = false;
2277
- clearPathfinder();
2278
- loadGraphData();
2279
- updateSyncButton();
2130
+ renderPathOverlay();
2131
+ } catch (err) {
2132
+ console.error('[laminark:graph] Failed to load path overlay:', err);
2280
2133
  }
2281
2134
  }
2282
2135
 
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>';
2136
+ function renderPathOverlay() {
2137
+ if (!pathOverlayGroup || !pathOverlayVisible) {
2138
+ if (pathOverlayGroup) pathOverlayGroup.style('display', 'none');
2139
+ return;
2297
2140
  }
2298
- }
2141
+ pathOverlayGroup.style('display', null);
2142
+ pathOverlayGroup.selectAll('*').remove();
2299
2143
 
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
- }
2144
+ if (pathData.length === 0) return;
2315
2145
 
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
- }
2146
+ var width = containerEl ? containerEl.clientWidth : 800;
2147
+ var height = containerEl ? containerEl.clientHeight : 600;
2326
2148
 
2327
- // ---------------------------------------------------------------------------
2328
- // Pathfinder: shortest path between two nodes
2329
- // ---------------------------------------------------------------------------
2149
+ // Get current transform to position in screen space
2150
+ var transform = d3.zoomTransform(svg.node());
2330
2151
 
2331
- function initPathfinderToggle() {
2332
- var btn = document.getElementById('paths-toggle-btn');
2333
- if (!btn) return;
2152
+ pathData.forEach(function(path, pathIndex) {
2153
+ if (!path.waypoints || path.waypoints.length === 0) return;
2334
2154
 
2335
- btn.classList.toggle('active', pathfinderActive);
2155
+ var pathGroup = pathOverlayGroup.append('g')
2156
+ .attr('class', 'path-trail')
2157
+ .attr('data-path-id', path.id);
2336
2158
 
2337
- btn.addEventListener('click', function () {
2338
- pathfinderActive = !pathfinderActive;
2339
- btn.classList.toggle('active', pathfinderActive);
2340
- if (!pathfinderActive) {
2341
- clearPathfinder();
2342
- }
2343
- });
2344
- }
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;
2345
2166
 
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
- }
2167
+ var points = waypoints.map(function(wp, i) {
2168
+ return { x: xStart + i * spacing, y: yBase + pathIndex * 40 / transform.k };
2169
+ });
2367
2170
 
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');
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);
2377
2186
  }
2378
- } else {
2379
- // Already have 2 — restart with this node as new start
2380
- pathfinderSelection = [d.id];
2381
- pathfinderResult = null;
2382
- clearPathfinderVisuals();
2383
- if (nodesGroup) {
2384
- nodesGroup.selectAll('.node-group')
2385
- .classed('pathfinder-start', function (n) { return n.id === d.id; })
2386
- .classed('pathfinder-end', false);
2387
- }
2388
- updatePathfinderInfo('Select target node...');
2389
- }
2390
- }
2391
2187
 
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);
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
+ });
2422
2228
  }
2423
2229
  });
2424
- return { nodeIds: nodeIds, edgeIds: edgeIds, path: current.path, relTypes: relTypes };
2425
- }
2230
+ });
2426
2231
 
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
- }
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));
2437
2244
  }
2438
- }
2439
-
2440
- return null; // No path found
2245
+ });
2441
2246
  }
2442
2247
 
2443
- function visualizePathfinderResult(result) {
2444
- if (!nodesGroup || !edgesGroup) return;
2445
-
2446
- // Dim non-path nodes
2447
- nodesGroup.selectAll('.node-group')
2448
- .classed('search-dimmed', function (d) { return !result.nodeIds.has(d.id); })
2449
- .classed('pathfinder-start', function (d) { return d.id === pathfinderSelection[0]; })
2450
- .classed('pathfinder-end', function (d) { return d.id === pathfinderSelection[1]; });
2451
-
2452
- nodeLabelsGroup.selectAll('.node-label')
2453
- .classed('search-dimmed', function (d) { return !result.nodeIds.has(d.id); });
2454
-
2455
- // Dim non-path edges, highlight path edges
2456
- edgesGroup.selectAll('.edge')
2457
- .classed('search-dimmed', function (d) { return !result.edgeIds.has(d.id); })
2458
- .classed('path-highlight', function (d) { return result.edgeIds.has(d.id); });
2459
-
2460
- edgeLabelsGroup.selectAll('.edge-label')
2461
- .classed('search-dimmed', function (d) { return !result.edgeIds.has(d.id); });
2462
-
2463
- // Show info
2464
- var hops = result.path.length - 1;
2465
- var types = result.relTypes.join(' > ');
2466
- updatePathfinderInfo(hops + ' hop' + (hops !== 1 ? 's' : '') + ': ' + types);
2248
+ function addPathOverlay(pathEvent) {
2249
+ loadPathOverlay();
2467
2250
  }
2468
2251
 
2469
- function clearPathfinder() {
2470
- pathfinderSelection = [];
2471
- pathfinderResult = null;
2472
- clearPathfinderVisuals();
2473
- hidePathfinderInfo();
2252
+ function updatePathOverlay(waypointEvent) {
2253
+ loadPathOverlay();
2474
2254
  }
2475
2255
 
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);
2256
+ function resolvePathOverlay(resolveEvent) {
2257
+ loadPathOverlay();
2489
2258
  }
2490
2259
 
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
- }
2260
+ function initPathOverlayToggle() {
2261
+ var btn = document.getElementById('paths-toggle-btn');
2262
+ if (!btn) return;
2263
+
2264
+ btn.classList.toggle('active', pathOverlayVisible);
2501
2265
 
2502
- function hidePathfinderInfo() {
2503
- if (pathfinderInfoEl) pathfinderInfoEl.style.display = 'none';
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
+ });
2504
2277
  }
2505
2278
 
2506
2279
  // ---------------------------------------------------------------------------
@@ -2524,7 +2297,6 @@ window.laminarkGraph = {
2524
2297
  hideDetailPanel: hideDetailPanel,
2525
2298
  selectAndCenterNode: selectAndCenterNode,
2526
2299
  queueBatchUpdate: queueBatchUpdate,
2527
- clearBatchQueue: clearBatchQueue,
2528
2300
  togglePerfOverlay: togglePerfOverlay,
2529
2301
  enterFocusMode: enterFocusMode,
2530
2302
  exitFocusMode: exitFocusMode,
@@ -2540,10 +2312,11 @@ window.laminarkGraph = {
2540
2312
  clearCommunityColors: clearCommunityColors,
2541
2313
  showLinkedNodesOfType: showLinkedNodesOfType,
2542
2314
  hideContextMenu: hideContextMenu,
2543
- clearPathfinder: clearPathfinder,
2544
- isPathfinderActive: function () { return pathfinderActive; },
2545
- isGraphFrozen: function () { return graphFrozen; },
2546
- toggleGraphFrozen: toggleGraphFrozen,
2315
+ addPathOverlay: addPathOverlay,
2316
+ updatePathOverlay: updatePathOverlay,
2317
+ resolvePathOverlay: resolvePathOverlay,
2318
+ loadPathOverlay: loadPathOverlay,
2319
+ isPathOverlayVisible: function() { return pathOverlayVisible; },
2547
2320
  toggleEdgeLabels: function (type) {
2548
2321
  if (type) {
2549
2322
  if (hiddenEdgeLabelTypes.has(type)) hiddenEdgeLabelTypes.delete(type);