@mishasinitcyn/betterrank 0.1.6 → 0.1.8

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.
package/src/ui.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>betterrank</title>
7
+ <script src="https://d3js.org/d3.v7.min.js"></script>
7
8
  <style>
8
9
  :root {
9
10
  --bg: #0c0c0c;
@@ -475,6 +476,180 @@
475
476
  border-color: var(--accent);
476
477
  background: rgba(37, 99, 235, 0.08);
477
478
  }
479
+
480
+ /* Expandable map cards */
481
+ .map-card {
482
+ border-bottom: 1px solid var(--border);
483
+ }
484
+ .map-card-header {
485
+ display: flex;
486
+ align-items: center;
487
+ gap: 8px;
488
+ padding: 12px 16px 4px;
489
+ cursor: pointer;
490
+ border-radius: 6px 6px 0 0;
491
+ transition: background 0.1s;
492
+ }
493
+ .map-card-header:hover { background: var(--bg-raised); }
494
+ .map-chevron {
495
+ font-size: 10px;
496
+ color: var(--text-faint);
497
+ transition: transform 0.2s ease;
498
+ flex-shrink: 0;
499
+ width: 14px;
500
+ text-align: center;
501
+ user-select: none;
502
+ }
503
+ .map-card.expanded .map-chevron { transform: rotate(90deg); }
504
+ .map-symbols {
505
+ padding: 2px 16px 10px 38px;
506
+ }
507
+ .map-expand {
508
+ display: none;
509
+ padding: 10px 16px 14px 38px;
510
+ background: var(--bg-raised);
511
+ margin: 0 8px 8px;
512
+ border-radius: 6px;
513
+ }
514
+ .map-card.expanded .map-expand { display: block; }
515
+ .map-expand-section {
516
+ margin-bottom: 10px;
517
+ }
518
+ .map-expand-section:last-child { margin-bottom: 0; }
519
+ .map-expand-title {
520
+ font-family: var(--mono);
521
+ font-size: 11px;
522
+ font-weight: 600;
523
+ letter-spacing: 0.3px;
524
+ color: var(--text-dim);
525
+ margin-bottom: 4px;
526
+ }
527
+ .map-expand-title .arrow { margin-right: 2px; }
528
+ .map-dep-item {
529
+ font-family: var(--mono);
530
+ font-size: 12px;
531
+ padding: 2px 0;
532
+ color: var(--accent);
533
+ cursor: pointer;
534
+ transition: color 0.1s;
535
+ }
536
+ .map-dep-item:hover { text-decoration: underline; }
537
+ .map-expand-empty {
538
+ font-family: var(--mono);
539
+ font-size: 11px;
540
+ color: var(--text-faint);
541
+ font-style: italic;
542
+ padding: 2px 0;
543
+ }
544
+ .map-expand-loading {
545
+ font-family: var(--mono);
546
+ font-size: 11px;
547
+ color: var(--text-dim);
548
+ padding: 4px 0;
549
+ }
550
+ .map-sym-item {
551
+ font-family: var(--mono);
552
+ font-size: 12px;
553
+ padding: 2px 0;
554
+ color: var(--text-dim);
555
+ cursor: pointer;
556
+ transition: color 0.1s;
557
+ display: flex;
558
+ align-items: baseline;
559
+ gap: 6px;
560
+ }
561
+ .map-sym-item:hover { color: var(--text); }
562
+ .map-sym-item .result-kind {
563
+ opacity: 0.55;
564
+ font-size: 9px;
565
+ flex-shrink: 0;
566
+ }
567
+ .map-sym-item .sym-line {
568
+ color: var(--text-faint);
569
+ min-width: 32px;
570
+ text-align: right;
571
+ flex-shrink: 0;
572
+ }
573
+
574
+ /* Graph view — inline within results area */
575
+ .graph-wrap {
576
+ position: relative;
577
+ height: calc(100vh - 280px);
578
+ min-height: 400px;
579
+ background: var(--bg);
580
+ border: 1px solid var(--border);
581
+ border-radius: var(--radius);
582
+ overflow: hidden;
583
+ }
584
+ .graph-wrap svg { width: 100%; height: 100%; display: block; }
585
+ .graph-filters {
586
+ position: absolute; top: 12px; right: 12px; z-index: 10;
587
+ background: rgba(22,22,22,0.9); backdrop-filter: blur(8px);
588
+ border: 1px solid var(--border); border-radius: 10px;
589
+ padding: 10px 12px; max-width: 240px;
590
+ }
591
+ .graph-filters-title { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
592
+ .graph-filter-badges { display: flex; flex-wrap: wrap; gap: 5px; }
593
+ .graph-badge {
594
+ display: inline-flex; align-items: center; gap: 5px;
595
+ padding: 3px 10px; border-radius: 999px;
596
+ font-size: 11px; font-weight: 500; cursor: pointer;
597
+ border: 1px solid var(--border); background: transparent;
598
+ color: var(--text-dim); transition: all 0.15s; user-select: none;
599
+ }
600
+ .graph-badge.active { background: var(--bg-raised); color: var(--text); }
601
+ .graph-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
602
+ .graph-badge:hover { border-color: #3f3f46; }
603
+ .graph-detail {
604
+ position: absolute; bottom: 12px; left: 12px; z-index: 10;
605
+ background: rgba(22,22,22,0.95); backdrop-filter: blur(8px);
606
+ border: 1px solid var(--border); border-radius: 10px;
607
+ padding: 14px 18px; min-width: 260px; max-width: 340px;
608
+ display: none;
609
+ }
610
+ .graph-detail.visible { display: block; }
611
+ .graph-detail-cat { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
612
+ .graph-detail-name { font-size: 15px; font-weight: 600; margin-bottom: 2px; color: var(--text); }
613
+ .graph-detail-path { font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-bottom: 10px; cursor: pointer; }
614
+ .graph-detail-path:hover { color: var(--accent); text-decoration: underline; }
615
+ .graph-detail-section { margin-top: 8px; }
616
+ .graph-detail-section-title { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
617
+ .graph-detail-item { font-size: 11px; color: var(--text-dim); padding: 2px 0; display: flex; align-items: center; gap: 6px; }
618
+ .graph-detail-item .dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
619
+ .graph-tooltip {
620
+ position: fixed; z-index: 70; pointer-events: none;
621
+ background: var(--bg-raised); border: 1px solid var(--border); border-radius: 6px;
622
+ padding: 6px 10px; font-size: 11px; font-family: var(--mono); display: none;
623
+ color: var(--text); box-shadow: 0 4px 12px rgba(0,0,0,0.4);
624
+ }
625
+ .graph-search {
626
+ position: absolute; bottom: 12px; right: 12px; z-index: 10;
627
+ background: rgba(22,22,22,0.95); backdrop-filter: blur(8px);
628
+ border: 1px solid var(--border); border-radius: 10px;
629
+ padding: 10px 12px; width: 280px;
630
+ }
631
+ .graph-search input {
632
+ width: 100%; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 6px;
633
+ padding: 7px 10px; font-size: 13px; color: var(--text); outline: none;
634
+ font-family: var(--mono);
635
+ }
636
+ .graph-search input::placeholder { color: var(--text-faint); }
637
+ .graph-search input:focus { border-color: var(--accent-dim); }
638
+ .graph-search-results { max-height: 200px; overflow-y: auto; margin-top: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
639
+ .graph-search-results:empty { display: none; }
640
+ .graph-search-result {
641
+ padding: 6px 8px; border-radius: 6px; cursor: pointer;
642
+ transition: background 0.1s; font-size: 12px; font-family: var(--mono);
643
+ color: var(--text-dim);
644
+ }
645
+ .graph-search-result:hover { background: var(--bg-hover); color: var(--text); }
646
+ .graph-link { stroke-opacity: 0.15; }
647
+ .graph-link.highlighted { stroke-opacity: 0.7; stroke-width: 2px !important; }
648
+ .graph-node circle { stroke-width: 1.5; cursor: pointer; transition: r 0.15s; }
649
+ .graph-node text { fill: var(--text-dim); font-size: 9px; pointer-events: none; font-family: var(--mono); }
650
+ .graph-node.dimmed circle { opacity: 0.15; }
651
+ .graph-node.dimmed text { opacity: 0.1; }
652
+ .graph-node.highlighted circle { stroke-width: 3; }
478
653
  </style>
479
654
  </head>
480
655
  <body>
@@ -503,9 +678,9 @@
503
678
  </div>
504
679
  </div>
505
680
 
506
- <!-- Recent repos -->
681
+ <!-- Indexed repos -->
507
682
  <div class="recent-repos" id="recentRepos" style="display:none">
508
- <div class="recent-label">Recent</div>
683
+ <div class="recent-label">Indexed</div>
509
684
  <div class="recent-list" id="recentList"></div>
510
685
  </div>
511
686
 
@@ -531,6 +706,7 @@
531
706
  <button class="tab" data-tool="dependents">Dependents</button>
532
707
  <button class="tab" data-tool="neighborhood">Neighborhood</button>
533
708
  <button class="tab" data-tool="structure">Structure</button>
709
+ <button class="tab" data-tool="graph">Graph</button>
534
710
  </div>
535
711
 
536
712
  <!-- Search area -->
@@ -559,9 +735,9 @@
559
735
 
560
736
  <!-- Results -->
561
737
  <div class="results" id="results">
562
- <div class="empty-state">
738
+ <div class="empty-state" id="emptyState">
563
739
  <div class="icon">&#x1F4C2;</div>
564
- <p>Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
740
+ <p id="emptyMsg">Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
565
741
  Then search symbols, explore the map, or trace callers.<br><br>
566
742
  <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search</p>
567
743
  </div>
@@ -576,6 +752,9 @@
576
752
 
577
753
  </div>
578
754
 
755
+ <!-- Graph tooltip (needs to be outside .app for fixed positioning) -->
756
+ <div class="graph-tooltip" id="graphTooltip"></div>
757
+
579
758
  <script>
580
759
  const $ = s => document.querySelector(s);
581
760
  const $$ = s => document.querySelectorAll(s);
@@ -588,6 +767,7 @@ let pageSize = 20;
588
767
  let totalResults = 0;
589
768
  let indexed = false;
590
769
  let loading = false;
770
+ const expandCache = new Map(); // file -> { dependents, deps } for map expansion
591
771
 
592
772
  // Elements
593
773
  const repoInput = $('#repoPath');
@@ -627,11 +807,13 @@ function removeRecentRepo(path) {
627
807
  const recent = getRecentRepos().filter(r => r !== path);
628
808
  localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
629
809
  renderRecentRepos();
810
+ updateEmptyState();
630
811
  }
631
812
 
632
813
  function clearRecentRepos() {
633
814
  localStorage.removeItem(RECENT_KEY);
634
815
  renderRecentRepos();
816
+ updateEmptyState();
635
817
  }
636
818
 
637
819
  function renderRecentRepos() {
@@ -786,10 +968,11 @@ btnIndex.onclick = async () => {
786
968
 
787
969
  addRecentRepo(root);
788
970
  indexed = true;
971
+ expandCache.clear();
789
972
  showStats(data.stats, data.elapsed);
790
973
  await loadPickerData();
791
- resultsEl.innerHTML = `<div class="empty-state"><p>Indexed <strong>${data.stats.files}</strong> files, <strong>${data.stats.symbols}</strong> symbols in ${data.elapsed}ms.<br>Start typing to search.</p></div>`;
792
- queryInput.focus();
974
+ // Auto-switch to Map tab to show the ranked overview
975
+ document.querySelector('[data-tool="map"]').click();
793
976
  } catch (e) {
794
977
  resultsEl.innerHTML = `<div class="error-msg">${esc(e.message)}</div>`;
795
978
  } finally {
@@ -917,9 +1100,15 @@ $$('.tab').forEach(tab => {
917
1100
  tab.classList.add('active');
918
1101
  currentTool = tab.dataset.tool;
919
1102
  currentPage = 0;
1103
+
1104
+ // Stop any running graph simulation when leaving graph tab
1105
+ if (currentTool !== 'graph' && graphSimulation) {
1106
+ graphSimulation.stop();
1107
+ }
1108
+
920
1109
  updateSearchUI();
921
- // Auto-run: map/structure run server query, others show pickers or run
922
- if (['map', 'structure', 'symbols'].includes(currentTool)) {
1110
+ // Auto-run: map/structure/graph run server query, others show pickers or run
1111
+ if (['map', 'structure', 'symbols', 'graph'].includes(currentTool)) {
923
1112
  runQuery();
924
1113
  } else {
925
1114
  queryInput.value = '';
@@ -934,6 +1123,7 @@ function updateSearchUI() {
934
1123
  const needsKind = ['search', 'symbols'].includes(currentTool);
935
1124
 
936
1125
  $('#searchArea').style.display = needsQuery ? 'block' : 'none';
1126
+ pagination.style.display = currentTool === 'graph' ? 'none' : pagination.style.display;
937
1127
  kindFilter.style.display = needsKind ? 'inline-block' : 'none';
938
1128
 
939
1129
  const placeholders = {
@@ -991,7 +1181,7 @@ async function runQuery() {
991
1181
  if (kindFilter.value) url += `&kind=${kindFilter.value}`;
992
1182
  break;
993
1183
  case 'map':
994
- url = `${API}/api/map?offset=${offset}&limit=${pageSize}`;
1184
+ url = `${API}/api/map?offset=${offset}&limit=${pageSize}&format=structured`;
995
1185
  if (query) url += `&focus=${enc(query)}`;
996
1186
  break;
997
1187
  case 'symbols':
@@ -1022,6 +1212,9 @@ async function runQuery() {
1022
1212
  case 'structure':
1023
1213
  url = `${API}/api/structure?depth=4`;
1024
1214
  break;
1215
+ case 'graph':
1216
+ url = `${API}/api/graph?limit=500`;
1217
+ break;
1025
1218
  }
1026
1219
 
1027
1220
  const res = await fetch(url);
@@ -1048,6 +1241,7 @@ function render(data) {
1048
1241
  case 'dependents': return renderFileList(data.results, data.total);
1049
1242
  case 'neighborhood': return renderNeighborhood(data);
1050
1243
  case 'structure': return renderPre(data.content);
1244
+ case 'graph': return renderGraph(data);
1051
1245
  }
1052
1246
  }
1053
1247
 
@@ -1098,34 +1292,138 @@ function renderMap(data) {
1098
1292
  totalResults = data.totalSymbols || 0;
1099
1293
  resultCount.textContent = `${data.shownSymbols} of ${data.totalSymbols} symbols across ${data.shownFiles} of ${data.totalFiles} files`;
1100
1294
 
1101
- if (!data.content || data.content === '(empty index)') {
1295
+ if (!data.files || data.files.length === 0) {
1102
1296
  resultsEl.innerHTML = '<div class="empty-state"><p>Empty index.</p></div>';
1103
1297
  pagination.style.display = 'none';
1104
1298
  return;
1105
1299
  }
1106
1300
 
1107
- // Parse map output into structured cards
1108
- const lines = data.content.split('\n');
1301
+ resultsEl.innerHTML = data.files.map(f => {
1302
+ const symsHtml = f.symbols.map(s =>
1303
+ `<div class="result-sig" style="font-size:12px">` +
1304
+ `<span class="result-kind kind-${s.kind || 'variable'}" style="font-size:9px">${s.kind || '?'}</span>` +
1305
+ `<span class="result-line">${s.lineStart}</span> ${highlightSig(s.signature || s.name)}` +
1306
+ `</div>`
1307
+ ).join('');
1308
+
1309
+ return `<div class="map-card" data-file="${esc(f.path)}">` +
1310
+ `<div class="map-card-header">` +
1311
+ `<span class="map-chevron">&#x25B8;</span>` +
1312
+ `<a class="result-file" href="#" onclick="openFile('${escJs(f.path)}'); event.stopPropagation(); return false" title="Open in editor">${esc(f.path)}</a>` +
1313
+ `</div>` +
1314
+ `<div class="map-symbols">${symsHtml}</div>` +
1315
+ `<div class="map-expand"></div>` +
1316
+ `</div>`;
1317
+ }).join('');
1318
+
1319
+ // Attach expand/collapse handlers via delegation
1320
+ resultsEl.querySelectorAll('.map-card-header').forEach(header => {
1321
+ header.onclick = (e) => {
1322
+ if (e.target.closest('.result-file')) return;
1323
+ const card = header.closest('.map-card');
1324
+ toggleMapExpand(card);
1325
+ };
1326
+ });
1327
+
1328
+ updatePagination();
1329
+ }
1330
+
1331
+ async function toggleMapExpand(card) {
1332
+ const file = card.dataset.file;
1333
+ const expandEl = card.querySelector('.map-expand');
1334
+ const isExpanded = card.classList.contains('expanded');
1335
+
1336
+ if (isExpanded) {
1337
+ card.classList.remove('expanded');
1338
+ return;
1339
+ }
1340
+
1341
+ card.classList.add('expanded');
1342
+
1343
+ // Check cache
1344
+ if (expandCache.has(file)) {
1345
+ renderExpandContent(expandEl, expandCache.get(file));
1346
+ return;
1347
+ }
1348
+
1349
+ expandEl.innerHTML = '<div class="map-expand-loading">Loading dependencies...</div>';
1350
+
1351
+ try {
1352
+ const [depsRes, dependentsRes, symbolsRes] = await Promise.all([
1353
+ fetch(`${API}/api/deps?file=${enc(file)}&limit=15`).then(r => r.json()),
1354
+ fetch(`${API}/api/dependents?file=${enc(file)}&limit=15`).then(r => r.json()),
1355
+ fetch(`${API}/api/symbols?file=${enc(file)}&limit=500`).then(r => r.json()),
1356
+ ]);
1357
+
1358
+ const data = {
1359
+ dependents: dependentsRes.results || [],
1360
+ dependentsTotal: dependentsRes.total || 0,
1361
+ deps: depsRes.results || [],
1362
+ depsTotal: depsRes.total || 0,
1363
+ symbols: (symbolsRes.results || []).sort((a, b) => a.lineStart - b.lineStart),
1364
+ };
1365
+ expandCache.set(file, data);
1366
+ renderExpandContent(expandEl, data);
1367
+ } catch (e) {
1368
+ expandEl.innerHTML = `<div class="error-msg">${esc(e.message)}</div>`;
1369
+ }
1370
+ }
1371
+
1372
+ function renderExpandContent(el, data) {
1109
1373
  let html = '';
1110
- let currentFile = null;
1111
-
1112
- for (const line of lines) {
1113
- if (line.endsWith(':') && !line.startsWith(' ')) {
1114
- currentFile = line.slice(0, -1);
1115
- html += `<div class="result-card" style="padding-bottom:4px"><a class="result-file" href="#" onclick="openFile('${currentFile}'); return false" title="Open in editor">${esc(currentFile)}</a>`;
1116
- } else if (line.trim() === '') {
1117
- if (currentFile) { html += '</div>'; currentFile = null; }
1118
- } else if (currentFile) {
1119
- const match = line.match(/^\s*(\d+)\│\s*(.*)$/);
1120
- if (match) {
1121
- html += `<div class="result-sig" style="margin-left:12px;font-size:12px"><span class="result-line">${match[1]}</span> ${highlightSig(match[2])}</div>`;
1122
- }
1374
+
1375
+ // Imported by (dependents)
1376
+ html += '<div class="map-expand-section">';
1377
+ html += `<div class="map-expand-title"><span class="arrow">&larr;</span> Imported by (${data.dependentsTotal})</div>`;
1378
+ if (data.dependents.length === 0) {
1379
+ html += '<div class="map-expand-empty">No dependents</div>';
1380
+ } else {
1381
+ for (const f of data.dependents) {
1382
+ const fp = typeof f === 'string' ? f : f.file;
1383
+ html += `<div class="map-dep-item" onclick="openFile('${escJs(fp)}')">${esc(fp)}</div>`;
1384
+ }
1385
+ if (data.dependentsTotal > data.dependents.length) {
1386
+ html += `<div class="map-expand-empty">...and ${data.dependentsTotal - data.dependents.length} more</div>`;
1123
1387
  }
1124
1388
  }
1125
- if (currentFile) html += '</div>';
1389
+ html += '</div>';
1126
1390
 
1127
- resultsEl.innerHTML = html;
1128
- updatePagination();
1391
+ // Depends on (deps)
1392
+ html += '<div class="map-expand-section">';
1393
+ html += `<div class="map-expand-title"><span class="arrow">&rarr;</span> Depends on (${data.depsTotal})</div>`;
1394
+ if (data.deps.length === 0) {
1395
+ html += '<div class="map-expand-empty">No dependencies</div>';
1396
+ } else {
1397
+ for (const f of data.deps) {
1398
+ const fp = typeof f === 'string' ? f : f.file;
1399
+ html += `<div class="map-dep-item" onclick="openFile('${escJs(fp)}')">${esc(fp)}</div>`;
1400
+ }
1401
+ if (data.depsTotal > data.deps.length) {
1402
+ html += `<div class="map-expand-empty">...and ${data.depsTotal - data.deps.length} more</div>`;
1403
+ }
1404
+ }
1405
+ html += '</div>';
1406
+
1407
+ // All symbols in source order
1408
+ if (data.symbols && data.symbols.length > 0) {
1409
+ html += '<div class="map-expand-section">';
1410
+ html += `<div class="map-expand-title">All symbols (${data.symbols.length})</div>`;
1411
+ for (const s of data.symbols) {
1412
+ html += `<div class="map-sym-item" onclick="openFile('${escJs(s.file)}', ${s.lineStart})">` +
1413
+ `<span class="result-kind kind-${s.kind || 'variable'}">${s.kind || '?'}</span>` +
1414
+ `<span class="sym-line">${s.lineStart}</span> ${esc(s.signature || s.name)}` +
1415
+ `</div>`;
1416
+ }
1417
+ html += '</div>';
1418
+ }
1419
+
1420
+ el.innerHTML = html;
1421
+ }
1422
+
1423
+ // Escape for use in JS string literals (single-quoted onclick handlers)
1424
+ function escJs(s) {
1425
+ if (!s) return '';
1426
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1129
1427
  }
1130
1428
 
1131
1429
  function renderNeighborhood(data) {
@@ -1244,6 +1542,332 @@ document.addEventListener('keydown', (e) => {
1244
1542
  repoInput.onkeydown = (e) => {
1245
1543
  if (e.key === 'Enter') btnIndex.click();
1246
1544
  };
1545
+
1546
+ // --- Graph visualization (inline) ---
1547
+ const graphTooltip = $('#graphTooltip');
1548
+
1549
+ const GRAPH_PALETTE = [
1550
+ '#3b82f6', '#a855f7', '#06b6d4', '#22c55e', '#f59e0b',
1551
+ '#14b8a6', '#ec4899', '#eab308', '#f97316', '#f43f5e',
1552
+ '#6366f1', '#84cc16', '#0ea5e9', '#d946ef', '#fb7185',
1553
+ ];
1554
+ let graphCategoryColors = {};
1555
+ let graphData = null;
1556
+ let graphSimulation = null;
1557
+ let graphActiveCategories = new Set();
1558
+ let graphG = null;
1559
+ let graphZoom = null;
1560
+ let graphLinkGroup = null;
1561
+ let graphNodeGroup = null;
1562
+
1563
+ function getCategoryColor(cat) {
1564
+ if (!graphCategoryColors[cat]) {
1565
+ const idx = Object.keys(graphCategoryColors).length % GRAPH_PALETTE.length;
1566
+ graphCategoryColors[cat] = GRAPH_PALETTE[idx];
1567
+ }
1568
+ return graphCategoryColors[cat];
1569
+ }
1570
+
1571
+ function renderGraph(data) {
1572
+ totalResults = 0;
1573
+ pagination.style.display = 'none';
1574
+ graphData = data;
1575
+
1576
+ if (!data.nodes || data.nodes.length === 0) {
1577
+ resultsEl.innerHTML = '<div class="empty-state"><p>No graph data. Index a repository first.</p></div>';
1578
+ resultCount.textContent = '';
1579
+ return;
1580
+ }
1581
+
1582
+ resultCount.textContent = `${data.nodes.length} modules, ${data.edges.length} edges`;
1583
+
1584
+ // Assign category colors
1585
+ graphCategoryColors = {};
1586
+ const categories = [...new Set(data.nodes.map(n => n.category))].sort();
1587
+ categories.forEach(c => getCategoryColor(c));
1588
+ graphActiveCategories = new Set(categories);
1589
+
1590
+ // Build the inline graph HTML
1591
+ resultsEl.innerHTML = `
1592
+ <div class="graph-wrap" id="graphWrap">
1593
+ <div class="graph-filters">
1594
+ <div class="graph-filters-title">Categories</div>
1595
+ <div class="graph-filter-badges" id="graphFilterBadges"></div>
1596
+ </div>
1597
+ <div class="graph-detail" id="graphDetail">
1598
+ <div class="graph-detail-cat" id="graphDetailCat"></div>
1599
+ <div class="graph-detail-name" id="graphDetailName"></div>
1600
+ <div class="graph-detail-path" id="graphDetailPath"></div>
1601
+ <div class="graph-detail-section">
1602
+ <div class="graph-detail-section-title">Imports (<span id="graphDetailImportsCount">0</span>)</div>
1603
+ <div id="graphDetailImports"></div>
1604
+ </div>
1605
+ <div class="graph-detail-section">
1606
+ <div class="graph-detail-section-title">Imported By (<span id="graphDetailImportedByCount">0</span>)</div>
1607
+ <div id="graphDetailImportedBy"></div>
1608
+ </div>
1609
+ </div>
1610
+ <div class="graph-search">
1611
+ <input type="text" id="graphSearchInput" placeholder="Search files..." autocomplete="off" spellcheck="false" />
1612
+ <div class="graph-search-results" id="graphSearchResults"></div>
1613
+ </div>
1614
+ <svg id="graphSvg"></svg>
1615
+ </div>
1616
+ `;
1617
+
1618
+ // Build filter badges
1619
+ const badgeContainer = $('#graphFilterBadges');
1620
+ categories.forEach(cat => {
1621
+ const badge = document.createElement('div');
1622
+ badge.className = 'graph-badge active';
1623
+ badge.dataset.cat = cat;
1624
+ badge.innerHTML = `<span class="dot" style="background:${getCategoryColor(cat)}"></span>${esc(cat)}`;
1625
+ badge.addEventListener('click', () => {
1626
+ if (graphActiveCategories.has(cat)) {
1627
+ graphActiveCategories.delete(cat);
1628
+ badge.classList.remove('active');
1629
+ } else {
1630
+ graphActiveCategories.add(cat);
1631
+ badge.classList.add('active');
1632
+ }
1633
+ updateGraphViz();
1634
+ });
1635
+ badgeContainer.appendChild(badge);
1636
+ });
1637
+
1638
+ // SVG setup
1639
+ const wrap = $('#graphWrap');
1640
+ const width = wrap.clientWidth;
1641
+ const height = wrap.clientHeight;
1642
+ const svg = d3.select('#graphSvg').attr('width', width).attr('height', height);
1643
+
1644
+ graphG = svg.append('g');
1645
+ graphZoom = d3.zoom()
1646
+ .scaleExtent([0.05, 5])
1647
+ .on('zoom', (e) => graphG.attr('transform', e.transform));
1648
+ svg.call(graphZoom);
1649
+
1650
+ svg.append('defs').append('marker')
1651
+ .attr('id', 'graph-arrow')
1652
+ .attr('viewBox', '0 -3 6 6')
1653
+ .attr('refX', 16).attr('refY', 0)
1654
+ .attr('markerWidth', 5).attr('markerHeight', 5)
1655
+ .attr('orient', 'auto')
1656
+ .append('path')
1657
+ .attr('d', 'M0,-3L6,0L0,3')
1658
+ .attr('fill', '#555');
1659
+
1660
+ graphLinkGroup = graphG.append('g');
1661
+ graphNodeGroup = graphG.append('g');
1662
+
1663
+ // Click empty space to dismiss detail
1664
+ svg.on('click', () => {
1665
+ const detail = $('#graphDetail');
1666
+ if (detail) detail.classList.remove('visible');
1667
+ });
1668
+
1669
+ updateGraphViz();
1670
+
1671
+ // Fit to view after simulation settles
1672
+ setTimeout(() => {
1673
+ if (!graphG || !graphG.node()) return;
1674
+ const bounds = graphG.node().getBBox();
1675
+ if (bounds.width === 0) return;
1676
+ const dx = bounds.width, dy = bounds.height, x = bounds.x, y = bounds.y;
1677
+ const scale = 0.85 / Math.max(dx / width, dy / height);
1678
+ const translate = [width / 2 - scale * (x + dx / 2), height / 2 - scale * (y + dy / 2)];
1679
+ svg.transition().duration(750).call(
1680
+ graphZoom.transform,
1681
+ d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
1682
+ );
1683
+ }, 3000);
1684
+
1685
+ // Wire up inline search
1686
+ const searchInput = $('#graphSearchInput');
1687
+ const searchResultsEl = $('#graphSearchResults');
1688
+ if (searchInput) {
1689
+ searchInput.oninput = () => {
1690
+ const q = searchInput.value.trim().toLowerCase();
1691
+ if (!q || !graphData) {
1692
+ searchResultsEl.innerHTML = '';
1693
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', false);
1694
+ return;
1695
+ }
1696
+ const matches = graphData.nodes.filter(n => n.id.toLowerCase().includes(q)).slice(0, 20);
1697
+ const matchIds = new Set(matches.map(m => m.id));
1698
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', n => !matchIds.has(n.id));
1699
+ searchResultsEl.innerHTML = matches.map(m =>
1700
+ `<div class="graph-search-result" data-id="${esc(m.id)}">${esc(m.id)}</div>`
1701
+ ).join('');
1702
+ searchResultsEl.querySelectorAll('.graph-search-result').forEach(el => {
1703
+ el.onclick = () => {
1704
+ const node = graphData.nodes.find(n => n.id === el.dataset.id);
1705
+ if (!node || node.x == null) return;
1706
+ const s = 1.5;
1707
+ const t = [width / 2 - s * node.x, height / 2 - s * node.y];
1708
+ svg.transition().duration(500).call(
1709
+ graphZoom.transform, d3.zoomIdentity.translate(t[0], t[1]).scale(s)
1710
+ );
1711
+ onGraphNodeClick({ stopPropagation: () => {} }, node);
1712
+ };
1713
+ });
1714
+ };
1715
+ }
1716
+ }
1717
+
1718
+ function updateGraphViz() {
1719
+ if (!graphData) return;
1720
+ const wrap = $('#graphWrap');
1721
+ if (!wrap) return;
1722
+ const width = wrap.clientWidth;
1723
+ const height = wrap.clientHeight;
1724
+
1725
+ const nodes = graphData.nodes.filter(n => graphActiveCategories.has(n.category));
1726
+ const nodeIds = new Set(nodes.map(n => n.id));
1727
+ const edges = graphData.edges.filter(e =>
1728
+ nodeIds.has(e.source?.id || e.source) && nodeIds.has(e.target?.id || e.target)
1729
+ );
1730
+
1731
+ resultCount.textContent = `${nodes.length} modules, ${edges.length} edges`;
1732
+
1733
+ // In-degree for sizing
1734
+ const inDegree = {};
1735
+ edges.forEach(e => {
1736
+ const tid = e.target?.id || e.target;
1737
+ inDegree[tid] = (inDegree[tid] || 0) + 1;
1738
+ });
1739
+
1740
+ // Links
1741
+ const links = graphLinkGroup.selectAll('line').data(edges, d => `${d.source?.id||d.source}-${d.target?.id||d.target}`);
1742
+ links.exit().remove();
1743
+ links.enter().append('line')
1744
+ .attr('class', 'graph-link')
1745
+ .attr('stroke', '#555')
1746
+ .attr('stroke-width', 1)
1747
+ .attr('marker-end', 'url(#graph-arrow)');
1748
+
1749
+ // Nodes
1750
+ const nodeData = graphNodeGroup.selectAll('g.graph-node').data(nodes, d => d.id);
1751
+ nodeData.exit().remove();
1752
+ const nodeEnter = nodeData.enter().append('g').attr('class', 'graph-node');
1753
+
1754
+ nodeEnter.append('circle')
1755
+ .attr('r', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)))
1756
+ .attr('fill', d => getCategoryColor(d.category))
1757
+ .attr('stroke', d => d3.color(getCategoryColor(d.category)).brighter(0.5))
1758
+ .on('mouseover', onGraphNodeHover)
1759
+ .on('mouseout', onGraphNodeOut)
1760
+ .on('click', onGraphNodeClick);
1761
+
1762
+ nodeEnter.append('text')
1763
+ .attr('dx', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)) + 4)
1764
+ .attr('dy', 3)
1765
+ .text(d => d.label);
1766
+
1767
+ nodeData.select('circle')
1768
+ .attr('r', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)))
1769
+ .attr('fill', d => getCategoryColor(d.category));
1770
+
1771
+ nodeEnter.call(d3.drag()
1772
+ .on('start', (e, d) => { if (!e.active) graphSimulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
1773
+ .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
1774
+ .on('end', (e, d) => { if (!e.active) graphSimulation.alphaTarget(0); d.fx = null; d.fy = null; })
1775
+ );
1776
+
1777
+ const allLinks = graphLinkGroup.selectAll('line');
1778
+ const allNodes = graphNodeGroup.selectAll('g.graph-node');
1779
+
1780
+ if (graphSimulation) graphSimulation.stop();
1781
+ graphSimulation = d3.forceSimulation(nodes)
1782
+ .force('link', d3.forceLink(edges).id(d => d.id).distance(80))
1783
+ .force('charge', d3.forceManyBody().strength(-200))
1784
+ .force('center', d3.forceCenter(width / 2, height / 2))
1785
+ .force('collision', d3.forceCollide(25))
1786
+ .on('tick', () => {
1787
+ allLinks
1788
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
1789
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
1790
+ allNodes.attr('transform', d => `translate(${d.x},${d.y})`);
1791
+ });
1792
+ }
1793
+
1794
+ function onGraphNodeHover(event, d) {
1795
+ graphTooltip.style.display = 'block';
1796
+ graphTooltip.style.left = (event.clientX + 12) + 'px';
1797
+ graphTooltip.style.top = (event.clientY - 8) + 'px';
1798
+ graphTooltip.textContent = d.id;
1799
+
1800
+ const connected = new Set([d.id]);
1801
+ graphData.edges.forEach(e => {
1802
+ const sid = e.source?.id || e.source;
1803
+ const tid = e.target?.id || e.target;
1804
+ if (sid === d.id) connected.add(tid);
1805
+ if (tid === d.id) connected.add(sid);
1806
+ });
1807
+
1808
+ graphNodeGroup.selectAll('g.graph-node')
1809
+ .classed('dimmed', n => !connected.has(n.id))
1810
+ .classed('highlighted', n => n.id === d.id);
1811
+ graphLinkGroup.selectAll('line').classed('highlighted', e => {
1812
+ const sid = e.source?.id || e.source;
1813
+ const tid = e.target?.id || e.target;
1814
+ return sid === d.id || tid === d.id;
1815
+ });
1816
+ }
1817
+
1818
+ function onGraphNodeOut() {
1819
+ graphTooltip.style.display = 'none';
1820
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', false).classed('highlighted', false);
1821
+ graphLinkGroup.selectAll('line').classed('highlighted', false);
1822
+ }
1823
+
1824
+ function onGraphNodeClick(event, d) {
1825
+ event.stopPropagation();
1826
+ const detail = $('#graphDetail');
1827
+ if (!detail) return;
1828
+ detail.classList.add('visible');
1829
+ $('#graphDetailCat').textContent = d.category;
1830
+ $('#graphDetailCat').style.color = getCategoryColor(d.category);
1831
+ $('#graphDetailName').textContent = d.label;
1832
+ const pathEl = $('#graphDetailPath');
1833
+ pathEl.textContent = d.id;
1834
+ pathEl.onclick = () => openFile(d.id);
1835
+
1836
+ const imports = graphData.edges
1837
+ .filter(e => (e.source?.id || e.source) === d.id)
1838
+ .map(e => graphData.nodes.find(n => n.id === (e.target?.id || e.target)))
1839
+ .filter(Boolean);
1840
+ const importedBy = graphData.edges
1841
+ .filter(e => (e.target?.id || e.target) === d.id)
1842
+ .map(e => graphData.nodes.find(n => n.id === (e.source?.id || e.source)))
1843
+ .filter(Boolean);
1844
+
1845
+ $('#graphDetailImportsCount').textContent = imports.length;
1846
+ $('#graphDetailImports').innerHTML = imports.map(n =>
1847
+ `<div class="graph-detail-item"><span class="dot" style="background:${getCategoryColor(n.category)}"></span>${esc(n.label)}</div>`
1848
+ ).join('');
1849
+ $('#graphDetailImportedByCount').textContent = importedBy.length;
1850
+ $('#graphDetailImportedBy').innerHTML = importedBy.map(n =>
1851
+ `<div class="graph-detail-item"><span class="dot" style="background:${getCategoryColor(n.category)}"></span>${esc(n.label)}</div>`
1852
+ ).join('');
1853
+ }
1854
+
1855
+ // --- Context-aware empty state ---
1856
+ function updateEmptyState() {
1857
+ const emptyMsg = $('#emptyMsg');
1858
+ if (!emptyMsg) return;
1859
+ const hasRecent = getRecentRepos().length > 0;
1860
+ if (hasRecent) {
1861
+ emptyMsg.innerHTML = `Select an indexed repository above, or add a new one.<br>
1862
+ Drop a folder, click <strong>Browse</strong>, or type a path.<br><br>
1863
+ <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search`;
1864
+ } else {
1865
+ emptyMsg.innerHTML = `Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
1866
+ Then search symbols, explore the map, or trace callers.<br><br>
1867
+ <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search`;
1868
+ }
1869
+ }
1870
+ updateEmptyState();
1247
1871
  </script>
1248
1872
  </body>
1249
1873
  </html>