@mishasinitcyn/betterrank 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cache.js +17 -4
- package/src/cli.js +295 -8
- package/src/index.js +320 -9
- package/src/server.js +27 -0
- package/src/ui.html +430 -6
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;
|
|
@@ -569,6 +570,86 @@
|
|
|
569
570
|
text-align: right;
|
|
570
571
|
flex-shrink: 0;
|
|
571
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; }
|
|
572
653
|
</style>
|
|
573
654
|
</head>
|
|
574
655
|
<body>
|
|
@@ -597,9 +678,9 @@
|
|
|
597
678
|
</div>
|
|
598
679
|
</div>
|
|
599
680
|
|
|
600
|
-
<!--
|
|
681
|
+
<!-- Indexed repos -->
|
|
601
682
|
<div class="recent-repos" id="recentRepos" style="display:none">
|
|
602
|
-
<div class="recent-label">
|
|
683
|
+
<div class="recent-label">Indexed</div>
|
|
603
684
|
<div class="recent-list" id="recentList"></div>
|
|
604
685
|
</div>
|
|
605
686
|
|
|
@@ -625,6 +706,7 @@
|
|
|
625
706
|
<button class="tab" data-tool="dependents">Dependents</button>
|
|
626
707
|
<button class="tab" data-tool="neighborhood">Neighborhood</button>
|
|
627
708
|
<button class="tab" data-tool="structure">Structure</button>
|
|
709
|
+
<button class="tab" data-tool="graph">Graph</button>
|
|
628
710
|
</div>
|
|
629
711
|
|
|
630
712
|
<!-- Search area -->
|
|
@@ -653,9 +735,9 @@
|
|
|
653
735
|
|
|
654
736
|
<!-- Results -->
|
|
655
737
|
<div class="results" id="results">
|
|
656
|
-
<div class="empty-state">
|
|
738
|
+
<div class="empty-state" id="emptyState">
|
|
657
739
|
<div class="icon">📂</div>
|
|
658
|
-
<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>
|
|
659
741
|
Then search symbols, explore the map, or trace callers.<br><br>
|
|
660
742
|
<kbd>⌘O</kbd> browse <kbd>⌘K</kbd> focus search</p>
|
|
661
743
|
</div>
|
|
@@ -670,6 +752,9 @@
|
|
|
670
752
|
|
|
671
753
|
</div>
|
|
672
754
|
|
|
755
|
+
<!-- Graph tooltip (needs to be outside .app for fixed positioning) -->
|
|
756
|
+
<div class="graph-tooltip" id="graphTooltip"></div>
|
|
757
|
+
|
|
673
758
|
<script>
|
|
674
759
|
const $ = s => document.querySelector(s);
|
|
675
760
|
const $$ = s => document.querySelectorAll(s);
|
|
@@ -722,11 +807,13 @@ function removeRecentRepo(path) {
|
|
|
722
807
|
const recent = getRecentRepos().filter(r => r !== path);
|
|
723
808
|
localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
|
|
724
809
|
renderRecentRepos();
|
|
810
|
+
updateEmptyState();
|
|
725
811
|
}
|
|
726
812
|
|
|
727
813
|
function clearRecentRepos() {
|
|
728
814
|
localStorage.removeItem(RECENT_KEY);
|
|
729
815
|
renderRecentRepos();
|
|
816
|
+
updateEmptyState();
|
|
730
817
|
}
|
|
731
818
|
|
|
732
819
|
function renderRecentRepos() {
|
|
@@ -1013,9 +1100,15 @@ $$('.tab').forEach(tab => {
|
|
|
1013
1100
|
tab.classList.add('active');
|
|
1014
1101
|
currentTool = tab.dataset.tool;
|
|
1015
1102
|
currentPage = 0;
|
|
1103
|
+
|
|
1104
|
+
// Stop any running graph simulation when leaving graph tab
|
|
1105
|
+
if (currentTool !== 'graph' && graphSimulation) {
|
|
1106
|
+
graphSimulation.stop();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1016
1109
|
updateSearchUI();
|
|
1017
|
-
// Auto-run: map/structure run server query, others show pickers or run
|
|
1018
|
-
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)) {
|
|
1019
1112
|
runQuery();
|
|
1020
1113
|
} else {
|
|
1021
1114
|
queryInput.value = '';
|
|
@@ -1030,6 +1123,7 @@ function updateSearchUI() {
|
|
|
1030
1123
|
const needsKind = ['search', 'symbols'].includes(currentTool);
|
|
1031
1124
|
|
|
1032
1125
|
$('#searchArea').style.display = needsQuery ? 'block' : 'none';
|
|
1126
|
+
pagination.style.display = currentTool === 'graph' ? 'none' : pagination.style.display;
|
|
1033
1127
|
kindFilter.style.display = needsKind ? 'inline-block' : 'none';
|
|
1034
1128
|
|
|
1035
1129
|
const placeholders = {
|
|
@@ -1118,6 +1212,9 @@ async function runQuery() {
|
|
|
1118
1212
|
case 'structure':
|
|
1119
1213
|
url = `${API}/api/structure?depth=4`;
|
|
1120
1214
|
break;
|
|
1215
|
+
case 'graph':
|
|
1216
|
+
url = `${API}/api/graph?limit=500`;
|
|
1217
|
+
break;
|
|
1121
1218
|
}
|
|
1122
1219
|
|
|
1123
1220
|
const res = await fetch(url);
|
|
@@ -1144,6 +1241,7 @@ function render(data) {
|
|
|
1144
1241
|
case 'dependents': return renderFileList(data.results, data.total);
|
|
1145
1242
|
case 'neighborhood': return renderNeighborhood(data);
|
|
1146
1243
|
case 'structure': return renderPre(data.content);
|
|
1244
|
+
case 'graph': return renderGraph(data);
|
|
1147
1245
|
}
|
|
1148
1246
|
}
|
|
1149
1247
|
|
|
@@ -1444,6 +1542,332 @@ document.addEventListener('keydown', (e) => {
|
|
|
1444
1542
|
repoInput.onkeydown = (e) => {
|
|
1445
1543
|
if (e.key === 'Enter') btnIndex.click();
|
|
1446
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>⌘O</kbd> browse <kbd>⌘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>⌘O</kbd> browse <kbd>⌘K</kbd> focus search`;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
updateEmptyState();
|
|
1447
1871
|
</script>
|
|
1448
1872
|
</body>
|
|
1449
1873
|
</html>
|