sverklo 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/bin/sverklo.js +194 -9
  2. package/dist/bin/sverklo.js.map +1 -1
  3. package/dist/src/index.d.ts +1 -1
  4. package/dist/src/index.js +1 -1
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/src/registry/indexer-pool.d.ts +24 -0
  7. package/dist/src/registry/indexer-pool.js +97 -0
  8. package/dist/src/registry/indexer-pool.js.map +1 -0
  9. package/dist/src/registry/registry.d.ts +18 -0
  10. package/dist/src/registry/registry.js +69 -0
  11. package/dist/src/registry/registry.js.map +1 -0
  12. package/dist/src/registry/registry.test.d.ts +1 -0
  13. package/dist/src/registry/registry.test.js +79 -0
  14. package/dist/src/registry/registry.test.js.map +1 -0
  15. package/dist/src/search/cluster.d.ts +25 -0
  16. package/dist/src/search/cluster.js +216 -0
  17. package/dist/src/search/cluster.js.map +1 -0
  18. package/dist/src/server/dashboard-html.js +324 -183
  19. package/dist/src/server/dashboard-html.js.map +1 -1
  20. package/dist/src/server/http-server.js +29 -0
  21. package/dist/src/server/http-server.js.map +1 -1
  22. package/dist/src/server/mcp-server.d.ts +1 -0
  23. package/dist/src/server/mcp-server.js +319 -0
  24. package/dist/src/server/mcp-server.js.map +1 -1
  25. package/dist/src/server/tools/clusters.d.ts +20 -0
  26. package/dist/src/server/tools/clusters.js +92 -0
  27. package/dist/src/server/tools/clusters.js.map +1 -0
  28. package/dist/src/server/tools/list-repos.d.ts +9 -0
  29. package/dist/src/server/tools/list-repos.js +48 -0
  30. package/dist/src/server/tools/list-repos.js.map +1 -0
  31. package/dist/src/wiki/wiki-generator.d.ts +12 -0
  32. package/dist/src/wiki/wiki-generator.js +357 -0
  33. package/dist/src/wiki/wiki-generator.js.map +1 -0
  34. package/package.json +1 -1
@@ -181,14 +181,31 @@ main.stage {
181
181
  .view.active { display: block; }
182
182
 
183
183
  /* ── Graph view ── */
184
- #graph-view { position: relative; }
185
- #graph-canvas {
184
+ #graph-view { position: relative; overflow: hidden; }
185
+ #graph-svg {
186
186
  width: 100%;
187
187
  height: 100%;
188
188
  display: block;
189
189
  cursor: grab;
190
190
  }
191
- #graph-canvas:active { cursor: grabbing; }
191
+ #graph-svg:active { cursor: grabbing; }
192
+ #graph-svg .node { cursor: pointer; }
193
+ #graph-svg .node circle { stroke: var(--bg); stroke-width: 1.5px; transition: stroke 0.15s; }
194
+ #graph-svg .node:hover circle { stroke: var(--accent); stroke-width: 2px; }
195
+ #graph-svg .node.selected circle { stroke: var(--accent); stroke-width: 2.5px; }
196
+ #graph-svg .link { stroke: var(--rule); fill: none; }
197
+ #graph-svg .link.highlighted { stroke: var(--accent); }
198
+ #graph-svg .node-label {
199
+ font-family: 'JetBrains Mono', monospace;
200
+ font-size: 10px;
201
+ fill: var(--text-2);
202
+ pointer-events: none;
203
+ opacity: 0;
204
+ transition: opacity 0.15s;
205
+ }
206
+ #graph-svg .node:hover .node-label,
207
+ #graph-svg .node.selected .node-label,
208
+ #graph-svg .node.label-visible .node-label { opacity: 1; }
192
209
 
