gitnexus 1.2.7 → 1.2.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/README.md +9 -1
- package/dist/cli/analyze.d.ts +1 -1
- package/dist/cli/analyze.js +59 -15
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +8 -1
- package/dist/cli/view.d.ts +13 -0
- package/dist/cli/view.js +59 -0
- package/dist/core/embeddings/embedder.js +1 -0
- package/dist/core/graph/html-graph-viewer.d.ts +15 -0
- package/dist/core/graph/html-graph-viewer.js +542 -0
- package/dist/core/graph/html-graph-viewer.test.d.ts +1 -0
- package/dist/core/graph/html-graph-viewer.test.js +67 -0
- package/dist/core/ingestion/tree-sitter-queries.js +282 -282
- package/dist/mcp/core/embedder.js +8 -4
- package/dist/mcp/local/local-backend.d.ts +6 -0
- package/dist/mcp/local/local-backend.js +113 -6
- package/dist/mcp/tools.js +12 -3
- package/dist/server/api.d.ts +4 -2
- package/dist/server/api.js +253 -83
- package/package.json +1 -1
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Graph Viewer Generator
|
|
3
|
+
*
|
|
4
|
+
* Produces a self-contained graph.html that renders the knowledge graph
|
|
5
|
+
* using Sigma.js v2 + graphology (both from CDN).
|
|
6
|
+
*
|
|
7
|
+
* Critical: node `content` fields are stripped before embedding to prevent
|
|
8
|
+
* </script> injection from source code breaking the HTML parser.
|
|
9
|
+
*/
|
|
10
|
+
// ─── CDN URLs ───────────────────────────────────────────────────────────
|
|
11
|
+
const CDN_GRAPHOLOGY = 'https://cdn.jsdelivr.net/npm/graphology@0.25/dist/graphology.umd.min.js';
|
|
12
|
+
const CDN_SIGMA = 'https://cdn.jsdelivr.net/npm/sigma@2/build/sigma.min.js';
|
|
13
|
+
const CDN_FA2 = 'https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10/dist/graphology-layout-forceatlas2.min.js';
|
|
14
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Generate a self-contained HTML file that renders the knowledge graph.
|
|
17
|
+
* Strips large/unsafe fields from nodes before embedding.
|
|
18
|
+
*/
|
|
19
|
+
export function generateHTMLGraphViewer(nodes, relationships, projectName) {
|
|
20
|
+
// Strip content + other large string fields to:
|
|
21
|
+
// 1) Prevent </script> injection (source code breaks HTML parsing)
|
|
22
|
+
// 2) Dramatically reduce output file size
|
|
23
|
+
const liteNodes = nodes.map(n => ({
|
|
24
|
+
id: n.id,
|
|
25
|
+
label: n.label,
|
|
26
|
+
properties: {
|
|
27
|
+
name: n.properties.name,
|
|
28
|
+
filePath: n.properties.filePath,
|
|
29
|
+
startLine: n.properties.startLine,
|
|
30
|
+
endLine: n.properties.endLine,
|
|
31
|
+
heuristicLabel: n.properties.heuristicLabel,
|
|
32
|
+
cohesion: n.properties.cohesion,
|
|
33
|
+
symbolCount: n.properties.symbolCount,
|
|
34
|
+
entryPointId: n.properties.entryPointId,
|
|
35
|
+
terminalId: n.properties.terminalId,
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
const liteRelationships = relationships.map(r => ({
|
|
39
|
+
id: r.id,
|
|
40
|
+
type: r.type,
|
|
41
|
+
sourceId: r.sourceId,
|
|
42
|
+
targetId: r.targetId,
|
|
43
|
+
confidence: r.confidence,
|
|
44
|
+
step: r.step,
|
|
45
|
+
}));
|
|
46
|
+
// Escape </script> as a belt-and-suspenders safety measure
|
|
47
|
+
const graphData = JSON.stringify({ nodes: liteNodes, relationships: liteRelationships })
|
|
48
|
+
.replace(/<\/script>/gi, '<\\/script>');
|
|
49
|
+
const parts = [];
|
|
50
|
+
// ── Head ──
|
|
51
|
+
parts.push('<!DOCTYPE html>');
|
|
52
|
+
parts.push('<html lang="en">');
|
|
53
|
+
parts.push('<head>');
|
|
54
|
+
parts.push('<meta charset="UTF-8">');
|
|
55
|
+
parts.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
|
|
56
|
+
parts.push('<title>' + esc(projectName) + ' \u2014 Knowledge Graph</title>');
|
|
57
|
+
parts.push('<style>');
|
|
58
|
+
parts.push(VIEWER_CSS);
|
|
59
|
+
parts.push('</style>');
|
|
60
|
+
parts.push('</head>');
|
|
61
|
+
// ── Body ──
|
|
62
|
+
parts.push('<body>');
|
|
63
|
+
parts.push('<div id="header">');
|
|
64
|
+
parts.push('<div id="header-left">');
|
|
65
|
+
parts.push('<span id="title">' + esc(projectName) + '</span>');
|
|
66
|
+
parts.push('<span id="stats"></span>');
|
|
67
|
+
parts.push('</div>');
|
|
68
|
+
parts.push('<div id="search-wrap">');
|
|
69
|
+
parts.push('<input id="search-input" type="search" placeholder="Search nodes\u2026" autocomplete="off">');
|
|
70
|
+
parts.push('<span id="search-count"></span>');
|
|
71
|
+
parts.push('</div>');
|
|
72
|
+
parts.push('<div id="legend"></div>');
|
|
73
|
+
parts.push('</div>');
|
|
74
|
+
parts.push('<div id="main">');
|
|
75
|
+
parts.push('<div id="sigma-container"></div>');
|
|
76
|
+
parts.push('<div id="info-panel" class="hidden">');
|
|
77
|
+
parts.push('<div id="info-header"><span id="info-name"></span><button id="info-close">\u00d7</button></div>');
|
|
78
|
+
parts.push('<div id="info-body"></div>');
|
|
79
|
+
parts.push('</div>');
|
|
80
|
+
parts.push('</div>');
|
|
81
|
+
parts.push('<div id="tooltip"></div>');
|
|
82
|
+
// ── Scripts ──
|
|
83
|
+
parts.push('<script src="' + CDN_GRAPHOLOGY + '"><\/script>');
|
|
84
|
+
parts.push('<script src="' + CDN_SIGMA + '"><\/script>');
|
|
85
|
+
parts.push('<script src="' + CDN_FA2 + '"><\/script>');
|
|
86
|
+
parts.push('<script>');
|
|
87
|
+
parts.push('window.GRAPH_DATA = ' + graphData + ';');
|
|
88
|
+
parts.push('window.PROJECT_NAME = ' + JSON.stringify(projectName) + ';');
|
|
89
|
+
parts.push(VIEWER_JS);
|
|
90
|
+
parts.push('<\/script>');
|
|
91
|
+
parts.push('</body>');
|
|
92
|
+
parts.push('</html>');
|
|
93
|
+
return parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
96
|
+
function esc(text) {
|
|
97
|
+
return text
|
|
98
|
+
.replace(/&/g, '&')
|
|
99
|
+
.replace(/</g, '<')
|
|
100
|
+
.replace(/>/g, '>')
|
|
101
|
+
.replace(/"/g, '"');
|
|
102
|
+
}
|
|
103
|
+
// ─── Static Assets ──────────────────────────────────────────────────────
|
|
104
|
+
const VIEWER_CSS = `
|
|
105
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
106
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d0e17;color:#e2e8f0;display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
|
107
|
+
#header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#12131f;border-bottom:1px solid #1e2035;flex-shrink:0;gap:12px}
|
|
108
|
+
#header-left{display:flex;align-items:center;gap:12px;min-width:0}
|
|
109
|
+
#title{font-size:13px;font-weight:700;color:#a5b4fc;white-space:nowrap}
|
|
110
|
+
#stats{font-size:11px;color:#475569;white-space:nowrap}
|
|
111
|
+
#legend{display:flex;gap:8px;flex-wrap:wrap;flex:1;justify-content:flex-end}
|
|
112
|
+
.legend-item{display:flex;align-items:center;gap:4px;font-size:10px;color:#64748b;white-space:nowrap}
|
|
113
|
+
.legend-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
114
|
+
#main{flex:1;position:relative;overflow:hidden;display:flex}
|
|
115
|
+
#sigma-container{flex:1;position:relative}
|
|
116
|
+
#sigma-container canvas{display:block}
|
|
117
|
+
#info-panel{position:absolute;top:12px;right:12px;width:260px;background:#12131f;border:1px solid #1e2035;border-radius:8px;z-index:10;font-size:12px}
|
|
118
|
+
#info-panel.hidden{display:none}
|
|
119
|
+
#info-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid #1e2035}
|
|
120
|
+
#info-name{font-weight:600;color:#c4b5fd;font-size:13px;word-break:break-all}
|
|
121
|
+
#info-close{background:none;border:none;color:#475569;cursor:pointer;font-size:16px;line-height:1;padding:0 2px}
|
|
122
|
+
#info-close:hover{color:#e2e8f0}
|
|
123
|
+
#info-body{padding:12px;display:flex;flex-direction:column;gap:6px}
|
|
124
|
+
.info-row{display:flex;flex-direction:column;gap:2px}
|
|
125
|
+
.info-label{font-size:10px;color:#475569;text-transform:uppercase;letter-spacing:.4px}
|
|
126
|
+
.info-value{color:#94a3b8;word-break:break-all;font-size:11px}
|
|
127
|
+
.info-badge{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;color:#0d0e17}
|
|
128
|
+
#tooltip{position:fixed;pointer-events:none;background:#12131f;border:1px solid #1e2035;border-radius:6px;padding:6px 10px;font-size:11px;color:#94a3b8;z-index:100;display:none;max-width:260px;line-height:1.6}
|
|
129
|
+
#tooltip strong{color:#c4b5fd;font-size:12px;display:block}
|
|
130
|
+
#search-wrap{display:flex;align-items:center;gap:6px;flex:0 0 auto}
|
|
131
|
+
#search-input{background:#0d0e17;border:1px solid #1e2035;border-radius:6px;color:#e2e8f0;font-size:11px;font-family:inherit;padding:4px 8px;width:200px;outline:none;transition:border-color .15s}
|
|
132
|
+
#search-input:focus{border-color:#6366f1}
|
|
133
|
+
#search-input::-webkit-search-cancel-button{cursor:pointer}
|
|
134
|
+
#search-count{font-size:10px;color:#6366f1;white-space:nowrap;min-width:60px}
|
|
135
|
+
`;
|
|
136
|
+
// The client-side JS is kept as a plain string to avoid template literal conflicts
|
|
137
|
+
const VIEWER_JS = `
|
|
138
|
+
(function() {
|
|
139
|
+
|
|
140
|
+
// ── Color + size tables (mirrored from gitnexus-web) ──────────────────────
|
|
141
|
+
var NODE_COLORS = {
|
|
142
|
+
Project:'#a855f7', Package:'#8b5cf6', Module:'#7c3aed', Folder:'#6366f1',
|
|
143
|
+
File:'#3b82f6', Class:'#f59e0b', Function:'#10b981', Method:'#14b8a6',
|
|
144
|
+
Variable:'#64748b', Interface:'#ec4899', Enum:'#f97316', Decorator:'#eab308',
|
|
145
|
+
Import:'#475569', Type:'#a78bfa', CodeElement:'#64748b',
|
|
146
|
+
Community:'#818cf8', Process:'#f43f5e',
|
|
147
|
+
};
|
|
148
|
+
var NODE_SIZES = {
|
|
149
|
+
Project:20, Package:16, Module:13, Folder:10, File:6,
|
|
150
|
+
Class:8, Function:4, Method:3, Variable:2, Interface:7, Enum:5,
|
|
151
|
+
Decorator:2, Import:1.5, Type:3, CodeElement:2, Community:0, Process:0,
|
|
152
|
+
};
|
|
153
|
+
var EDGE_COLORS = {
|
|
154
|
+
CONTAINS:'#2d5a3d', DEFINES:'#0e7490', IMPORTS:'#1d4ed8',
|
|
155
|
+
CALLS:'#7c3aed', EXTENDS:'#c2410c', IMPLEMENTS:'#be185d',
|
|
156
|
+
};
|
|
157
|
+
var COMMUNITY_COLORS = [
|
|
158
|
+
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6',
|
|
159
|
+
'#8b5cf6','#d946ef','#ec4899','#f43f5e','#14b8a6','#84cc16',
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
// Types visible by default (hide noisy Import/Variable/Decorator/CodeElement)
|
|
163
|
+
var VISIBLE_LABELS = {
|
|
164
|
+
Project:1, Package:1, Module:1, Folder:1, File:1,
|
|
165
|
+
Class:1, Function:1, Method:1, Interface:1, Enum:1, Type:1,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function getCommunityColor(idx) {
|
|
169
|
+
return COMMUNITY_COLORS[idx % COMMUNITY_COLORS.length];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Scale node sizes for large graphs
|
|
173
|
+
function getScaledSize(base, nodeCount) {
|
|
174
|
+
if (nodeCount > 20000) return Math.max(1, base * 0.5);
|
|
175
|
+
if (nodeCount > 5000) return Math.max(1.5, base * 0.65);
|
|
176
|
+
if (nodeCount > 1000) return Math.max(2, base * 0.8);
|
|
177
|
+
return base;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Graph building ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
183
|
+
var data = window.GRAPH_DATA;
|
|
184
|
+
var container = document.getElementById('sigma-container');
|
|
185
|
+
|
|
186
|
+
// Stats header
|
|
187
|
+
var visible = data.nodes.filter(function(n) { return VISIBLE_LABELS[n.label]; });
|
|
188
|
+
document.getElementById('stats').textContent =
|
|
189
|
+
data.nodes.length + ' nodes \u00b7 ' + data.relationships.length + ' edges';
|
|
190
|
+
|
|
191
|
+
// Build legend
|
|
192
|
+
var shownTypes = {};
|
|
193
|
+
data.nodes.forEach(function(n) { if (VISIBLE_LABELS[n.label]) shownTypes[n.label] = true; });
|
|
194
|
+
var legendEl = document.getElementById('legend');
|
|
195
|
+
Object.keys(NODE_COLORS).forEach(function(label) {
|
|
196
|
+
if (!shownTypes[label]) return;
|
|
197
|
+
var item = document.createElement('div');
|
|
198
|
+
item.className = 'legend-item';
|
|
199
|
+
var dot = document.createElement('div');
|
|
200
|
+
dot.className = 'legend-dot';
|
|
201
|
+
dot.style.background = NODE_COLORS[label];
|
|
202
|
+
item.appendChild(dot);
|
|
203
|
+
item.appendChild(document.createTextNode(label));
|
|
204
|
+
legendEl.appendChild(item);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Build graphology graph ────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
var Graph = graphology.Graph || graphology;
|
|
210
|
+
var graph = new Graph({ type: 'directed', multi: false });
|
|
211
|
+
var nodeCount = data.nodes.length;
|
|
212
|
+
|
|
213
|
+
// Build parent maps from structural relationships
|
|
214
|
+
var parentOf = {}; // childId -> parentId
|
|
215
|
+
var childrenOf = {}; // parentId -> [childId]
|
|
216
|
+
var HIERARCHY = { CONTAINS:1, DEFINES:1, IMPORTS:1 };
|
|
217
|
+
data.relationships.forEach(function(r) {
|
|
218
|
+
if (HIERARCHY[r.type]) {
|
|
219
|
+
parentOf[r.targetId] = r.sourceId;
|
|
220
|
+
if (!childrenOf[r.sourceId]) childrenOf[r.sourceId] = [];
|
|
221
|
+
childrenOf[r.sourceId].push(r.targetId);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Build community membership from MEMBER_OF relationships
|
|
226
|
+
var communityOf = {}; // nodeId -> communityIndex
|
|
227
|
+
var communityNodes = {};
|
|
228
|
+
data.nodes.forEach(function(n) {
|
|
229
|
+
if (n.label === 'Community') communityNodes[n.id] = true;
|
|
230
|
+
});
|
|
231
|
+
var memberIdx = {};
|
|
232
|
+
var communityCounter = 0;
|
|
233
|
+
data.relationships.forEach(function(r) {
|
|
234
|
+
if (r.type === 'MEMBER_OF' && communityNodes[r.targetId]) {
|
|
235
|
+
if (memberIdx[r.targetId] === undefined) {
|
|
236
|
+
memberIdx[r.targetId] = communityCounter++;
|
|
237
|
+
}
|
|
238
|
+
communityOf[r.sourceId] = memberIdx[r.targetId];
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Spread multiplier
|
|
243
|
+
var spread = Math.sqrt(nodeCount) * 40;
|
|
244
|
+
var childJitter = Math.sqrt(nodeCount) * 3;
|
|
245
|
+
var clusterJitter = Math.sqrt(nodeCount) * 1.5;
|
|
246
|
+
var GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
|
247
|
+
|
|
248
|
+
// Cluster centers
|
|
249
|
+
var clusterCenters = {};
|
|
250
|
+
var uniqueCommunities = {};
|
|
251
|
+
Object.values(communityOf).forEach(function(ci) { uniqueCommunities[ci] = true; });
|
|
252
|
+
var communityList = Object.keys(uniqueCommunities).map(Number);
|
|
253
|
+
communityList.forEach(function(ci, idx) {
|
|
254
|
+
var angle = idx * GOLDEN_ANGLE;
|
|
255
|
+
var radius = spread * 0.8 * Math.sqrt((idx + 1) / Math.max(communityList.length, 1));
|
|
256
|
+
clusterCenters[ci] = { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Node positions cache
|
|
260
|
+
var posMap = {};
|
|
261
|
+
|
|
262
|
+
// Structural types positioned first, wide radial
|
|
263
|
+
var STRUCTURAL = { Project:1, Package:1, Module:1, Folder:1 };
|
|
264
|
+
var structural = data.nodes.filter(function(n) { return STRUCTURAL[n.label]; });
|
|
265
|
+
|
|
266
|
+
structural.forEach(function(node, idx) {
|
|
267
|
+
var angle = idx * GOLDEN_ANGLE;
|
|
268
|
+
var radius = spread * Math.sqrt((idx + 1) / Math.max(structural.length, 1));
|
|
269
|
+
var jitter = spread * 0.15;
|
|
270
|
+
var x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
|
|
271
|
+
var y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
|
|
272
|
+
posMap[node.id] = { x: x, y: y };
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
function addNode(node) {
|
|
276
|
+
if (graph.hasNode(node.id)) return;
|
|
277
|
+
if (!VISIBLE_LABELS[node.label]) return; // skip Community, Process, Import by default
|
|
278
|
+
|
|
279
|
+
var x, y;
|
|
280
|
+
var ci = communityOf[node.id];
|
|
281
|
+
var SYMBOLS = { Function:1, Class:1, Method:1, Interface:1 };
|
|
282
|
+
|
|
283
|
+
if (ci !== undefined && SYMBOLS[node.label] && clusterCenters[ci]) {
|
|
284
|
+
x = clusterCenters[ci].x + (Math.random() - 0.5) * clusterJitter;
|
|
285
|
+
y = clusterCenters[ci].y + (Math.random() - 0.5) * clusterJitter;
|
|
286
|
+
} else {
|
|
287
|
+
var parentPos = posMap[parentOf[node.id]];
|
|
288
|
+
if (parentPos) {
|
|
289
|
+
x = parentPos.x + (Math.random() - 0.5) * childJitter;
|
|
290
|
+
y = parentPos.y + (Math.random() - 0.5) * childJitter;
|
|
291
|
+
} else {
|
|
292
|
+
x = (Math.random() - 0.5) * spread * 0.5;
|
|
293
|
+
y = (Math.random() - 0.5) * spread * 0.5;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
posMap[node.id] = { x: x, y: y };
|
|
298
|
+
|
|
299
|
+
var baseSize = NODE_SIZES[node.label] || 4;
|
|
300
|
+
var size = getScaledSize(baseSize, nodeCount);
|
|
301
|
+
var color = (ci !== undefined) ? getCommunityColor(ci) : (NODE_COLORS[node.label] || '#94a3b8');
|
|
302
|
+
|
|
303
|
+
graph.addNode(node.id, {
|
|
304
|
+
x: x, y: y, size: size, color: color,
|
|
305
|
+
label: node.properties.name || node.id,
|
|
306
|
+
nodeType: node.label,
|
|
307
|
+
filePath: node.properties.filePath,
|
|
308
|
+
startLine: node.properties.startLine,
|
|
309
|
+
endLine: node.properties.endLine,
|
|
310
|
+
communityColor: ci !== undefined ? getCommunityColor(ci) : null,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// BFS from structural nodes (parents before children)
|
|
315
|
+
var queue = structural.map(function(n) { return n.id; });
|
|
316
|
+
var visited = {};
|
|
317
|
+
queue.forEach(function(id) { visited[id] = true; addNode(data.nodes.find(function(n) { return n.id === id; })); });
|
|
318
|
+
|
|
319
|
+
while (queue.length) {
|
|
320
|
+
var cur = queue.shift();
|
|
321
|
+
var kids = childrenOf[cur] || [];
|
|
322
|
+
kids.forEach(function(kidId) {
|
|
323
|
+
if (!visited[kidId]) {
|
|
324
|
+
visited[kidId] = true;
|
|
325
|
+
var kidNode = data.nodes.find(function(n) { return n.id === kidId; });
|
|
326
|
+
if (kidNode) { addNode(kidNode); queue.push(kidId); }
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Orphans
|
|
332
|
+
data.nodes.forEach(function(n) {
|
|
333
|
+
if (!visited[n.id]) { visited[n.id] = true; addNode(n); }
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Add edges
|
|
337
|
+
var edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 0.8;
|
|
338
|
+
data.relationships.forEach(function(r) {
|
|
339
|
+
if (!graph.hasNode(r.sourceId) || !graph.hasNode(r.targetId)) return;
|
|
340
|
+
if (graph.hasEdge(r.sourceId, r.targetId)) return;
|
|
341
|
+
var color = EDGE_COLORS[r.type] || '#2a2a3a';
|
|
342
|
+
var sizeM = (r.type === 'CALLS') ? 0.8 : (r.type === 'EXTENDS' || r.type === 'IMPLEMENTS') ? 1.0 : 0.5;
|
|
343
|
+
try {
|
|
344
|
+
graph.addEdge(r.sourceId, r.targetId, {
|
|
345
|
+
size: edgeBaseSize * sizeM,
|
|
346
|
+
color: color,
|
|
347
|
+
relationType: r.type,
|
|
348
|
+
});
|
|
349
|
+
} catch(e) {}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ── Degree-based node sizing ──────────────────────────────────────────
|
|
353
|
+
graph.forEachNode(function(node) {
|
|
354
|
+
var deg = graph.degree(node);
|
|
355
|
+
var cur = graph.getNodeAttribute(node, 'size');
|
|
356
|
+
var bonus = Math.min(deg * 0.15, cur * 1.5);
|
|
357
|
+
graph.setNodeAttribute(node, 'size', cur + bonus);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── ForceAtlas2 layout ────────────────────────────────────────────────
|
|
361
|
+
var FA2Layout = typeof graphologyLayoutForceatlas2 !== 'undefined' ? graphologyLayoutForceatlas2 : null;
|
|
362
|
+
if (FA2Layout && FA2Layout.assign) {
|
|
363
|
+
try {
|
|
364
|
+
FA2Layout.assign(graph, {
|
|
365
|
+
iterations: nodeCount > 1000 ? 100 : 200,
|
|
366
|
+
settings: {
|
|
367
|
+
gravity: 1,
|
|
368
|
+
scalingRatio: 2,
|
|
369
|
+
barnesHutOptimize: nodeCount > 500,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
} catch(e) { console.warn('FA2 layout failed:', e); }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Mount Sigma ────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
var SigmaClass = typeof Sigma !== 'undefined' ? Sigma : window.Sigma;
|
|
378
|
+
if (!SigmaClass) {
|
|
379
|
+
container.innerHTML = '<div style="color:#ef4444;padding:40px;text-align:center">Sigma.js failed to load from CDN.<br>Check your internet connection.</div>';
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var sigma = new SigmaClass(graph, container, {
|
|
384
|
+
renderLabels: true,
|
|
385
|
+
labelFont: 'monospace',
|
|
386
|
+
labelSize: 10,
|
|
387
|
+
labelWeight: '400',
|
|
388
|
+
labelColor: { color: '#94a3b8' },
|
|
389
|
+
labelRenderedSizeThreshold: 6,
|
|
390
|
+
labelDensity: 0.07,
|
|
391
|
+
labelGridCellSize: 80,
|
|
392
|
+
defaultNodeColor: '#6b7280',
|
|
393
|
+
defaultEdgeColor: '#1e2035',
|
|
394
|
+
minCameraRatio: 0.005,
|
|
395
|
+
maxCameraRatio: 50,
|
|
396
|
+
hideEdgesOnMove: true,
|
|
397
|
+
zIndex: true,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── Search index ───────────────────────────────────────────────────────
|
|
401
|
+
// Build search index from graphology graph (visible nodes only)
|
|
402
|
+
var searchIndex = {};
|
|
403
|
+
graph.forEachNode(function(nodeId, attrs) {
|
|
404
|
+
var terms = (attrs.label || '').toLowerCase();
|
|
405
|
+
if (attrs.filePath) terms += ' ' + attrs.filePath.toLowerCase();
|
|
406
|
+
searchIndex[nodeId] = terms;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ── Hover tooltip ──────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
var tooltip = document.getElementById('tooltip');
|
|
412
|
+
|
|
413
|
+
sigma.on('enterNode', function(e) {
|
|
414
|
+
var attrs = graph.getNodeAttributes(e.node);
|
|
415
|
+
var html = '<strong>' + (attrs.label || e.node).replace(/</g, '<') + '</strong>';
|
|
416
|
+
if (attrs.nodeType) html += '<span style="color:#475569">' + attrs.nodeType + '</span>';
|
|
417
|
+
if (attrs.filePath) html += '<span style="color:#64748b;font-size:10px;display:block">' + attrs.filePath + '</span>';
|
|
418
|
+
tooltip.innerHTML = html;
|
|
419
|
+
tooltip.style.display = 'block';
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
sigma.on('leaveNode', function() { tooltip.style.display = 'none'; });
|
|
423
|
+
|
|
424
|
+
container.addEventListener('mousemove', function(e) {
|
|
425
|
+
tooltip.style.left = (e.clientX + 14) + 'px';
|
|
426
|
+
tooltip.style.top = (e.clientY - 8) + 'px';
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ── Node info panel + neighbor highlighting ────────────────────────────
|
|
430
|
+
|
|
431
|
+
var panel = document.getElementById('info-panel');
|
|
432
|
+
var infoName = document.getElementById('info-name');
|
|
433
|
+
var infoBody = document.getElementById('info-body');
|
|
434
|
+
var highlightedNode = null;
|
|
435
|
+
var highlightedNeighbors = {};
|
|
436
|
+
|
|
437
|
+
function row(label, value) {
|
|
438
|
+
if (!value && value !== 0) return '';
|
|
439
|
+
return '<div class="info-row"><div class="info-label">' + label + '</div>' +
|
|
440
|
+
'<div class="info-value">' + String(value).replace(/</g,'<').replace(/>/g,'>') + '</div></div>';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function clearHighlight() {
|
|
444
|
+
highlightedNode = null;
|
|
445
|
+
highlightedNeighbors = {};
|
|
446
|
+
sigma.setSetting('nodeReducer', null);
|
|
447
|
+
sigma.setSetting('edgeReducer', null);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Search ─────────────────────────────────────────────────────────────
|
|
451
|
+
var searchActive = false;
|
|
452
|
+
var searchMatches = {};
|
|
453
|
+
|
|
454
|
+
function applySearch(query) {
|
|
455
|
+
var q = query.trim().toLowerCase();
|
|
456
|
+
if (!q) {
|
|
457
|
+
searchActive = false;
|
|
458
|
+
searchMatches = {};
|
|
459
|
+
sigma.setSetting('nodeReducer', null);
|
|
460
|
+
sigma.setSetting('edgeReducer', null);
|
|
461
|
+
document.getElementById('search-count').textContent = '';
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
searchActive = true;
|
|
465
|
+
searchMatches = {};
|
|
466
|
+
graph.forEachNode(function(nodeId) {
|
|
467
|
+
if (searchIndex[nodeId] && searchIndex[nodeId].indexOf(q) !== -1) {
|
|
468
|
+
searchMatches[nodeId] = true;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
var n = Object.keys(searchMatches).length;
|
|
472
|
+
document.getElementById('search-count').textContent =
|
|
473
|
+
n === 0 ? 'no matches' : n === 1 ? '1 match' : n + ' matches';
|
|
474
|
+
sigma.setSetting('nodeReducer', function(node, data) {
|
|
475
|
+
if (searchMatches[node]) return Object.assign({}, data, { zIndex: 1 });
|
|
476
|
+
return Object.assign({}, data, { color: '#1a1b2e', label: null, zIndex: 0 });
|
|
477
|
+
});
|
|
478
|
+
sigma.setSetting('edgeReducer', function(edge, data) {
|
|
479
|
+
if (searchMatches[graph.source(edge)] || searchMatches[graph.target(edge)]) return data;
|
|
480
|
+
return Object.assign({}, data, { color: '#151520' });
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
document.getElementById('search-input').addEventListener('input', function(e) {
|
|
485
|
+
if (e.target.value.trim()) {
|
|
486
|
+
// Clear click-highlight when search activates
|
|
487
|
+
highlightedNode = null;
|
|
488
|
+
highlightedNeighbors = {};
|
|
489
|
+
panel.classList.add('hidden');
|
|
490
|
+
}
|
|
491
|
+
applySearch(e.target.value);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
sigma.on('clickNode', function(e) {
|
|
495
|
+
// Clear search when click-highlight activates
|
|
496
|
+
if (searchActive) {
|
|
497
|
+
searchActive = false;
|
|
498
|
+
searchMatches = {};
|
|
499
|
+
document.getElementById('search-input').value = '';
|
|
500
|
+
document.getElementById('search-count').textContent = '';
|
|
501
|
+
}
|
|
502
|
+
highlightedNode = e.node;
|
|
503
|
+
highlightedNeighbors = {};
|
|
504
|
+
graph.neighbors(e.node).forEach(function(n) { highlightedNeighbors[n] = true; });
|
|
505
|
+
|
|
506
|
+
sigma.setSetting('nodeReducer', function(node, data) {
|
|
507
|
+
if (node === highlightedNode || highlightedNeighbors[node]) return data;
|
|
508
|
+
return Object.assign({}, data, { color: '#1a1b2e', label: null, zIndex: 0 });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
sigma.setSetting('edgeReducer', function(edge, data) {
|
|
512
|
+
var src = graph.source(edge);
|
|
513
|
+
var tgt = graph.target(edge);
|
|
514
|
+
if (src === highlightedNode || tgt === highlightedNode) return data;
|
|
515
|
+
return Object.assign({}, data, { color: '#151520' });
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
var attrs = graph.getNodeAttributes(e.node);
|
|
519
|
+
infoName.innerHTML = '<span class="info-badge" style="background:' + (attrs.color||'#6366f1') + '">' +
|
|
520
|
+
(attrs.nodeType || '') + '</span> ' +
|
|
521
|
+
(attrs.label || e.node).replace(/</g,'<');
|
|
522
|
+
var html = '';
|
|
523
|
+
html += row('File', attrs.filePath);
|
|
524
|
+
if (attrs.startLine) html += row('Lines', attrs.startLine + (attrs.endLine ? '\u2013' + attrs.endLine : ''));
|
|
525
|
+
html += row('Connections', graph.degree(e.node));
|
|
526
|
+
panel.classList.remove('hidden');
|
|
527
|
+
infoBody.innerHTML = html;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
sigma.on('clickStage', function() {
|
|
531
|
+
clearHighlight();
|
|
532
|
+
panel.classList.add('hidden');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
document.getElementById('info-close').addEventListener('click', function() {
|
|
536
|
+
clearHighlight();
|
|
537
|
+
panel.classList.add('hidden');
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
})();
|
|
542
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateHTMLGraphViewer } from './html-graph-viewer.js';
|
|
3
|
+
const nodes = [
|
|
4
|
+
{ id: 'fn_a', label: 'Function', properties: { name: 'doSomething', filePath: 'src/a.ts' } },
|
|
5
|
+
{ id: 'fn_b', label: 'Function', properties: { name: 'doOther', filePath: 'src/b.ts' } },
|
|
6
|
+
];
|
|
7
|
+
const relationships = [
|
|
8
|
+
{ id: 'fn_a_CALLS_fn_b', type: 'CALLS', sourceId: 'fn_a', targetId: 'fn_b' }
|
|
9
|
+
];
|
|
10
|
+
describe('generateHTMLGraphViewer', () => {
|
|
11
|
+
it('returns a non-empty HTML string', () => {
|
|
12
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
13
|
+
expect(typeof html).toBe('string');
|
|
14
|
+
expect(html.length).toBeGreaterThan(100);
|
|
15
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
16
|
+
});
|
|
17
|
+
it('embeds all node ids in GRAPH_DATA', () => {
|
|
18
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
19
|
+
expect(html).toContain('fn_a');
|
|
20
|
+
expect(html).toContain('fn_b');
|
|
21
|
+
});
|
|
22
|
+
it('embeds relationship data', () => {
|
|
23
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
24
|
+
expect(html).toContain('CALLS');
|
|
25
|
+
});
|
|
26
|
+
it('includes the project name in the title', () => {
|
|
27
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'MyRepo');
|
|
28
|
+
expect(html).toContain('MyRepo');
|
|
29
|
+
});
|
|
30
|
+
describe('search feature', () => {
|
|
31
|
+
it('includes search input, wrap, and count elements', () => {
|
|
32
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
33
|
+
expect(html).toContain('id="search-input"');
|
|
34
|
+
expect(html).toContain('id="search-wrap"');
|
|
35
|
+
expect(html).toContain('id="search-count"');
|
|
36
|
+
});
|
|
37
|
+
it('places search-wrap between header-left and legend', () => {
|
|
38
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
39
|
+
const wrapPos = html.indexOf('id="search-wrap"');
|
|
40
|
+
const legendPos = html.indexOf('id="legend"');
|
|
41
|
+
const headerLeftPos = html.indexOf('id="header-left"');
|
|
42
|
+
expect(wrapPos).toBeGreaterThan(headerLeftPos);
|
|
43
|
+
expect(legendPos).toBeGreaterThan(wrapPos);
|
|
44
|
+
});
|
|
45
|
+
it('uses type="search" for native clear button', () => {
|
|
46
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
47
|
+
expect(html).toContain('type="search"');
|
|
48
|
+
});
|
|
49
|
+
it('includes applySearch function and search state variables', () => {
|
|
50
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
51
|
+
expect(html).toContain('applySearch');
|
|
52
|
+
expect(html).toContain('searchMatches');
|
|
53
|
+
expect(html).toContain('searchIndex');
|
|
54
|
+
expect(html).toContain('searchActive');
|
|
55
|
+
});
|
|
56
|
+
it('wires input event listener to search-input', () => {
|
|
57
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
58
|
+
expect(html).toContain("getElementById('search-input')");
|
|
59
|
+
expect(html).toContain("addEventListener('input'");
|
|
60
|
+
});
|
|
61
|
+
it('clickNode handler clears search state', () => {
|
|
62
|
+
const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
|
|
63
|
+
// The searchActive guard must appear in the clickNode context
|
|
64
|
+
expect(html).toContain('searchActive');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|