193
210
  .graph-controls {
194
211
  position: absolute;
@@ -198,6 +215,9 @@ main.stage {
198
215
  gap: 8px;
199
216
  font-family: 'JetBrains Mono', monospace;
200
217
  font-size: 11px;
218
+ align-items: center;
219
+ flex-wrap: wrap;
220
+ max-width: 500px;
201
221
  }
202
222
  .graph-chip {
203
223
  padding: 6px 12px;
@@ -233,6 +253,69 @@ main.stage {
233
253
  .graph-search input:focus { border-color: var(--accent); }
234
254
  .graph-search input::placeholder { color: var(--text-3); }
235
255
 
256
+ .graph-legend {
257
+ position: absolute;
258
+ top: 20px;
259
+ right: 20px;
260
+ background: rgba(22, 20, 15, 0.92);
261
+ backdrop-filter: blur(8px);
262
+ border: 1px solid var(--rule);
263
+ padding: 12px 16px;
264
+ font-family: 'JetBrains Mono', monospace;
265
+ font-size: 10px;
266
+ color: var(--text-3);
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: 6px;
270
+ }
271
+ .graph-legend-item {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 8px;
275
+ }
276
+ .graph-legend-dot {
277
+ width: 8px;
278
+ height: 8px;
279
+ border-radius: 50%;
280
+ flex-shrink: 0;
281
+ }
282
+
283
+ .graph-slider-group {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 8px;
287
+ padding: 6px 12px;
288
+ background: var(--bg-2);
289
+ border: 1px solid var(--rule);
290
+ color: var(--text-2);
291
+ }
292
+ .graph-slider-group label { white-space: nowrap; }
293
+ .graph-slider-group input[type="range"] {
294
+ width: 100px;
295
+ accent-color: var(--accent);
296
+ }
297
+ .graph-slider-group .slider-val {
298
+ min-width: 32px;
299
+ text-align: right;
300
+ color: var(--accent);
301
+ }
302
+
303
+ .graph-tooltip {
304
+ position: absolute;
305
+ pointer-events: none;
306
+ background: rgba(22, 20, 15, 0.95);
307
+ border: 1px solid var(--rule-2);
308
+ padding: 8px 12px;
309
+ font-family: 'JetBrains Mono', monospace;
310
+ font-size: 11px;
311
+ color: var(--text);
312
+ display: none;
313
+ z-index: 100;
314
+ white-space: nowrap;
315
+ }
316
+ .graph-tooltip .tt-path { color: var(--accent); margin-bottom: 2px; }
317
+ .graph-tooltip .tt-meta { color: var(--text-3); }
318
+
236
319
  /* ── Search view ── */
237
320
  #search-view {
238
321
  display: none;
@@ -642,15 +725,23 @@ footer.status .spacer { flex: 1; }
642
725
  <main class="stage">
643
726
  <!-- Graph View -->
644
727
  <div class="view active" id="graph-view">
645
- <canvas id="graph-canvas"></canvas>
728
+ <svg id="graph-svg"></svg>
646
729
  <div class="graph-search">
647
730
  <input type="text" id="graph-filter" placeholder="filter nodes…" />
648
731
  </div>
649
732
  <div class="graph-controls">
650
- <div class="graph-chip on" data-filter="all">all</div>
651
- <div class="graph-chip" data-filter="ts">ts</div>
652
- <div class="graph-chip" data-filter="js">js</div>
653
- <div class="graph-chip" data-filter="py">py</div>
733
+ <div class="graph-chip on" id="graph-top100" onclick="graphLoadTop()">top 100</div>
734
+ <div class="graph-chip" id="graph-showall" onclick="graphLoadAll()">show all</div>
735
+ <div class="graph-slider-group">
736
+ <label>min PR</label>
737
+ <input type="range" id="graph-pr-slider" min="0" max="100" value="0" />
738
+ <span class="slider-val" id="graph-pr-val">0.00</span>
739
+ </div>
740
+ </div>
741
+ <div class="graph-legend" id="graph-legend"></div>
742
+ <div class="graph-tooltip" id="graph-tooltip">
743
+ <div class="tt-path"></div>
744
+ <div class="tt-meta"></div>
654
745
  </div>
655
746
  </div>
656
747
 
@@ -727,6 +818,7 @@ footer.status .spacer { flex: 1; }
727
818
  </div>
728
819
  </div>
729
820
 
821
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
730
822
  <script>
731
823
  // ────────── STATE ──────────
732
824
  let state = {
@@ -773,9 +865,8 @@ async function init() {
773
865
  renderInspectorToday();
774
866
  renderStats();
775
867
 
776
- // Load graph
777
- state.graphData = await api('/api/deps');
778
- drawGraph();
868
+ // Load graph (D3 force-directed)
869
+ await graphLoadTop();
779
870
 
780
871
  // Rail navigation
781
872
  document.querySelectorAll('.rail-item').forEach(el => {
@@ -788,7 +879,12 @@ async function init() {
788
879
  // Graph filter
789
880
  document.getElementById('graph-filter').addEventListener('input', (e) => {
790
881
  state.graphFilter = e.target.value.toLowerCase();
791
- drawGraph();
882
+ graphApplyFilter();
883
+ });
884
+
885
+ // PageRank slider
886
+ document.getElementById('graph-pr-slider').addEventListener('input', (e) => {
887
+ graphApplyFilter();
792
888
  });
793
889
 
794
890
  // Cmdk
@@ -802,8 +898,7 @@ async function init() {
802
898
  });
803
899
  document.getElementById('cmdk-input').addEventListener('input', (e) => runCmdk(e.target.value));
804
900
 
805
- // Resize canvas
806
- window.addEventListener('resize', () => { if (state.currentView === 'graph') drawGraph(); });
901
+ // Resize handled by D3 (SVG scales with container)
807
902
  }
808
903
 
809
904
  function switchView(view) {
@@ -811,188 +906,234 @@ function switchView(view) {
811
906
  document.querySelectorAll('.rail-item').forEach(el => el.classList.toggle('active', el.dataset.view === view));
812
907
  document.querySelectorAll('.view').forEach(el => el.classList.toggle('active', el.id === view + '-view'));
813
908
 
814
- if (view === 'graph') drawGraph();
909
+ if (view === 'graph' && !state.graphData) graphLoadTop();
815
910
  if (view === 'files') renderFiles();
816
911
  if (view === 'memories') renderMemories();
817
912
  if (view === 'search') document.getElementById('search-input').focus();
818
913
  if (view === 'stats') renderStats();
819
914
  }
820
915
 
821
- // ────────── GRAPH (Canvas + simple force layout) ──────────
822
- let graphState = { nodes: [], edges: [], pan: {x:0,y:0}, zoom: 1, hover: null, selected: null };
823
-
824
- function drawGraph() {
825
- const canvas = document.getElementById('graph-canvas');
826
- if (!canvas || !state.graphData) return;
827
-
828
- const dpr = window.devicePixelRatio || 1;
829
- const w = canvas.parentElement.clientWidth;
830
- const h = canvas.parentElement.clientHeight;
831
- canvas.width = w * dpr;
832
- canvas.height = h * dpr;
833
- canvas.style.width = w + 'px';
834
- canvas.style.height = h + 'px';
835
- const ctx = canvas.getContext('2d');
836
- ctx.scale(dpr, dpr);
837
-
838
- // Init nodes with force layout if not done
839
- if (graphState.nodes.length === 0) {
840
- graphState.nodes = state.graphData.nodes.map((n, i) => {
841
- const a = (i / state.graphData.nodes.length) * Math.PI * 2;
842
- const r = Math.min(w, h) * 0.32;
843
- return { ...n, x: w/2 + Math.cos(a)*r, y: h/2 + Math.sin(a)*r, vx: 0, vy: 0 };
916
+ // ────────── GRAPH (D3 force-directed) ──────────
917
+ let graphSim = null;
918
+ let graphSvgGroup = null;
919
+ let graphZoom = null;
920
+ let graphNodeSel = null;
921
+ let graphLinkSel = null;
922
+ let graphSelectedNode = null;
923
+ let graphIsShowAll = false;
924
+
925
+ async function graphLoadTop() {
926
+ document.getElementById('graph-top100').classList.add('on');
927
+ document.getElementById('graph-showall').classList.remove('on');
928
+ graphIsShowAll = false;
929
+ state.graphData = await api('/api/graph?limit=100');
930
+ initD3Graph();
931
+ }
932
+
933
+ async function graphLoadAll() {
934
+ document.getElementById('graph-showall').classList.add('on');
935
+ document.getElementById('graph-top100').classList.remove('on');
936
+ graphIsShowAll = true;
937
+ state.graphData = await api('/api/graph?limit=0');
938
+ initD3Graph();
939
+ }
940
+
941
+ function nodeRadius(d) {
942
+ // Scale pagerank to 4-24px radius
943
+ const pr = d.pagerank || 0;
944
+ const maxPR = state.graphData ? Math.max(...state.graphData.nodes.map(n => n.pagerank || 0), 0.001) : 1;
945
+ return 4 + (pr / maxPR) * 20;
946
+ }
947
+
948
+ function edgeOpacity(d) {
949
+ if (!state.graphData) return 0.15;
950
+ const maxW = Math.max(...state.graphData.edges.map(e => e.weight || 1), 1);
951
+ return 0.08 + (d.weight / maxW) * 0.5;
952
+ }
953
+
954
+ function initD3Graph() {
955
+ const container = document.getElementById('graph-view');
956
+ const svg = d3.select('#graph-svg');
957
+ svg.selectAll('*').remove();
958
+ if (graphSim) { graphSim.stop(); graphSim = null; }
959
+
960
+ const w = container.clientWidth;
961
+ const h = container.clientHeight;
962
+ svg.attr('width', w).attr('height', h).attr('viewBox', [0, 0, w, h]);
963
+
964
+ if (!state.graphData || !state.graphData.nodes.length) return;
965
+
966
+ // Build node map for edge resolution (edges use numeric IDs)
967
+ const nodeById = new Map(state.graphData.nodes.map(n => [n.id, n]));
968
+ const nodes = state.graphData.nodes.map(n => ({ ...n }));
969
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
970
+ const links = state.graphData.edges
971
+ .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
972
+ .map(e => ({ source: e.source, target: e.target, weight: e.weight }));
973
+
974
+ // Set up zoom
975
+ graphZoom = d3.zoom()
976
+ .scaleExtent([0.1, 8])
977
+ .on('zoom', (event) => {
978
+ graphSvgGroup.attr('transform', event.transform);
979
+ });
980
+ svg.call(graphZoom);
981
+
982
+ graphSvgGroup = svg.append('g');
983
+
984
+ // Links
985
+ graphLinkSel = graphSvgGroup.append('g').attr('class', 'links')
986
+ .selectAll('line')
987
+ .data(links)
988
+ .join('line')
989
+ .attr('class', 'link')
990
+ .attr('stroke-opacity', d => edgeOpacity(d))
991
+ .attr('stroke-width', d => Math.max(0.5, Math.min(3, d.weight * 0.5)));
992
+
993
+ // Nodes
994
+ graphNodeSel = graphSvgGroup.append('g').attr('class', 'nodes')
995
+ .selectAll('g')
996
+ .data(nodes, d => d.id)
997
+ .join('g')
998
+ .attr('class', d => {
999
+ let cls = 'node';
1000
+ if ((d.pagerank || 0) > 0.3) cls += ' label-visible';
1001
+ return cls;
1002
+ })
1003
+ .call(d3.drag()
1004
+ .on('start', dragStart)
1005
+ .on('drag', dragging)
1006
+ .on('end', dragEnd));
1007
+
1008
+ graphNodeSel.append('circle')
1009
+ .attr('r', d => nodeRadius(d))
1010
+ .attr('fill', d => getLangColor(d.language));
1011
+
1012
+ graphNodeSel.append('text')
1013
+ .attr('class', 'node-label')
1014
+ .attr('dx', d => nodeRadius(d) + 4)
1015
+ .attr('dy', 3)
1016
+ .text(d => d.path.split('/').pop());
1017
+
1018
+ // Hover
1019
+ const tooltip = document.getElementById('graph-tooltip');
1020
+ graphNodeSel
1021
+ .on('mouseover', function(event, d) {
1022
+ d3.select(this).raise();
1023
+ // Highlight connected edges
1024
+ graphLinkSel
1025
+ .classed('highlighted', l => l.source.id === d.id || l.target.id === d.id)
1026
+ .attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 0.9 : edgeOpacity(l));
1027
+ // Tooltip
1028
+ tooltip.style.display = 'block';
1029
+ tooltip.querySelector('.tt-path').textContent = d.path;
1030
+ tooltip.querySelector('.tt-meta').textContent =
1031
+ (d.language || 'unknown') + ' · PR ' + (d.pagerank || 0).toFixed(3) + ' · ' + formatBytes(d.size_bytes);
1032
+ })
1033
+ .on('mousemove', function(event) {
1034
+ tooltip.style.left = (event.offsetX + 16) + 'px';
1035
+ tooltip.style.top = (event.offsetY - 10) + 'px';
1036
+ })
1037
+ .on('mouseout', function() {
1038
+ graphLinkSel.classed('highlighted', false)
1039
+ .attr('stroke-opacity', d => edgeOpacity(d));
1040
+ tooltip.style.display = 'none';
1041
+ })
1042
+ .on('click', function(event, d) {
1043
+ event.stopPropagation();
1044
+ graphNodeSel.classed('selected', false);
1045
+ d3.select(this).classed('selected', true);
1046
+ graphSelectedNode = d;
1047
+ inspectFile(d.path);
844
1048
  });
845
- graphState.edges = state.graphData.edges;
846
- const nm = {};
847
- graphState.nodes.forEach(n => nm[n.path] = n);
848
-
849
- // Run force simulation
850
- for (let iter = 0; iter < 120; iter++) {
851
- // Repulsion
852
- for (let i = 0; i < graphState.nodes.length; i++) {
853
- for (let j = i+1; j < graphState.nodes.length; j++) {
854
- const a = graphState.nodes[i], b = graphState.nodes[j];
855
- const dx = b.x - a.x, dy = b.y - a.y;
856
- const d = Math.max(Math.sqrt(dx*dx + dy*dy), 1);
857
- const f = 8000 / (d * d);
858
- const fx = (dx/d) * f, fy = (dy/d) * f;
859
- a.vx -= fx; a.vy -= fy;
860
- b.vx += fx; b.vy += fy;
861
- }
862
- }
863
- // Attraction
864
- for (const e of graphState.edges) {
865
- const a = nm[e.source], b = nm[e.target];
866
- if (!a || !b) continue;
867
- const dx = b.x - a.x, dy = b.y - a.y;
868
- const d = Math.max(Math.sqrt(dx*dx + dy*dy), 1);
869
- const f = (d - 140) * 0.05;
870
- const fx = (dx/d) * f, fy = (dy/d) * f;
871
- a.vx += fx; a.vy += fy;
872
- b.vx -= fx; b.vy -= fy;
873
- }
874
- // Center gravity + damping
875
- for (const n of graphState.nodes) {
876
- n.vx += (w/2 - n.x) * 0.008;
877
- n.vy += (h/2 - n.y) * 0.008;
878
- n.x += n.vx * 0.4;
879
- n.y += n.vy * 0.4;
880
- n.vx *= 0.85;
881
- n.vy *= 0.85;
882
- }
883
- }
884
- }
885
1049
 
886
- // Clear
887
- ctx.fillStyle = '#0E0D0B';
888
- ctx.fillRect(0, 0, w, h);
889
-
890
- // Draw edges
891
- const nm = {};
892
- graphState.nodes.forEach(n => nm[n.path] = n);
893
- ctx.lineWidth = 1;
894
- for (const e of graphState.edges) {
895
- const a = nm[e.source], b = nm[e.target];
896
- if (!a || !b) continue;
897
- const highlight = graphState.hover && (e.source === graphState.hover.path || e.target === graphState.hover.path);
898
- ctx.strokeStyle = highlight ? '#E85A2A' : '#2A2620';
899
- ctx.beginPath();
900
- ctx.moveTo(a.x, a.y);
901
- ctx.lineTo(b.x, b.y);
902
- ctx.stroke();
903
- }
1050
+ // Click background to deselect
1051
+ svg.on('click', () => {
1052
+ graphNodeSel.classed('selected', false);
1053
+ graphSelectedNode = null;
1054
+ });
904
1055
 
905
- // Draw nodes
906
- const filter = state.graphFilter || '';
907
- for (const n of graphState.nodes) {
908
- const size = 3 + (n.pagerank || 0) * 12;
909
- const match = !filter || n.path.toLowerCase().includes(filter);
910
- const dim = filter && !match;
911
-
912
- ctx.globalAlpha = dim ? 0.15 : 1;
913
- ctx.beginPath();
914
- ctx.arc(n.x, n.y, size, 0, Math.PI*2);
915
- ctx.fillStyle = getLangColor(n.language);
916
- if (graphState.hover === n) ctx.fillStyle = '#E85A2A';
917
- ctx.fill();
918
-
919
- // Label top N by PageRank
920
- if ((n.pagerank || 0) > 0.3 || graphState.hover === n) {
921
- ctx.font = '11px "JetBrains Mono", monospace';
922
- ctx.fillStyle = '#A39886';
923
- const label = n.path.split('/').pop();
924
- ctx.fillText(label, n.x + size + 4, n.y + 3);
925
- }
926
- }
927
- ctx.globalAlpha = 1;
928
-
929
- // Mouse handling
930
- canvas.onmousemove = (ev) => {
931
- const rect = canvas.getBoundingClientRect();
932
- const mx = ev.clientX - rect.left;
933
- const my = ev.clientY - rect.top;
934
- let hit = null;
935
- for (const n of graphState.nodes) {
936
- const size = 3 + (n.pagerank || 0) * 12 + 3;
937
- const dx = mx - n.x, dy = my - n.y;
938
- if (dx*dx + dy*dy < size*size) { hit = n; break; }
939
- }
940
- if (hit !== graphState.hover) {
941
- graphState.hover = hit;
942
- canvas.style.cursor = hit ? 'pointer' : 'grab';
943
- drawGraphOnly();
944
- }
945
- };
946
- canvas.onclick = () => {
947
- if (graphState.hover) inspectFile(graphState.hover.path);
948
- };
949
- }
1056
+ // Force simulation
1057
+ graphSim = d3.forceSimulation(nodes)
1058
+ .force('link', d3.forceLink(links).id(d => d.id).distance(d => 60 + 40 / Math.max(d.weight, 1)))
1059
+ .force('charge', d3.forceManyBody().strength(-100))
1060
+ .force('center', d3.forceCenter(w / 2, h / 2))
1061
+ .force('collision', d3.forceCollide().radius(d => nodeRadius(d) + 2))
1062
+ .alphaDecay(0.03)
1063
+ .on('tick', () => {
1064
+ graphLinkSel
1065
+ .attr('x1', d => d.source.x)
1066
+ .attr('y1', d => d.source.y)
1067
+ .attr('x2', d => d.target.x)
1068
+ .attr('y2', d => d.target.y);
1069
+ graphNodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
1070
+ });
950
1071
 
951
- function drawGraphOnly() {
952
- // Re-draw without re-running layout
953
- const canvas = document.getElementById('graph-canvas');
954
- if (!canvas) return;
955
- const dpr = window.devicePixelRatio || 1;
956
- const w = canvas.parentElement.clientWidth;
957
- const h = canvas.parentElement.clientHeight;
958
- const ctx = canvas.getContext('2d');
959
- ctx.resetTransform();
960
- ctx.scale(dpr, dpr);
961
- ctx.fillStyle = '#0E0D0B';
962
- ctx.fillRect(0, 0, w, h);
963
-
964
- const nm = {};
965
- graphState.nodes.forEach(n => nm[n.path] = n);
966
- ctx.lineWidth = 1;
967
- for (const e of graphState.edges) {
968
- const a = nm[e.source], b = nm[e.target];
969
- if (!a || !b) continue;
970
- const highlight = graphState.hover && (e.source === graphState.hover.path || e.target === graphState.hover.path);
971
- ctx.strokeStyle = highlight ? '#E85A2A' : '#2A2620';
972
- ctx.beginPath();
973
- ctx.moveTo(a.x, a.y);
974
- ctx.lineTo(b.x, b.y);
975
- ctx.stroke();
976
- }
1072
+ // Render legend
1073
+ renderGraphLegend();
1074
+
1075
+ // Update slider max
1076
+ const maxPR = Math.max(...nodes.map(n => n.pagerank || 0), 0.01);
1077
+ const slider = document.getElementById('graph-pr-slider');
1078
+ slider.max = 100;
1079
+ slider.value = 0;
1080
+ document.getElementById('graph-pr-val').textContent = '0.00';
1081
+ }
1082
+
1083
+ function dragStart(event, d) {
1084
+ if (!event.active) graphSim.alphaTarget(0.3).restart();
1085
+ d.fx = d.x;
1086
+ d.fy = d.y;
1087
+ }
1088
+ function dragging(event, d) {
1089
+ d.fx = event.x;
1090
+ d.fy = event.y;
1091
+ }
1092
+ function dragEnd(event, d) {
1093
+ if (!event.active) graphSim.alphaTarget(0);
1094
+ d.fx = null;
1095
+ d.fy = null;
1096
+ }
1097
+
1098
+ function graphApplyFilter() {
1099
+ if (!graphNodeSel || !graphLinkSel) return;
1100
+ const textFilter = (state.graphFilter || '').toLowerCase();
1101
+ const slider = document.getElementById('graph-pr-slider');
1102
+ const maxPR = state.graphData ? Math.max(...state.graphData.nodes.map(n => n.pagerank || 0), 0.01) : 1;
1103
+ const minPR = (parseInt(slider.value) / 100) * maxPR;
1104
+ document.getElementById('graph-pr-val').textContent = minPR.toFixed(2);
1105
+
1106
+ graphNodeSel.each(function(d) {
1107
+ const textMatch = !textFilter || d.path.toLowerCase().includes(textFilter);
1108
+ const prMatch = (d.pagerank || 0) >= minPR;
1109
+ const visible = textMatch && prMatch;
1110
+ d._visible = visible;
1111
+ d3.select(this).style('opacity', visible ? 1 : 0.08);
1112
+ });
1113
+
1114
+ graphLinkSel.style('opacity', function(d) {
1115
+ const srcVis = d.source._visible !== false;
1116
+ const tgtVis = d.target._visible !== false;
1117
+ return (srcVis && tgtVis) ? edgeOpacity(d) : 0.02;
1118
+ });
1119
+ }
977
1120
 
978
- const filter = state.graphFilter || '';
979
- for (const n of graphState.nodes) {
980
- const size = 3 + (n.pagerank || 0) * 12;
981
- const match = !filter || n.path.toLowerCase().includes(filter);
982
- const dim = filter && !match;
983
- ctx.globalAlpha = dim ? 0.15 : 1;
984
- ctx.beginPath();
985
- ctx.arc(n.x, n.y, size, 0, Math.PI*2);
986
- ctx.fillStyle = getLangColor(n.language);
987
- if (graphState.hover === n) ctx.fillStyle = '#E85A2A';
988
- ctx.fill();
989
- if ((n.pagerank || 0) > 0.3 || graphState.hover === n) {
990
- ctx.font = '11px "JetBrains Mono", monospace';
991
- ctx.fillStyle = '#A39886';
992
- ctx.fillText(n.path.split('/').pop(), n.x + size + 4, n.y + 3);
1121
+ function renderGraphLegend() {
1122
+ if (!state.graphData) return;
1123
+ const langs = new Map();
1124
+ for (const n of state.graphData.nodes) {
1125
+ if (n.language && !langs.has(n.language)) {
1126
+ langs.set(n.language, getLangColor(n.language));
993
1127
  }
994
1128
  }
995
- ctx.globalAlpha = 1;
1129
+ const el = document.getElementById('graph-legend');
1130
+ el.innerHTML = Array.from(langs.entries()).map(([lang, color]) =>
1131
+ '<div class="graph-legend-item"><div class="graph-legend-dot" style="background:' + color + '"></div><span>' + lang + '</span></div>'
1132
+ ).join('') +
1133
+ '<div class="graph-legend-item" style="margin-top:4px;color:var(--text-3);font-size:9px;">' +
1134
+ (state.graphData.total || state.graphData.nodes.length) + ' total files' +
1135
+ (state.graphData.nodes.length < (state.graphData.total || 0) ? ' · showing top ' + state.graphData.nodes.length : '') +
1136
+ '</div>';
996
1137
  }
997
1138
 
998
1139
  function getLangColor(lang) {
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard-html.js","sourceRoot":"","sources":["../../../src/server/dashboard-html.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,gBAAgB;IAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAwxCD,CAAC;AACT,CAAC"}
1
+ {"version":3,"file":"dashboard-html.js","sourceRoot":"","sources":["../../../src/server/dashboard-html.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,gBAAgB;IAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAq6CD,CAAC;AACT,CAAC"}
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { log } from "../utils/logger.js";
6
6
  import { getDashboardHTML } from "./dashboard-html.js";
7
+ import { getClustersJSON } from "./tools/clusters.js";
7
8
  // Read the package version once at module load so the dashboard footer
8
9
  // and any other surface can show what version is actually running.
9
10
  // Was hardcoded as "v0.1.7" in the dashboard HTML until a dogfood
@@ -130,6 +131,30 @@ export function startHttpServer(indexer, port = 3847) {
130
131
  }));
131
132
  json(res, overview);
132
133
  }
134
+ else if (url.pathname === "/api/graph") {
135
+ const limitParam = url.searchParams.get("limit");
136
+ const limit = limitParam ? parseInt(limitParam, 10) : 100;
137
+ const allFiles = indexer.fileStore.getAll(); // already sorted by pagerank DESC
138
+ const allEdges = indexer.graphStore.getAll();
139
+ const files = limit > 0 ? allFiles.slice(0, limit) : allFiles;
140
+ const fileIdSet = new Set(files.map(f => f.id));
141
+ const edges = allEdges.filter(e => fileIdSet.has(e.source_file_id) && fileIdSet.has(e.target_file_id));
142
+ json(res, {
143
+ nodes: files.map(f => ({
144
+ id: f.id,
145
+ path: f.path,
146
+ language: f.language,
147
+ pagerank: f.pagerank,
148
+ size_bytes: f.size_bytes,
149
+ })),
150
+ edges: edges.map(e => ({
151
+ source: e.source_file_id,
152
+ target: e.target_file_id,
153
+ weight: e.reference_count,
154
+ })),
155
+ total: allFiles.length,
156
+ });
157
+ }
133
158
  else if (url.pathname === "/api/deps") {
134
159
  const files = indexer.fileStore.getAll();
135
160
  const fileMap = new Map(files.map(f => [f.id, f.path]));
@@ -157,6 +182,10 @@ export function startHttpServer(indexer, port = 3847) {
157
182
  edges,
158
183
  });
159
184
  }
185
+ else if (url.pathname === "/api/clusters") {
186
+ const clusters = getClustersJSON(indexer);
187
+ json(res, clusters);
188
+ }
160
189
  else if (url.pathname === "/api/search") {
161
190
  const q = url.searchParams.get("q");
162
191
  if (!q) {