codebread 1.0.0__py3-none-any.whl
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.
- codebread/__init__.py +3 -0
- codebread/analyzer.py +84 -0
- codebread/classifier.py +102 -0
- codebread/cli.py +106 -0
- codebread/connections.py +407 -0
- codebread/diff.py +123 -0
- codebread/export.py +45 -0
- codebread/languages.py +110 -0
- codebread/models.py +119 -0
- codebread/parsers/__init__.py +53 -0
- codebread/parsers/generic_parser.py +288 -0
- codebread/parsers/javascript_parser.py +371 -0
- codebread/parsers/python_parser.py +291 -0
- codebread/scanner.py +206 -0
- codebread/server.py +86 -0
- codebread/web/app.js +1716 -0
- codebread/web/index.html +188 -0
- codebread/web/style.css +601 -0
- codebread-1.0.0.dist-info/METADATA +254 -0
- codebread-1.0.0.dist-info/RECORD +24 -0
- codebread-1.0.0.dist-info/WHEEL +5 -0
- codebread-1.0.0.dist-info/entry_points.txt +2 -0
- codebread-1.0.0.dist-info/licenses/LICENSE +21 -0
- codebread-1.0.0.dist-info/top_level.txt +1 -0
codebread/web/app.js
ADDED
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
/* CodeBread interactive graph UI — vanilla JS + SVG, no dependencies. */
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const LAYER_COLOR = {
|
|
6
|
+
frontend: "#0284c7", backend: "#0d9488", database: "#8b5cf6",
|
|
7
|
+
config: "#d97706", unknown: "#6678d0",
|
|
8
|
+
};
|
|
9
|
+
const LAYER_LABEL = {
|
|
10
|
+
frontend: "Frontend", backend: "Backend", database: "Database",
|
|
11
|
+
config: "Config", unknown: "Unclassified",
|
|
12
|
+
};
|
|
13
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
14
|
+
|
|
15
|
+
/* ---------------- state ---------------- */
|
|
16
|
+
const S = {
|
|
17
|
+
data: null,
|
|
18
|
+
nodesById: new Map(),
|
|
19
|
+
outEdges: new Map(), // id -> [edge]
|
|
20
|
+
inEdges: new Map(), // id -> [edge]
|
|
21
|
+
fileFns: new Map(), // file path -> [fn node ids]
|
|
22
|
+
visible: new Set(), // node ids currently on canvas
|
|
23
|
+
expanded: new Set(), // file ids whose functions are shown
|
|
24
|
+
pos: new Map(), // id -> {x,y,vx,vy,fx,fy}
|
|
25
|
+
selected: null,
|
|
26
|
+
litNodes: new Set(),
|
|
27
|
+
litEdges: new Set(),
|
|
28
|
+
filter: "all",
|
|
29
|
+
mode: "orbit", // "orbit" (files float, functions ring around them) |
|
|
30
|
+
// "free" (plain force layout)
|
|
31
|
+
view: "chart", // "chart" (graph) | "ide" (full code editor)
|
|
32
|
+
ideFile: null, // file currently open in IDE mode
|
|
33
|
+
tf: { x: 0, y: 0, k: 1 },
|
|
34
|
+
alpha: 0,
|
|
35
|
+
elNodes: new Map(), // id -> <g>
|
|
36
|
+
elEdges: new Map(), // edge key -> {vis, hit}
|
|
37
|
+
drawnEdges: [], // [{edge, key}] currently in DOM
|
|
38
|
+
drag: null,
|
|
39
|
+
searchSel: -1,
|
|
40
|
+
crumbs: [], // navigation history (node ids)
|
|
41
|
+
crumbIdx: -1,
|
|
42
|
+
navList: [], // neighbor cycle list for ArrowLeft/ArrowRight
|
|
43
|
+
navIdx: -1,
|
|
44
|
+
mmT: null, // minimap world->minimap transform
|
|
45
|
+
mmDots: new Map(),
|
|
46
|
+
mmViewportEl: null,
|
|
47
|
+
focusMode: false, // when on, picking a file in the Explorer
|
|
48
|
+
// spotlights just that file instead of adding to the view
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const $ = (id) => document.getElementById(id);
|
|
52
|
+
const svg = $("graph"), viewport = $("viewport");
|
|
53
|
+
const edgesG = $("edges-g"), nodesG = $("nodes-g");
|
|
54
|
+
|
|
55
|
+
/* ---------------- boot ---------------- */
|
|
56
|
+
function boot(data) {
|
|
57
|
+
S.data = data;
|
|
58
|
+
indexData();
|
|
59
|
+
renderHeader();
|
|
60
|
+
renderLegend();
|
|
61
|
+
renderTree();
|
|
62
|
+
initialVisible();
|
|
63
|
+
layoutInitial();
|
|
64
|
+
rebuild();
|
|
65
|
+
runTicks(260);
|
|
66
|
+
fitView(0.82);
|
|
67
|
+
startLoop();
|
|
68
|
+
bindUI();
|
|
69
|
+
bindMinimap();
|
|
70
|
+
maybeShowOnboarding();
|
|
71
|
+
setTimeout(() => $("hint").classList.add("faded"), 6000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (window.CODEBREAD_DATA) {
|
|
75
|
+
boot(window.CODEBREAD_DATA);
|
|
76
|
+
} else {
|
|
77
|
+
fetch("data.json").then(r => r.json()).then(boot)
|
|
78
|
+
.catch(err => {
|
|
79
|
+
document.body.innerHTML =
|
|
80
|
+
"<p style='padding:40px;font-family:monospace'>Failed to load " +
|
|
81
|
+
"data.json — " + err + "</p>";
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ---------------- indexing ---------------- */
|
|
86
|
+
function indexData() {
|
|
87
|
+
for (const n of S.data.nodes) {
|
|
88
|
+
S.nodesById.set(n.id, n);
|
|
89
|
+
if ((n.kind === "function" || n.kind === "method") && n.file) {
|
|
90
|
+
if (!S.fileFns.has(n.file)) S.fileFns.set(n.file, []);
|
|
91
|
+
S.fileFns.get(n.file).push(n.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const e of S.data.edges) {
|
|
95
|
+
if (!S.nodesById.has(e.source) || !S.nodesById.has(e.target)) continue;
|
|
96
|
+
if (!S.outEdges.has(e.source)) S.outEdges.set(e.source, []);
|
|
97
|
+
if (!S.inEdges.has(e.target)) S.inEdges.set(e.target, []);
|
|
98
|
+
S.outEdges.get(e.source).push(e);
|
|
99
|
+
S.inEdges.get(e.target).push(e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const edgeKey = (e) => e.source + "→" + e.target + "·" + e.kind;
|
|
103
|
+
|
|
104
|
+
function nodeLayer(n) {
|
|
105
|
+
return n.kind === "table" ? "database" : (n.layer || "unknown");
|
|
106
|
+
}
|
|
107
|
+
function passesFilter(n) {
|
|
108
|
+
return S.filter === "all" || nodeLayer(n) === S.filter;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ---------------- header / legend / warnings ---------------- */
|
|
112
|
+
function renderHeader() {
|
|
113
|
+
$("project-name").textContent = "· " + (S.data.meta?.name || "");
|
|
114
|
+
document.title = "CodeBread — " + (S.data.meta?.name || "codebase map");
|
|
115
|
+
const st = S.data.stats || {};
|
|
116
|
+
const stat = (label, v) => `<span class="stat"><b>${v ?? 0}</b> ${label}</span>`;
|
|
117
|
+
let html = stat("files", st.files) + stat("functions", st.functions) +
|
|
118
|
+
stat("tables", st.tables) + stat("connections", st.connections);
|
|
119
|
+
$("stats").innerHTML = html;
|
|
120
|
+
if (st.warnings) {
|
|
121
|
+
const b = document.createElement("button");
|
|
122
|
+
b.className = "stat warn-btn";
|
|
123
|
+
b.innerHTML = `⚠ <b>${st.warnings}</b> warnings`;
|
|
124
|
+
b.onclick = toggleWarnings;
|
|
125
|
+
$("stats").appendChild(b);
|
|
126
|
+
}
|
|
127
|
+
if (st.orphans || st.cycles) {
|
|
128
|
+
const bits = [];
|
|
129
|
+
if (st.orphans) bits.push(`${st.orphans} orphan${st.orphans === 1 ? "" : "s"}`);
|
|
130
|
+
if (st.cycles) bits.push(`${st.cycles} cycle${st.cycles === 1 ? "" : "s"}`);
|
|
131
|
+
const b = document.createElement("button");
|
|
132
|
+
b.className = "stat warn-btn insight-btn";
|
|
133
|
+
b.innerHTML = `◎ <b>${bits.join(" · ")}</b>`;
|
|
134
|
+
b.onclick = toggleInsights;
|
|
135
|
+
$("stats").appendChild(b);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toggleInsights() {
|
|
140
|
+
const p = $("insights-panel");
|
|
141
|
+
if (!p.classList.contains("hidden")) { p.classList.add("hidden"); return; }
|
|
142
|
+
const st = S.data.stats || {};
|
|
143
|
+
$("ip-orphan-count").textContent = `(${st.orphans || 0})`;
|
|
144
|
+
$("ip-cycle-count").textContent = `(${st.cycles || 0})`;
|
|
145
|
+
|
|
146
|
+
const orphanList = $("ip-orphans-list");
|
|
147
|
+
orphanList.innerHTML = "";
|
|
148
|
+
const orphans = S.data.nodes.filter(n => n.orphan);
|
|
149
|
+
if (!orphans.length) orphanList.innerHTML = "<div class='wp-item'>None detected.</div>";
|
|
150
|
+
for (const n of orphans.slice(0, 300)) {
|
|
151
|
+
const d = document.createElement("div");
|
|
152
|
+
d.className = "wp-item ip-clickable";
|
|
153
|
+
d.innerHTML = `<div class="wp-path">${esc(n.file || "")}</div>` +
|
|
154
|
+
`<div class="wp-msg">${esc(n.label)}() — no detected callers</div>`;
|
|
155
|
+
d.onclick = () => { p.classList.add("hidden"); jumpTo(n.id); };
|
|
156
|
+
orphanList.appendChild(d);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cycleList = $("ip-cycles-list");
|
|
160
|
+
cycleList.innerHTML = "";
|
|
161
|
+
const cycles = S.data.cycles || [];
|
|
162
|
+
if (!cycles.length) cycleList.innerHTML = "<div class='wp-item'>None detected.</div>";
|
|
163
|
+
for (const c of cycles.slice(0, 100)) {
|
|
164
|
+
const d = document.createElement("div");
|
|
165
|
+
d.className = "wp-item ip-clickable";
|
|
166
|
+
const chain = [...c.labels, c.labels[0]].map(esc).join(" → ");
|
|
167
|
+
d.innerHTML = `<div class="wp-msg">${chain}</div>`;
|
|
168
|
+
d.onclick = () => { p.classList.add("hidden"); jumpTo(c.nodes[0]); };
|
|
169
|
+
cycleList.appendChild(d);
|
|
170
|
+
}
|
|
171
|
+
p.classList.remove("hidden");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toggleWarnings() {
|
|
175
|
+
const p = $("warnings-panel");
|
|
176
|
+
if (!p.classList.contains("hidden")) { p.classList.add("hidden"); return; }
|
|
177
|
+
const list = $("warnings-list");
|
|
178
|
+
list.innerHTML = "";
|
|
179
|
+
const all = [...(S.data.warnings || [])];
|
|
180
|
+
for (const n of S.data.nodes) {
|
|
181
|
+
if (n.kind === "file" && n.warnings)
|
|
182
|
+
for (const w of n.warnings) all.push({ path: n.file, message: w });
|
|
183
|
+
}
|
|
184
|
+
if (!all.length) list.innerHTML = "<div class='wp-item'>No warnings.</div>";
|
|
185
|
+
for (const w of all.slice(0, 400)) {
|
|
186
|
+
const d = document.createElement("div");
|
|
187
|
+
d.className = "wp-item";
|
|
188
|
+
d.innerHTML = `<div class="wp-path">${esc(w.path)}</div>` +
|
|
189
|
+
`<div class="wp-msg">${esc(w.message)}</div>`;
|
|
190
|
+
list.appendChild(d);
|
|
191
|
+
}
|
|
192
|
+
p.classList.remove("hidden");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderLegend() {
|
|
196
|
+
const rows = [
|
|
197
|
+
["frontend", "Frontend"], ["backend", "Backend"],
|
|
198
|
+
["database", "Database / tables"], ["config", "Config"],
|
|
199
|
+
["unknown", "Unclassified"],
|
|
200
|
+
].map(([k, label]) =>
|
|
201
|
+
`<div class="lg-row"><span class="lg-dot" style="background:${LAYER_COLOR[k]}"></span>${label}</div>`);
|
|
202
|
+
rows.push(`<div class="lg-row" style="margin-top:3px;color:var(--ink-3)">─ call · api · db · include · page</div>`);
|
|
203
|
+
$("legend").innerHTML = rows.join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ---------------- sidebar tree ---------------- */
|
|
207
|
+
function renderTree() {
|
|
208
|
+
const root = S.data.tree;
|
|
209
|
+
const el = buildTreeNode(root, 0);
|
|
210
|
+
el.classList.add("open");
|
|
211
|
+
$("tree").innerHTML = "";
|
|
212
|
+
$("tree").appendChild(el);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildTreeNode(node, depth) {
|
|
216
|
+
const isDir = node.type === "dir";
|
|
217
|
+
const wrap = document.createElement("div");
|
|
218
|
+
wrap.className = "tnode " + (isDir ? "dir" : "file");
|
|
219
|
+
const row = document.createElement("div");
|
|
220
|
+
row.className = "trow";
|
|
221
|
+
row.dataset.path = node.path || "";
|
|
222
|
+
|
|
223
|
+
const caret = document.createElement("span");
|
|
224
|
+
caret.className = "tcaret";
|
|
225
|
+
caret.textContent = isDir ? "▶" : "";
|
|
226
|
+
row.appendChild(caret);
|
|
227
|
+
|
|
228
|
+
const dot = document.createElement("span");
|
|
229
|
+
dot.className = "tdot";
|
|
230
|
+
dot.style.background = LAYER_COLOR[node.layer] || "rgba(103,232,249,0.18)";
|
|
231
|
+
row.appendChild(dot);
|
|
232
|
+
|
|
233
|
+
const name = document.createElement("span");
|
|
234
|
+
name.className = "tname";
|
|
235
|
+
name.textContent = node.name;
|
|
236
|
+
row.appendChild(name);
|
|
237
|
+
|
|
238
|
+
if (node.warning) {
|
|
239
|
+
const w = document.createElement("span");
|
|
240
|
+
w.className = "twarn";
|
|
241
|
+
w.title = "This entry has a warning — see ⚠ in the header.";
|
|
242
|
+
w.textContent = "⚠";
|
|
243
|
+
row.appendChild(w);
|
|
244
|
+
}
|
|
245
|
+
if (!isDir && node.nFunctions) {
|
|
246
|
+
const c = document.createElement("span");
|
|
247
|
+
c.className = "tcount";
|
|
248
|
+
c.textContent = node.nFunctions;
|
|
249
|
+
row.appendChild(c);
|
|
250
|
+
}
|
|
251
|
+
wrap.appendChild(row);
|
|
252
|
+
|
|
253
|
+
if (isDir) {
|
|
254
|
+
const kids = document.createElement("div");
|
|
255
|
+
kids.className = "tchildren";
|
|
256
|
+
for (const ch of node.children || []) kids.appendChild(buildTreeNode(ch, depth + 1));
|
|
257
|
+
wrap.appendChild(kids);
|
|
258
|
+
row.onclick = () => wrap.classList.toggle("open");
|
|
259
|
+
if (depth < 1) wrap.classList.add("open");
|
|
260
|
+
} else {
|
|
261
|
+
row.onclick = () => {
|
|
262
|
+
document.querySelectorAll(".trow.selected").forEach(x => x.classList.remove("selected"));
|
|
263
|
+
row.classList.add("selected");
|
|
264
|
+
if (!S.nodesById.has(node.path)) return;
|
|
265
|
+
if (S.view === "ide" && S.nodesById.get(node.path)?.source) {
|
|
266
|
+
openIde(node.path); // IDE mode: open the file like an editor
|
|
267
|
+
} else if (S.focusMode) {
|
|
268
|
+
spotlightFile(node.path); // focus mode: show just this file
|
|
269
|
+
} else {
|
|
270
|
+
jumpTo(node.path); // overview mode: add to what's shown
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return wrap;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ---------------- initial visibility & layout ---------------- */
|
|
278
|
+
function initialVisible() {
|
|
279
|
+
const files = S.data.nodes.filter(n => n.kind === "file");
|
|
280
|
+
const tables = S.data.nodes.filter(n => n.kind === "table");
|
|
281
|
+
// dense codebases: start with files that actually contain code
|
|
282
|
+
const interesting = files.filter(f =>
|
|
283
|
+
(f.nFunctions || 0) > 0 || S.outEdges.has(f.id) || S.inEdges.has(f.id) ||
|
|
284
|
+
(f.dbConfig || []).length);
|
|
285
|
+
const show = (interesting.length ? interesting : files).slice(0, 400);
|
|
286
|
+
for (const f of show) S.visible.add(f.id);
|
|
287
|
+
for (const t of tables) S.visible.add(t.id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const BAND = { frontend: 0.16, unknown: 0.38, backend: 0.55, config: 0.5, database: 0.84 };
|
|
291
|
+
|
|
292
|
+
function layoutInitial() {
|
|
293
|
+
const W = 1600, H = 1100;
|
|
294
|
+
let i = 0;
|
|
295
|
+
for (const id of S.visible) {
|
|
296
|
+
const n = S.nodesById.get(id);
|
|
297
|
+
const bx = BAND[nodeLayer(n)] ?? 0.5;
|
|
298
|
+
const by = nodeLayer(n) === "config" ? 0.88 : 0.18 + 0.64 * hash01(id);
|
|
299
|
+
S.pos.set(id, {
|
|
300
|
+
x: bx * W + (hash01(id + "x") - 0.5) * 260,
|
|
301
|
+
y: by * H + (hash01(id + "y") - 0.5) * 120,
|
|
302
|
+
vx: 0, vy: 0,
|
|
303
|
+
});
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function hash01(s) {
|
|
309
|
+
let h = 2166136261;
|
|
310
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
311
|
+
return ((h >>> 0) % 10000) / 10000;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ensurePos(id, nearId) {
|
|
315
|
+
if (S.pos.has(id)) return;
|
|
316
|
+
const near = nearId && S.pos.get(nearId);
|
|
317
|
+
const n = S.nodesById.get(id);
|
|
318
|
+
const bx = BAND[nodeLayer(n)] ?? 0.5;
|
|
319
|
+
const base = near || { x: bx * 1600, y: 550 };
|
|
320
|
+
const a = hash01(id) * Math.PI * 2;
|
|
321
|
+
S.pos.set(id, {
|
|
322
|
+
x: base.x + Math.cos(a) * (60 + hash01(id + "r") * 70),
|
|
323
|
+
y: base.y + Math.sin(a) * (60 + hash01(id + "r") * 70),
|
|
324
|
+
vx: 0, vy: 0,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ---------------- visible edge computation ---------------- */
|
|
329
|
+
function visibleEdges() {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const e of S.data.edges) {
|
|
332
|
+
if (S.visible.has(e.source) && S.visible.has(e.target)) {
|
|
333
|
+
const sn = S.nodesById.get(e.source), tn = S.nodesById.get(e.target);
|
|
334
|
+
if (passesFilter(sn) && passesFilter(tn)) out.push(e);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// synthetic containment edges: file -> its expanded functions
|
|
338
|
+
for (const fid of S.expanded) {
|
|
339
|
+
if (!S.visible.has(fid)) continue;
|
|
340
|
+
const fnode = S.nodesById.get(fid);
|
|
341
|
+
if (!passesFilter(fnode)) continue;
|
|
342
|
+
for (const fnId of S.fileFns.get(fid) || []) {
|
|
343
|
+
if (S.visible.has(fnId) && passesFilter(S.nodesById.get(fnId)))
|
|
344
|
+
out.push({ source: fid, target: fnId, kind: "contains", label: "" });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ---------------- DOM (re)build ---------------- */
|
|
351
|
+
function rebuild() {
|
|
352
|
+
nodesG.innerHTML = ""; edgesG.innerHTML = "";
|
|
353
|
+
S.elNodes.clear(); S.elEdges.clear();
|
|
354
|
+
S.drawnEdges = [];
|
|
355
|
+
|
|
356
|
+
for (const e of visibleEdges()) {
|
|
357
|
+
const key = edgeKey(e);
|
|
358
|
+
const vis = document.createElementNS(SVGNS, "path");
|
|
359
|
+
vis.setAttribute("class", "edge k-" + e.kind + (e.cycle ? " cycle" : ""));
|
|
360
|
+
const hit = document.createElementNS(SVGNS, "path");
|
|
361
|
+
hit.setAttribute("class", "edge-hit");
|
|
362
|
+
hit.addEventListener("mousemove", (ev) => showEdgeTooltip(e, ev));
|
|
363
|
+
hit.addEventListener("mouseleave", hideTooltip);
|
|
364
|
+
hit.addEventListener("contextmenu", (ev) => {
|
|
365
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
366
|
+
onEdgeContextMenu(e, ev);
|
|
367
|
+
});
|
|
368
|
+
edgesG.appendChild(vis); edgesG.appendChild(hit);
|
|
369
|
+
S.elEdges.set(key, { vis, hit });
|
|
370
|
+
S.drawnEdges.push({ edge: e, key });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const id of S.visible) {
|
|
374
|
+
const n = S.nodesById.get(id);
|
|
375
|
+
if (!n || !passesFilter(n)) continue;
|
|
376
|
+
ensurePos(id);
|
|
377
|
+
const g = makeNodeEl(n);
|
|
378
|
+
nodesG.appendChild(g);
|
|
379
|
+
S.elNodes.set(id, g);
|
|
380
|
+
}
|
|
381
|
+
applyHighlight();
|
|
382
|
+
updatePositions();
|
|
383
|
+
renderMinimap();
|
|
384
|
+
updateEmptyState();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function updateEmptyState() {
|
|
388
|
+
const empty = $("empty-state");
|
|
389
|
+
if (S.elNodes.size > 0) { empty.classList.add("hidden"); return; }
|
|
390
|
+
const totalFiles = S.data.nodes.filter(n => n.kind === "file").length;
|
|
391
|
+
if (totalFiles === 0) {
|
|
392
|
+
$("empty-title").textContent = "Nothing to show";
|
|
393
|
+
$("empty-msg").textContent = "CodeBread didn't find any scannable files in this folder.";
|
|
394
|
+
} else if (S.visible.size === 0) {
|
|
395
|
+
$("empty-title").textContent = "Pick a file to begin";
|
|
396
|
+
$("empty-msg").textContent = "Select a file in the Explorer on the left to see its " +
|
|
397
|
+
"functions and how it connects to the rest of the codebase.";
|
|
398
|
+
} else {
|
|
399
|
+
$("empty-title").textContent = "Nothing to show";
|
|
400
|
+
$("empty-msg").textContent = "No nodes match the current layer filter — try “All”.";
|
|
401
|
+
}
|
|
402
|
+
empty.classList.remove("hidden");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function nodeSize(n) {
|
|
406
|
+
const label = n.label || "?";
|
|
407
|
+
if (n.kind === "file") {
|
|
408
|
+
return { w: Math.max(74, label.length * 7.4 + 34), h: 30, r: 9 };
|
|
409
|
+
}
|
|
410
|
+
if (n.kind === "table") {
|
|
411
|
+
return { w: Math.max(66, label.length * 7.2 + 40), h: 30, r: 15 };
|
|
412
|
+
}
|
|
413
|
+
return { w: Math.max(56, label.length * 6.6 + 26), h: 22, r: 11 };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function makeNodeEl(n) {
|
|
417
|
+
const g = document.createElementNS(SVGNS, "g");
|
|
418
|
+
g.setAttribute("class", "node k-" + n.kind);
|
|
419
|
+
g.dataset.id = n.id;
|
|
420
|
+
const { w, h, r } = nodeSize(n);
|
|
421
|
+
const color = LAYER_COLOR[nodeLayer(n)];
|
|
422
|
+
|
|
423
|
+
const rect = document.createElementNS(SVGNS, "rect");
|
|
424
|
+
rect.setAttribute("class", "shape");
|
|
425
|
+
rect.setAttribute("x", -w / 2); rect.setAttribute("y", -h / 2);
|
|
426
|
+
rect.setAttribute("width", w); rect.setAttribute("height", h);
|
|
427
|
+
rect.setAttribute("rx", r);
|
|
428
|
+
rect.setAttribute("stroke", color);
|
|
429
|
+
if (n.kind === "table") rect.setAttribute("fill", "rgba(139,92,246,0.14)");
|
|
430
|
+
g.appendChild(rect);
|
|
431
|
+
|
|
432
|
+
const dot = document.createElementNS(SVGNS, "circle");
|
|
433
|
+
dot.setAttribute("class", "ldot");
|
|
434
|
+
dot.setAttribute("cx", -w / 2 + 12); dot.setAttribute("cy", 0);
|
|
435
|
+
dot.setAttribute("r", n.kind === "file" ? 4 : 3);
|
|
436
|
+
dot.setAttribute("fill", color);
|
|
437
|
+
g.appendChild(dot);
|
|
438
|
+
|
|
439
|
+
const label = document.createElementNS(SVGNS, "text");
|
|
440
|
+
label.setAttribute("x", -w / 2 + 21);
|
|
441
|
+
label.setAttribute("font-size", n.kind === "file" ? "11.5" : "10.5");
|
|
442
|
+
label.textContent = n.kind === "table" ? "🗃 " + n.label : n.label;
|
|
443
|
+
g.appendChild(label);
|
|
444
|
+
|
|
445
|
+
if (n.kind === "file" && n.nFunctions) {
|
|
446
|
+
const c = document.createElementNS(SVGNS, "text");
|
|
447
|
+
c.setAttribute("class", "badge");
|
|
448
|
+
c.setAttribute("x", w / 2 - 8); c.setAttribute("text-anchor", "end");
|
|
449
|
+
c.setAttribute("y", 0.5);
|
|
450
|
+
c.textContent = n.nFunctions;
|
|
451
|
+
g.appendChild(c);
|
|
452
|
+
}
|
|
453
|
+
if (n.routes && n.routes.length) {
|
|
454
|
+
const rb = document.createElementNS(SVGNS, "text");
|
|
455
|
+
rb.setAttribute("class", "routeb");
|
|
456
|
+
rb.setAttribute("x", -w / 2 + 2); rb.setAttribute("y", -h / 2 - 6);
|
|
457
|
+
rb.textContent = n.routes[0].method + " " + n.routes[0].path;
|
|
458
|
+
g.appendChild(rb);
|
|
459
|
+
}
|
|
460
|
+
if (n.kind === "file" && n.warnings && n.warnings.length) {
|
|
461
|
+
const wb = document.createElementNS(SVGNS, "text");
|
|
462
|
+
wb.setAttribute("class", "warnb");
|
|
463
|
+
wb.setAttribute("x", w / 2 - 2); wb.setAttribute("y", -h / 2 - 5);
|
|
464
|
+
wb.setAttribute("text-anchor", "end");
|
|
465
|
+
wb.textContent = "⚠";
|
|
466
|
+
g.appendChild(wb);
|
|
467
|
+
}
|
|
468
|
+
if ((n.kind === "function" || n.kind === "method") && n.cycle) {
|
|
469
|
+
const cb = document.createElementNS(SVGNS, "text");
|
|
470
|
+
cb.setAttribute("class", "cycleb");
|
|
471
|
+
cb.setAttribute("x", w / 2 - 2); cb.setAttribute("y", -h / 2 - 5);
|
|
472
|
+
cb.setAttribute("text-anchor", "end");
|
|
473
|
+
cb.textContent = "↻";
|
|
474
|
+
g.appendChild(cb);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
g.addEventListener("mousedown", (ev) => startDragNode(ev, n.id));
|
|
478
|
+
g.addEventListener("click", (ev) => { ev.stopPropagation(); onNodeClick(n.id); });
|
|
479
|
+
g.addEventListener("dblclick", (ev) => { ev.stopPropagation(); collapseFile(n.id); });
|
|
480
|
+
g.addEventListener("mousemove", (ev) => showNodeTooltip(n, ev));
|
|
481
|
+
g.addEventListener("mouseleave", hideTooltip);
|
|
482
|
+
g.addEventListener("contextmenu", (ev) => {
|
|
483
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
484
|
+
onNodeContextMenu(n, ev);
|
|
485
|
+
});
|
|
486
|
+
return g;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* ---------------- interaction: reveal / select ---------------- */
|
|
490
|
+
function onNodeClick(id) {
|
|
491
|
+
if (S.drag && S.drag.moved) return;
|
|
492
|
+
const n = S.nodesById.get(id);
|
|
493
|
+
if (n.kind === "file") expandFile(id);
|
|
494
|
+
else revealNeighbors(id);
|
|
495
|
+
select(id);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function expandFile(fileId) {
|
|
499
|
+
const fns = S.fileFns.get(fileId) || [];
|
|
500
|
+
const wasExpanded = S.expanded.has(fileId);
|
|
501
|
+
if (!wasExpanded && fns.length) {
|
|
502
|
+
S.expanded.add(fileId);
|
|
503
|
+
for (const fnId of fns) { S.visible.add(fnId); ensurePos(fnId, fileId); }
|
|
504
|
+
}
|
|
505
|
+
revealNeighbors(fileId);
|
|
506
|
+
rebuild(); reheat();
|
|
507
|
+
if (S.mode === "orbit" && !wasExpanded && fns.length) {
|
|
508
|
+
// orbit rings can be large — reframe once the ring has been laid out
|
|
509
|
+
// (deterministic, so one simulation frame is enough to know its size)
|
|
510
|
+
requestAnimationFrame(() => requestAnimationFrame(() => fitView(0.82)));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function collapseFile(fileId) {
|
|
515
|
+
const n = S.nodesById.get(fileId);
|
|
516
|
+
if (!n || n.kind !== "file" || !S.expanded.has(fileId)) return;
|
|
517
|
+
S.expanded.delete(fileId);
|
|
518
|
+
for (const fnId of S.fileFns.get(fileId) || []) S.visible.delete(fnId);
|
|
519
|
+
if (S.selected && !S.visible.has(S.selected)) select(null);
|
|
520
|
+
rebuild(); reheat();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* ---------------- spotlight: pick one file, see just its world --------- */
|
|
524
|
+
/* Clears the canvas down to a single file — its functions plus everything
|
|
525
|
+
one hop away — so picking a file from the Explorer answers "does this
|
|
526
|
+
thing connect to anything else, or not" without other clutter. */
|
|
527
|
+
function spotlightFile(fid) {
|
|
528
|
+
const n = S.nodesById.get(fid);
|
|
529
|
+
if (!n) return;
|
|
530
|
+
|
|
531
|
+
S.visible = new Set();
|
|
532
|
+
S.expanded = new Set();
|
|
533
|
+
S.visible.add(fid); ensurePos(fid);
|
|
534
|
+
const fns = S.fileFns.get(fid) || [];
|
|
535
|
+
for (const fnId of fns) { S.visible.add(fnId); ensurePos(fnId, fid); }
|
|
536
|
+
if (fns.length) S.expanded.add(fid);
|
|
537
|
+
|
|
538
|
+
const revealFor = (id) => {
|
|
539
|
+
for (const e of S.outEdges.get(id) || [])
|
|
540
|
+
if (!S.visible.has(e.target)) { S.visible.add(e.target); ensurePos(e.target, id); }
|
|
541
|
+
for (const e of S.inEdges.get(id) || [])
|
|
542
|
+
if (!S.visible.has(e.source)) { S.visible.add(e.source); ensurePos(e.source, id); }
|
|
543
|
+
};
|
|
544
|
+
revealFor(fid);
|
|
545
|
+
for (const fnId of fns) revealFor(fnId);
|
|
546
|
+
|
|
547
|
+
if (!passesFilter(n)) setFilter("all");
|
|
548
|
+
rebuild(); reheat();
|
|
549
|
+
select(fid);
|
|
550
|
+
fitView(0.82);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/* ---------------- focus mode toggle ------------------------------------ */
|
|
554
|
+
/* Off (default): the full "interesting files" overview, click-to-explore
|
|
555
|
+
cumulatively. On: canvas clears and picking a file in the Explorer
|
|
556
|
+
spotlights just that file instead of adding to what's already shown. */
|
|
557
|
+
function toggleFocusMode() {
|
|
558
|
+
S.focusMode = !S.focusMode;
|
|
559
|
+
$("focus-toggle").classList.toggle("active", S.focusMode);
|
|
560
|
+
if (S.focusMode) {
|
|
561
|
+
S.visible = new Set(); S.expanded = new Set();
|
|
562
|
+
select(null);
|
|
563
|
+
rebuild(); reheat();
|
|
564
|
+
} else {
|
|
565
|
+
S.visible = new Set(); S.expanded = new Set();
|
|
566
|
+
initialVisible(); layoutInitial();
|
|
567
|
+
select(null);
|
|
568
|
+
rebuild(); reheat();
|
|
569
|
+
fitView(0.82);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function revealNeighbors(id) {
|
|
574
|
+
let added = false;
|
|
575
|
+
for (const e of S.outEdges.get(id) || []) {
|
|
576
|
+
if (!S.visible.has(e.target)) { S.visible.add(e.target); ensurePos(e.target, id); added = true; }
|
|
577
|
+
}
|
|
578
|
+
for (const e of S.inEdges.get(id) || []) {
|
|
579
|
+
if (!S.visible.has(e.source)) { S.visible.add(e.source); ensurePos(e.source, id); added = true; }
|
|
580
|
+
}
|
|
581
|
+
if (added) { rebuild(); reheat(); }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function select(id, opts) {
|
|
585
|
+
opts = opts || {};
|
|
586
|
+
S.selected = id;
|
|
587
|
+
computeChain();
|
|
588
|
+
applyHighlight();
|
|
589
|
+
renderDetail();
|
|
590
|
+
if (!opts.fromNav) computeNavList(id);
|
|
591
|
+
if (!opts.fromHistory) pushHistory(id);
|
|
592
|
+
renderBreadcrumb();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* ---------------- navigation: neighbor cycling + history --------------- */
|
|
596
|
+
function computeNavList(id) {
|
|
597
|
+
if (id == null) { S.navList = []; S.navIdx = -1; return; }
|
|
598
|
+
const outs = (S.outEdges.get(id) || []).filter(e => e.kind !== "contains").map(e => e.target);
|
|
599
|
+
const ins = (S.inEdges.get(id) || []).filter(e => e.kind !== "contains").map(e => e.source);
|
|
600
|
+
const seen = new Set([id]);
|
|
601
|
+
S.navList = [...outs, ...ins].filter(nid => {
|
|
602
|
+
if (seen.has(nid)) return false;
|
|
603
|
+
seen.add(nid); return true;
|
|
604
|
+
});
|
|
605
|
+
S.navIdx = -1;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function pushHistory(id) {
|
|
609
|
+
if (id == null) return;
|
|
610
|
+
if (S.crumbs[S.crumbIdx] === id) return;
|
|
611
|
+
S.crumbs = S.crumbs.slice(0, S.crumbIdx + 1);
|
|
612
|
+
S.crumbs.push(id);
|
|
613
|
+
if (S.crumbs.length > 40) S.crumbs.shift();
|
|
614
|
+
S.crumbIdx = S.crumbs.length - 1;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function historyBack() {
|
|
618
|
+
if (S.crumbIdx <= 0) return;
|
|
619
|
+
S.crumbIdx--;
|
|
620
|
+
jumpTo(S.crumbs[S.crumbIdx], { fromHistory: true });
|
|
621
|
+
}
|
|
622
|
+
function historyForward() {
|
|
623
|
+
if (S.crumbIdx >= S.crumbs.length - 1) return;
|
|
624
|
+
S.crumbIdx++;
|
|
625
|
+
jumpTo(S.crumbs[S.crumbIdx], { fromHistory: true });
|
|
626
|
+
}
|
|
627
|
+
function navStep(dir) {
|
|
628
|
+
if (!S.navList.length) return;
|
|
629
|
+
S.navIdx = ((S.navIdx + dir) % S.navList.length + S.navList.length) % S.navList.length;
|
|
630
|
+
jumpTo(S.navList[S.navIdx], { fromNav: true });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* shows WHERE the current selection lives — folder / file / function —
|
|
634
|
+
not a history of past clicks (arrow-key history still works via
|
|
635
|
+
historyBack/historyForward, it's just not what's drawn here) */
|
|
636
|
+
function renderBreadcrumb() {
|
|
637
|
+
const el = $("breadcrumb");
|
|
638
|
+
const n = S.selected && S.nodesById.get(S.selected);
|
|
639
|
+
if (!n) { el.classList.add("hidden"); el.innerHTML = ""; return; }
|
|
640
|
+
|
|
641
|
+
const segs = [];
|
|
642
|
+
if (n.kind === "table") {
|
|
643
|
+
segs.push({ label: "Database", id: null });
|
|
644
|
+
segs.push({ label: n.label, id: n.id });
|
|
645
|
+
} else {
|
|
646
|
+
const parts = (n.file || "").split("/").filter(Boolean);
|
|
647
|
+
parts.forEach((part, i) => {
|
|
648
|
+
const isFileSeg = i === parts.length - 1;
|
|
649
|
+
segs.push({ label: part, id: isFileSeg ? n.file : null });
|
|
650
|
+
});
|
|
651
|
+
if (n.kind === "function" || n.kind === "method") {
|
|
652
|
+
segs.push({ label: n.label + "()", id: n.id });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
el.innerHTML = segs.map((s, i) => {
|
|
657
|
+
const isLast = i === segs.length - 1;
|
|
658
|
+
const cls = "bc-item" + (isLast ? " current" : "") + (s.id ? "" : " bc-dir");
|
|
659
|
+
return `<span class="${cls}"${s.id ? ` data-id="${esc(s.id)}"` : ""}>${esc(s.label)}</span>`;
|
|
660
|
+
}).join('<span class="bc-sep">›</span>');
|
|
661
|
+
el.classList.remove("hidden");
|
|
662
|
+
el.querySelectorAll(".bc-item[data-id]").forEach(elm => elm.addEventListener("click", () => {
|
|
663
|
+
jumpTo(elm.dataset.id, { fromHistory: true });
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/* full call chain: everything reachable upstream + downstream */
|
|
668
|
+
function computeChain() {
|
|
669
|
+
S.litNodes.clear(); S.litEdges.clear();
|
|
670
|
+
if (!S.selected) return;
|
|
671
|
+
S.litNodes.add(S.selected);
|
|
672
|
+
const walk = (start, dir) => {
|
|
673
|
+
const stack = [start], seen = new Set([start]);
|
|
674
|
+
while (stack.length) {
|
|
675
|
+
const cur = stack.pop();
|
|
676
|
+
const edges = (dir === "out" ? S.outEdges : S.inEdges).get(cur) || [];
|
|
677
|
+
for (const e of edges) {
|
|
678
|
+
if (e.kind === "contains") continue;
|
|
679
|
+
const next = dir === "out" ? e.target : e.source;
|
|
680
|
+
if (!S.visible.has(next)) continue;
|
|
681
|
+
S.litEdges.add(edgeKey(e));
|
|
682
|
+
if (!seen.has(next)) {
|
|
683
|
+
seen.add(next); S.litNodes.add(next); stack.push(next);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
walk(S.selected, "out");
|
|
689
|
+
walk(S.selected, "in");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function applyHighlight() {
|
|
693
|
+
const hasSel = !!S.selected;
|
|
694
|
+
for (const [id, el] of S.elNodes) {
|
|
695
|
+
el.classList.toggle("selected", id === S.selected);
|
|
696
|
+
el.classList.toggle("lit", hasSel && S.litNodes.has(id));
|
|
697
|
+
el.classList.toggle("dim", hasSel && !S.litNodes.has(id));
|
|
698
|
+
}
|
|
699
|
+
for (const { edge, key } of S.drawnEdges) {
|
|
700
|
+
const els = S.elEdges.get(key);
|
|
701
|
+
const lit = hasSel && (S.litEdges.has(key) ||
|
|
702
|
+
(edge.kind === "contains" && S.litNodes.has(edge.target)));
|
|
703
|
+
els.vis.classList.toggle("lit", lit);
|
|
704
|
+
els.vis.classList.toggle("dim", hasSel && !lit);
|
|
705
|
+
els.hit.classList.toggle("dim", hasSel && !lit);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/* ---------------- detail panel ---------------- */
|
|
710
|
+
function renderDetail() {
|
|
711
|
+
const panel = $("detail"), body = $("detail-body");
|
|
712
|
+
if (!S.selected) { panel.classList.add("hidden"); return; }
|
|
713
|
+
const n = S.nodesById.get(S.selected);
|
|
714
|
+
const color = LAYER_COLOR[nodeLayer(n)];
|
|
715
|
+
let html = `<span class="d-kind" style="color:${color};border-color:${color}55">` +
|
|
716
|
+
`${esc(n.kind)} · ${esc(LAYER_LABEL[nodeLayer(n)])}</span>`;
|
|
717
|
+
html += `<div class="d-name">${esc(n.label)}${n.kind === "function" || n.kind === "method" ? "()" : ""}</div>`;
|
|
718
|
+
|
|
719
|
+
const subBits = [];
|
|
720
|
+
if (n.kind === "function" || n.kind === "method") {
|
|
721
|
+
subBits.push(`Function ${n.index}`);
|
|
722
|
+
if (n.parentClass) subBits.push("in class " + n.parentClass);
|
|
723
|
+
}
|
|
724
|
+
if (n.file) subBits.push(`<span class="d-file" data-file="${esc(n.file)}">${esc(n.file)}</span>` +
|
|
725
|
+
(n.line ? `:${n.line}` : ""));
|
|
726
|
+
if (n.kind === "file") subBits.push(`${n.language} · ${n.loc} lines`);
|
|
727
|
+
html += `<div class="d-sub">${subBits.join(" · ")}</div>`;
|
|
728
|
+
|
|
729
|
+
if (n.description) html += `<div class="d-desc">${esc(n.description)}</div>`;
|
|
730
|
+
if (n.orphan) html += `<div class="d-warn">⚠ Orphaned — no detected callers.</div>`;
|
|
731
|
+
if (n.cycle) html += `<div class="d-warn d-cycle">↻ Part of a circular call chain.</div>`;
|
|
732
|
+
|
|
733
|
+
if (n.params && n.params.length)
|
|
734
|
+
html += section("Parameters", n.params.map(p => `<span class="d-chip">${esc(p)}</span>`).join(""));
|
|
735
|
+
if (n.returns) html += section("Returns", `<span class="d-chip">${esc(n.returns)}</span>`);
|
|
736
|
+
if (n.routes && n.routes.length)
|
|
737
|
+
html += section("Handles routes", n.routes.map(r =>
|
|
738
|
+
`<div class="d-route">⇢ ${esc(r.method)} ${esc(r.path)}</div>`).join(""));
|
|
739
|
+
{
|
|
740
|
+
const srcFileId = n.kind === "file" ? n.id : n.file;
|
|
741
|
+
const srcFile = srcFileId && S.nodesById.get(srcFileId);
|
|
742
|
+
let src = "";
|
|
743
|
+
if (n.code) {
|
|
744
|
+
const lineInfo = n.line ? ` (lines ${n.line}–${n.endLine || n.line})` : "";
|
|
745
|
+
src += `<button class="d-code-toggle" data-lines="${esc(lineInfo)}">▸ View code${esc(lineInfo)}</button>` +
|
|
746
|
+
`<pre class="d-code hidden"><code>${highlightCode(n.code, n.line || 1)}</code></pre>`;
|
|
747
|
+
}
|
|
748
|
+
if (srcFile && srcFile.source) {
|
|
749
|
+
src += `<button class="d-code-toggle d-ide-open" data-idefile="${esc(srcFileId)}"` +
|
|
750
|
+
` data-idefocus="${n.kind === "file" ? "" : esc(n.id)}"` +
|
|
751
|
+
` style="margin-top:6px">⛶ Open full file (IDE view)</button>`;
|
|
752
|
+
}
|
|
753
|
+
if (src) html += section("Source", src);
|
|
754
|
+
}
|
|
755
|
+
if (n.kind === "table") {
|
|
756
|
+
if (n.model) html += section("ORM model", `<span class="d-chip">${esc(n.model)}</span>`);
|
|
757
|
+
if (n.fields && n.fields.length)
|
|
758
|
+
html += section("Fields / columns", `<div class="d-fields">${n.fields.map(esc).join("<br>")}</div>`);
|
|
759
|
+
}
|
|
760
|
+
if (n.dbConfig && n.dbConfig.length)
|
|
761
|
+
html += section("DB config (masked)", `<div class="d-fields">${n.dbConfig.map(esc).join("<br>")}</div>`);
|
|
762
|
+
|
|
763
|
+
const outs = (S.outEdges.get(n.id) || []);
|
|
764
|
+
const ins = (S.inEdges.get(n.id) || []);
|
|
765
|
+
if (outs.length) html += section(`Calls into (${outs.length})`,
|
|
766
|
+
outs.slice(0, 40).map(e => linkRow(e.target, e)).join(""));
|
|
767
|
+
if (ins.length) html += section(`Called from (${ins.length})`,
|
|
768
|
+
ins.slice(0, 40).map(e => linkRow(e.source, e)).join(""));
|
|
769
|
+
if (!outs.length && !ins.length) {
|
|
770
|
+
const msg = n.kind === "file"
|
|
771
|
+
? "No detected connections — this file doesn't appear to link to anything else in the scan."
|
|
772
|
+
: "No detected connections — possibly unused (orphaned).";
|
|
773
|
+
html += section("Connections", `<div class="d-empty">${msg}</div>`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (n.kind === "file" && n.nFunctions)
|
|
777
|
+
html += section(`Functions (${n.nFunctions})`,
|
|
778
|
+
(S.fileFns.get(n.id) || []).map(fid => {
|
|
779
|
+
const f = S.nodesById.get(fid);
|
|
780
|
+
return `<div class="d-link" data-jump="${esc(fid)}">` +
|
|
781
|
+
`<span class="dl-kind">${f.index}.</span>` +
|
|
782
|
+
`<span class="dl-label">${esc(f.label)}()</span></div>`;
|
|
783
|
+
}).join(""));
|
|
784
|
+
|
|
785
|
+
if (n.warnings && n.warnings.length)
|
|
786
|
+
html += section("Warnings", n.warnings.map(w => `<div class="d-warn">⚠ ${esc(w)}</div>`).join(""));
|
|
787
|
+
|
|
788
|
+
body.innerHTML = html;
|
|
789
|
+
body.querySelectorAll("[data-jump]").forEach(el =>
|
|
790
|
+
el.addEventListener("click", () => jumpTo(el.dataset.jump)));
|
|
791
|
+
body.querySelectorAll(".d-file").forEach(el =>
|
|
792
|
+
el.addEventListener("click", () => jumpTo(el.dataset.file)));
|
|
793
|
+
body.querySelectorAll(".d-code-toggle:not(.d-ide-open)").forEach(el =>
|
|
794
|
+
el.addEventListener("click", () => {
|
|
795
|
+
const pre = el.nextElementSibling;
|
|
796
|
+
const hidden = pre.classList.toggle("hidden");
|
|
797
|
+
el.textContent = (hidden ? "▸ View code" : "▾ Hide code") + el.dataset.lines;
|
|
798
|
+
}));
|
|
799
|
+
body.querySelectorAll(".d-ide-open").forEach(el =>
|
|
800
|
+
el.addEventListener("click", () =>
|
|
801
|
+
openIde(el.dataset.idefile, el.dataset.idefocus || null)));
|
|
802
|
+
if (S.view !== "ide") panel.classList.remove("hidden");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function section(title, inner) {
|
|
806
|
+
return `<div class="d-section"><h4>${esc(title)}</h4>${inner}</div>`;
|
|
807
|
+
}
|
|
808
|
+
function linkRow(id, e) {
|
|
809
|
+
const t = S.nodesById.get(id);
|
|
810
|
+
if (!t) return "";
|
|
811
|
+
const kindTag = { call: "call", api: "API", db: "DB", include: "incl",
|
|
812
|
+
link: "page", contains: "" }[e.kind] || e.kind;
|
|
813
|
+
return `<div class="d-link" data-jump="${esc(id)}" title="${esc(e.label || "")}">` +
|
|
814
|
+
`<span class="dl-kind">${kindTag}</span>` +
|
|
815
|
+
`<span class="dl-label">${esc(t.label)}${t.kind === "table" ? " 🗃" : ""}</span></div>`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/* ---------------- jump / center ---------------- */
|
|
819
|
+
function jumpTo(id, opts) {
|
|
820
|
+
const n = S.nodesById.get(id);
|
|
821
|
+
if (!n) return;
|
|
822
|
+
if (S.view === "ide") {
|
|
823
|
+
const fileId = n.kind === "file" ? n.id : n.file;
|
|
824
|
+
if (fileId && S.nodesById.get(fileId)?.source) {
|
|
825
|
+
openIde(fileId, n.kind === "file" ? null : id);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
setView("chart"); // no source to show (e.g. a table) — use the chart
|
|
829
|
+
}
|
|
830
|
+
if ((n.kind === "function" || n.kind === "method") && !S.visible.has(id)) {
|
|
831
|
+
if (n.file && S.nodesById.has(n.file)) {
|
|
832
|
+
S.visible.add(n.file); ensurePos(n.file);
|
|
833
|
+
S.expanded.add(n.file);
|
|
834
|
+
for (const fid of S.fileFns.get(n.file) || []) {
|
|
835
|
+
S.visible.add(fid); ensurePos(fid, n.file);
|
|
836
|
+
}
|
|
837
|
+
} else { S.visible.add(id); ensurePos(id); }
|
|
838
|
+
} else if (!S.visible.has(id)) {
|
|
839
|
+
S.visible.add(id); ensurePos(id);
|
|
840
|
+
}
|
|
841
|
+
if (!passesFilter(n)) setFilter("all");
|
|
842
|
+
rebuild(); reheat();
|
|
843
|
+
select(id, opts);
|
|
844
|
+
centerOn(id);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function centerOn(id) {
|
|
848
|
+
const p = S.pos.get(id);
|
|
849
|
+
if (!p) return;
|
|
850
|
+
const x = p.x, y = p.y;
|
|
851
|
+
const r = svg.getBoundingClientRect();
|
|
852
|
+
animateTf({ x: r.width / 2 - x * S.tf.k, y: r.height / 2 - y * S.tf.k, k: S.tf.k });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function fitView(pad) {
|
|
856
|
+
const ids = [...S.visible].filter(id => S.elNodes.has(id));
|
|
857
|
+
if (!ids.length) return;
|
|
858
|
+
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
|
|
859
|
+
for (const id of ids) {
|
|
860
|
+
const p = S.pos.get(id); if (!p) continue;
|
|
861
|
+
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
|
862
|
+
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
|
863
|
+
}
|
|
864
|
+
const r = svg.getBoundingClientRect();
|
|
865
|
+
const w = Math.max(x1 - x0, 60), h = Math.max(y1 - y0, 60);
|
|
866
|
+
const k = Math.min(2, Math.min(r.width / w, r.height / h) * (pad || 0.85));
|
|
867
|
+
animateTf({
|
|
868
|
+
x: r.width / 2 - (x0 + x1) / 2 * k,
|
|
869
|
+
y: r.height / 2 - (y0 + y1) / 2 * k, k,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
let tfAnim = null;
|
|
874
|
+
function animateTf(target) {
|
|
875
|
+
const from = { ...S.tf }, t0 = performance.now();
|
|
876
|
+
cancelAnimationFrame(tfAnim);
|
|
877
|
+
const step = (t) => {
|
|
878
|
+
const u = Math.min(1, (t - t0) / 350), e = 1 - Math.pow(1 - u, 3);
|
|
879
|
+
S.tf.x = from.x + (target.x - from.x) * e;
|
|
880
|
+
S.tf.y = from.y + (target.y - from.y) * e;
|
|
881
|
+
S.tf.k = from.k + (target.k - from.k) * e;
|
|
882
|
+
applyTf();
|
|
883
|
+
if (u < 1) tfAnim = requestAnimationFrame(step);
|
|
884
|
+
};
|
|
885
|
+
tfAnim = requestAnimationFrame(step);
|
|
886
|
+
}
|
|
887
|
+
function applyTf() {
|
|
888
|
+
viewport.setAttribute("transform",
|
|
889
|
+
`translate(${S.tf.x},${S.tf.y}) scale(${S.tf.k})`);
|
|
890
|
+
updateMinimapViewport();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/* ---------------- minimap ---------------- */
|
|
894
|
+
const MM_W = 168, MM_H = 112;
|
|
895
|
+
|
|
896
|
+
function computeMinimapTransform() {
|
|
897
|
+
const ids = [...S.visible].filter(id => S.elNodes.has(id));
|
|
898
|
+
if (!ids.length) return null;
|
|
899
|
+
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
|
|
900
|
+
for (const id of ids) {
|
|
901
|
+
const p = S.pos.get(id); if (!p) continue;
|
|
902
|
+
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
|
903
|
+
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
|
904
|
+
}
|
|
905
|
+
const w = Math.max(x1 - x0, 60), h = Math.max(y1 - y0, 60);
|
|
906
|
+
const pad = 18;
|
|
907
|
+
const scale = Math.min((MM_W - pad) / w, (MM_H - pad) / h);
|
|
908
|
+
return { x0, y0, scale, ox: (MM_W - w * scale) / 2, oy: (MM_H - h * scale) / 2 };
|
|
909
|
+
}
|
|
910
|
+
const mmX = (t, x) => t.ox + (x - t.x0) * t.scale;
|
|
911
|
+
const mmY = (t, y) => t.oy + (y - t.y0) * t.scale;
|
|
912
|
+
|
|
913
|
+
function renderMinimap() {
|
|
914
|
+
const svgEl = $("minimap-svg");
|
|
915
|
+
svgEl.innerHTML = "";
|
|
916
|
+
svgEl.setAttribute("viewBox", `0 0 ${MM_W} ${MM_H}`);
|
|
917
|
+
S.mmDots = new Map();
|
|
918
|
+
S.mmT = computeMinimapTransform();
|
|
919
|
+
if (!S.mmT) { S.mmViewportEl = null; return; }
|
|
920
|
+
for (const id of S.visible) {
|
|
921
|
+
if (!S.elNodes.has(id)) continue;
|
|
922
|
+
const n = S.nodesById.get(id);
|
|
923
|
+
if (!n) continue;
|
|
924
|
+
const c = document.createElementNS(SVGNS, "circle");
|
|
925
|
+
c.setAttribute("r", n.kind === "file" ? 1.6 : 1);
|
|
926
|
+
c.setAttribute("fill", LAYER_COLOR[nodeLayer(n)]);
|
|
927
|
+
c.setAttribute("opacity", n.kind === "file" ? 0.9 : 0.55);
|
|
928
|
+
svgEl.appendChild(c);
|
|
929
|
+
S.mmDots.set(id, c);
|
|
930
|
+
}
|
|
931
|
+
const rect = document.createElementNS(SVGNS, "rect");
|
|
932
|
+
rect.id = "mm-viewport";
|
|
933
|
+
svgEl.appendChild(rect);
|
|
934
|
+
S.mmViewportEl = rect;
|
|
935
|
+
updateMinimapPositions();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function updateMinimapPositions() {
|
|
939
|
+
const t = S.mmT;
|
|
940
|
+
if (!t) return;
|
|
941
|
+
for (const [id, c] of S.mmDots) {
|
|
942
|
+
const p = S.pos.get(id);
|
|
943
|
+
if (!p) continue;
|
|
944
|
+
c.setAttribute("cx", mmX(t, p.x));
|
|
945
|
+
c.setAttribute("cy", mmY(t, p.y));
|
|
946
|
+
}
|
|
947
|
+
updateMinimapViewport();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function updateMinimapViewport() {
|
|
951
|
+
const t = S.mmT, rectEl = S.mmViewportEl;
|
|
952
|
+
if (!t || !rectEl) return;
|
|
953
|
+
const r = svg.getBoundingClientRect();
|
|
954
|
+
if (!r.width || !r.height) return;
|
|
955
|
+
const wx0 = -S.tf.x / S.tf.k, wy0 = -S.tf.y / S.tf.k;
|
|
956
|
+
const wx1 = (r.width - S.tf.x) / S.tf.k, wy1 = (r.height - S.tf.y) / S.tf.k;
|
|
957
|
+
rectEl.setAttribute("x", mmX(t, wx0));
|
|
958
|
+
rectEl.setAttribute("y", mmY(t, wy0));
|
|
959
|
+
rectEl.setAttribute("width", Math.max(4, (wx1 - wx0) * t.scale));
|
|
960
|
+
rectEl.setAttribute("height", Math.max(4, (wy1 - wy0) * t.scale));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function bindMinimap() {
|
|
964
|
+
const svgEl = $("minimap-svg");
|
|
965
|
+
let dragging = false;
|
|
966
|
+
const panTo = (ev) => {
|
|
967
|
+
const t = S.mmT; if (!t) return;
|
|
968
|
+
const rect = svgEl.getBoundingClientRect();
|
|
969
|
+
const mx = (ev.clientX - rect.left) * (MM_W / rect.width);
|
|
970
|
+
const my = (ev.clientY - rect.top) * (MM_H / rect.height);
|
|
971
|
+
const wx = t.x0 + (mx - t.ox) / t.scale;
|
|
972
|
+
const wy = t.y0 + (my - t.oy) / t.scale;
|
|
973
|
+
const r = svg.getBoundingClientRect();
|
|
974
|
+
animateTf({ x: r.width / 2 - wx * S.tf.k, y: r.height / 2 - wy * S.tf.k, k: S.tf.k });
|
|
975
|
+
};
|
|
976
|
+
svgEl.addEventListener("mousedown", (ev) => { dragging = true; panTo(ev); ev.stopPropagation(); });
|
|
977
|
+
window.addEventListener("mousemove", (ev) => { if (dragging) panTo(ev); });
|
|
978
|
+
window.addEventListener("mouseup", () => { dragging = false; });
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const MODE_CYCLE = ["orbit", "free"];
|
|
982
|
+
const MODE_ICON = { orbit: "◎", free: "✺" };
|
|
983
|
+
const MODE_TITLE = {
|
|
984
|
+
orbit: "Orbit layout — files float, functions ring around them (click to switch)",
|
|
985
|
+
free: "Free layout — everything force-directed (click to switch)",
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
/* ---------------- orbit layout (files float, functions ring them) ------ */
|
|
989
|
+
/* A file's functions sit on a circle around it, spoked by straight
|
|
990
|
+
"contains" lines. Only files/tables/unanchored functions take part in
|
|
991
|
+
the physics (repulsion + spring + light layer grouping); a file's own
|
|
992
|
+
functions are placed deterministically each frame so the ring never
|
|
993
|
+
drifts out of shape or overlaps a neighboring file's ring. */
|
|
994
|
+
function isOrbitHub(id) {
|
|
995
|
+
const n = S.nodesById.get(id);
|
|
996
|
+
if (!n) return true;
|
|
997
|
+
if (n.kind !== "function" && n.kind !== "method") return true;
|
|
998
|
+
return !(n.file && S.expanded.has(n.file) && S.visible.has(n.file));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function hubOf(id) {
|
|
1002
|
+
const n = S.nodesById.get(id);
|
|
1003
|
+
if (n && (n.kind === "function" || n.kind === "method") &&
|
|
1004
|
+
n.file && S.expanded.has(n.file) && S.visible.has(n.file)) {
|
|
1005
|
+
return n.file;
|
|
1006
|
+
}
|
|
1007
|
+
return id;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function fileOrbitFns(fid) {
|
|
1011
|
+
if (!S.expanded.has(fid)) return [];
|
|
1012
|
+
return (S.fileFns.get(fid) || []).filter(fnId => S.visible.has(fnId));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/* radius grows with function count so the ring's circumference always
|
|
1016
|
+
has enough room per function — dense files never look like a jam */
|
|
1017
|
+
function orbitRadius(fid) {
|
|
1018
|
+
const n = fileOrbitFns(fid).length;
|
|
1019
|
+
return n ? Math.max(70, 6.5 * n + 40) : 0;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function hubFootprint(id) {
|
|
1023
|
+
const n = S.nodesById.get(id);
|
|
1024
|
+
return (n ? nodeSize(n).w / 2 : 40) + 16 + orbitRadius(id);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function layoutSatellites() {
|
|
1028
|
+
for (const fid of S.expanded) {
|
|
1029
|
+
if (!S.visible.has(fid)) continue;
|
|
1030
|
+
const fp = S.pos.get(fid);
|
|
1031
|
+
if (!fp) continue;
|
|
1032
|
+
const fns = fileOrbitFns(fid);
|
|
1033
|
+
const n = fns.length;
|
|
1034
|
+
if (!n) continue;
|
|
1035
|
+
const r = orbitRadius(fid);
|
|
1036
|
+
fns.forEach((fnId, i) => {
|
|
1037
|
+
let p = S.pos.get(fnId);
|
|
1038
|
+
if (!p) { p = { x: fp.x, y: fp.y, vx: 0, vy: 0 }; S.pos.set(fnId, p); }
|
|
1039
|
+
if (p.fx !== undefined) { p.x = p.fx; p.y = p.fy; return; } // being dragged — follow the pointer
|
|
1040
|
+
const angle = (2 * Math.PI * i / n) - Math.PI / 2;
|
|
1041
|
+
p.x = fp.x + r * Math.cos(angle);
|
|
1042
|
+
p.y = fp.y + r * Math.sin(angle);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function simTickOrbit() {
|
|
1048
|
+
const alpha = S.alpha;
|
|
1049
|
+
if (alpha < 0.005) return false;
|
|
1050
|
+
|
|
1051
|
+
const hubIds = [...S.elNodes.keys()].filter(isOrbitHub);
|
|
1052
|
+
|
|
1053
|
+
// repulsion, orbit-radius aware so rings never overlap each other
|
|
1054
|
+
const cell = 260, grid = new Map();
|
|
1055
|
+
for (const id of hubIds) {
|
|
1056
|
+
const p = S.pos.get(id);
|
|
1057
|
+
const key = (p.x / cell | 0) + ":" + (p.y / cell | 0);
|
|
1058
|
+
if (!grid.has(key)) grid.set(key, []);
|
|
1059
|
+
grid.get(key).push(id);
|
|
1060
|
+
}
|
|
1061
|
+
for (const id of hubIds) {
|
|
1062
|
+
const p = S.pos.get(id), ri = hubFootprint(id);
|
|
1063
|
+
const cx = p.x / cell | 0, cy = p.y / cell | 0;
|
|
1064
|
+
for (let gx = cx - 2; gx <= cx + 2; gx++)
|
|
1065
|
+
for (let gy = cy - 2; gy <= cy + 2; gy++) {
|
|
1066
|
+
for (const oid of grid.get(gx + ":" + gy) || []) {
|
|
1067
|
+
if (oid === id) continue;
|
|
1068
|
+
const q = S.pos.get(oid), rj = hubFootprint(oid);
|
|
1069
|
+
let dx = p.x - q.x, dy = p.y - q.y;
|
|
1070
|
+
let d2 = dx * dx + dy * dy;
|
|
1071
|
+
if (d2 < 1) { dx = hash01(id) - 0.5; dy = hash01(oid) - 0.5; d2 = 1; }
|
|
1072
|
+
const d = Math.sqrt(d2);
|
|
1073
|
+
const minSep = ri + rj + 60;
|
|
1074
|
+
let f;
|
|
1075
|
+
if (d < minSep) f = (minSep - d) * 0.11 * alpha;
|
|
1076
|
+
else { if (d2 > 810000) continue; f = 4200 / d2 * alpha; }
|
|
1077
|
+
p.vx += dx / d * f; p.vy += dy / d * f;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// hub-to-hub springs, derived from edges between their satellite functions
|
|
1083
|
+
const hubSprings = new Map();
|
|
1084
|
+
for (const { edge } of S.drawnEdges) {
|
|
1085
|
+
if (edge.kind === "contains") continue;
|
|
1086
|
+
const hs = hubOf(edge.source), ht = hubOf(edge.target);
|
|
1087
|
+
if (hs === ht) continue;
|
|
1088
|
+
const key = hs < ht ? hs + "→" + ht : ht + "→" + hs;
|
|
1089
|
+
const rec = hubSprings.get(key);
|
|
1090
|
+
if (rec) rec.count++;
|
|
1091
|
+
else hubSprings.set(key, { a: hs, b: ht, count: 1 });
|
|
1092
|
+
}
|
|
1093
|
+
for (const { a, b, count } of hubSprings.values()) {
|
|
1094
|
+
const p = S.pos.get(a), q = S.pos.get(b);
|
|
1095
|
+
if (!p || !q) continue;
|
|
1096
|
+
const idealLen = 130 + hubFootprint(a) + hubFootprint(b);
|
|
1097
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
1098
|
+
const d = Math.max(1, Math.hypot(dx, dy));
|
|
1099
|
+
const strength = Math.min(2.2, 1 + Math.log2(count));
|
|
1100
|
+
const f = (d - idealLen) * 0.05 * alpha * strength;
|
|
1101
|
+
p.vx += dx / d * f; p.vy += dy / d * f;
|
|
1102
|
+
q.vx -= dx / d * f; q.vy -= dy / d * f;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// light layer grouping + centering — floaty, not rigid columns
|
|
1106
|
+
for (const id of hubIds) {
|
|
1107
|
+
const n = S.nodesById.get(id), p = S.pos.get(id);
|
|
1108
|
+
const bx = (BAND[nodeLayer(n)] ?? 0.5) * 1600;
|
|
1109
|
+
p.vx += (bx - p.x) * 0.006 * alpha;
|
|
1110
|
+
p.vy += (550 - p.y) * 0.003 * alpha;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// integrate
|
|
1114
|
+
for (const id of hubIds) {
|
|
1115
|
+
const p = S.pos.get(id);
|
|
1116
|
+
if (p.fx !== undefined) { p.x = p.fx; p.y = p.fy; p.vx = p.vy = 0; continue; }
|
|
1117
|
+
p.vx *= 0.58; p.vy *= 0.58;
|
|
1118
|
+
p.x += Math.max(-26, Math.min(26, p.vx));
|
|
1119
|
+
p.y += Math.max(-26, Math.min(26, p.vy));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
layoutSatellites();
|
|
1123
|
+
S.alpha *= 0.985;
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/* ---------------- force simulation (free layout) ------------------------ */
|
|
1128
|
+
function reheat() {
|
|
1129
|
+
S.alpha = Math.max(S.alpha, 0.9);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function simTick() {
|
|
1133
|
+
return S.mode === "orbit" ? simTickOrbit() : simTickFree();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function simTickFree() {
|
|
1137
|
+
const nodes = [...S.elNodes.keys()];
|
|
1138
|
+
const a = S.alpha;
|
|
1139
|
+
if (a < 0.005) return false;
|
|
1140
|
+
const REP = 5200, SPRING = 0.06,
|
|
1141
|
+
LEN = { contains: 80, call: 130, api: 190, db: 150,
|
|
1142
|
+
include: 200, link: 220 };
|
|
1143
|
+
|
|
1144
|
+
// repulsion (grid-bucketed to stay fast)
|
|
1145
|
+
const cell = 170, grid = new Map();
|
|
1146
|
+
for (const id of nodes) {
|
|
1147
|
+
const p = S.pos.get(id);
|
|
1148
|
+
const key = (p.x / cell | 0) + ":" + (p.y / cell | 0);
|
|
1149
|
+
if (!grid.has(key)) grid.set(key, []);
|
|
1150
|
+
grid.get(key).push(id);
|
|
1151
|
+
}
|
|
1152
|
+
for (const id of nodes) {
|
|
1153
|
+
const p = S.pos.get(id);
|
|
1154
|
+
const cx = p.x / cell | 0, cy = p.y / cell | 0;
|
|
1155
|
+
for (let gx = cx - 1; gx <= cx + 1; gx++)
|
|
1156
|
+
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
|
1157
|
+
for (const oid of grid.get(gx + ":" + gy) || []) {
|
|
1158
|
+
if (oid === id) continue;
|
|
1159
|
+
const q = S.pos.get(oid);
|
|
1160
|
+
let dx = p.x - q.x, dy = p.y - q.y;
|
|
1161
|
+
let d2 = dx * dx + dy * dy;
|
|
1162
|
+
if (d2 < 1) { dx = hash01(id) - 0.5; dy = hash01(oid) - 0.5; d2 = 1; }
|
|
1163
|
+
if (d2 > cell * cell * 2.6) continue;
|
|
1164
|
+
const f = REP / d2 * a;
|
|
1165
|
+
const d = Math.sqrt(d2);
|
|
1166
|
+
p.vx += dx / d * f; p.vy += dy / d * f;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
// springs
|
|
1171
|
+
for (const { edge } of S.drawnEdges) {
|
|
1172
|
+
const p = S.pos.get(edge.source), q = S.pos.get(edge.target);
|
|
1173
|
+
if (!p || !q) continue;
|
|
1174
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
1175
|
+
const d = Math.max(1, Math.hypot(dx, dy));
|
|
1176
|
+
const f = (d - (LEN[edge.kind] || 140)) * SPRING * a;
|
|
1177
|
+
p.vx += dx / d * f; p.vy += dy / d * f;
|
|
1178
|
+
q.vx -= dx / d * f; q.vy -= dy / d * f;
|
|
1179
|
+
}
|
|
1180
|
+
// layer-band gravity + centering
|
|
1181
|
+
for (const id of nodes) {
|
|
1182
|
+
const n = S.nodesById.get(id), p = S.pos.get(id);
|
|
1183
|
+
const bx = (BAND[nodeLayer(n)] ?? 0.5) * 1600;
|
|
1184
|
+
p.vx += (bx - p.x) * 0.012 * a;
|
|
1185
|
+
p.vy += (550 - p.y) * 0.006 * a;
|
|
1186
|
+
}
|
|
1187
|
+
// integrate
|
|
1188
|
+
for (const id of nodes) {
|
|
1189
|
+
const p = S.pos.get(id);
|
|
1190
|
+
if (p.fx !== undefined) { p.x = p.fx; p.y = p.fy; p.vx = p.vy = 0; continue; }
|
|
1191
|
+
p.vx *= 0.55; p.vy *= 0.55;
|
|
1192
|
+
p.x += Math.max(-28, Math.min(28, p.vx));
|
|
1193
|
+
p.y += Math.max(-28, Math.min(28, p.vy));
|
|
1194
|
+
}
|
|
1195
|
+
S.alpha *= 0.985;
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function runTicks(n) { for (let i = 0; i < n; i++) { S.alpha = 0.9; simTick(); } S.alpha = 0.25; }
|
|
1200
|
+
|
|
1201
|
+
function startLoop() {
|
|
1202
|
+
const loop = () => {
|
|
1203
|
+
if (simTick()) updatePositions();
|
|
1204
|
+
requestAnimationFrame(loop);
|
|
1205
|
+
};
|
|
1206
|
+
requestAnimationFrame(loop);
|
|
1207
|
+
applyTf();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function updatePositions() {
|
|
1211
|
+
for (const [id, el] of S.elNodes) {
|
|
1212
|
+
const p = S.pos.get(id);
|
|
1213
|
+
el.setAttribute("transform", `translate(${p.x},${p.y})`);
|
|
1214
|
+
}
|
|
1215
|
+
updateMinimapPositions();
|
|
1216
|
+
for (const { edge, key } of S.drawnEdges) {
|
|
1217
|
+
const p = S.pos.get(edge.source), q = S.pos.get(edge.target);
|
|
1218
|
+
if (!p || !q) continue;
|
|
1219
|
+
const els = S.elEdges.get(key);
|
|
1220
|
+
const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2;
|
|
1221
|
+
const dx = q.x - p.x, dy = q.y - p.y;
|
|
1222
|
+
const d = Math.max(1, Math.hypot(dx, dy));
|
|
1223
|
+
const bend = edge.kind === "contains" ? 0 : Math.min(34, d * 0.14);
|
|
1224
|
+
const cx = mx - dy / d * bend, cy = my + dx / d * bend;
|
|
1225
|
+
const path = `M${p.x},${p.y} Q${cx},${cy} ${q.x},${q.y}`;
|
|
1226
|
+
els.vis.setAttribute("d", path);
|
|
1227
|
+
els.hit.setAttribute("d", path);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/* ---------------- pan / zoom / drag ---------------- */
|
|
1232
|
+
function bindUI() {
|
|
1233
|
+
let pan = null;
|
|
1234
|
+
svg.addEventListener("mousedown", (ev) => {
|
|
1235
|
+
if (ev.target.closest(".node")) return;
|
|
1236
|
+
pan = { x: ev.clientX, y: ev.clientY, tx: S.tf.x, ty: S.tf.y };
|
|
1237
|
+
svg.classList.add("panning");
|
|
1238
|
+
});
|
|
1239
|
+
window.addEventListener("mousemove", (ev) => {
|
|
1240
|
+
if (S.drag) { dragNodeMove(ev); return; }
|
|
1241
|
+
if (!pan) return;
|
|
1242
|
+
S.tf.x = pan.tx + ev.clientX - pan.x;
|
|
1243
|
+
S.tf.y = pan.ty + ev.clientY - pan.y;
|
|
1244
|
+
applyTf();
|
|
1245
|
+
});
|
|
1246
|
+
window.addEventListener("mouseup", () => {
|
|
1247
|
+
if (S.drag) endDragNode();
|
|
1248
|
+
pan = null; svg.classList.remove("panning");
|
|
1249
|
+
});
|
|
1250
|
+
svg.addEventListener("click", (ev) => {
|
|
1251
|
+
if (!ev.target.closest(".node") && !ev.target.closest(".edge-hit")) select(null);
|
|
1252
|
+
});
|
|
1253
|
+
svg.addEventListener("contextmenu", (ev) => {
|
|
1254
|
+
if (ev.target.closest(".node") || ev.target.closest(".edge-hit")) return;
|
|
1255
|
+
ev.preventDefault();
|
|
1256
|
+
onCanvasContextMenu(ev);
|
|
1257
|
+
});
|
|
1258
|
+
svg.addEventListener("wheel", (ev) => {
|
|
1259
|
+
ev.preventDefault();
|
|
1260
|
+
const r = svg.getBoundingClientRect();
|
|
1261
|
+
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
|
1262
|
+
const k2 = Math.max(0.12, Math.min(3.2, S.tf.k * (ev.deltaY < 0 ? 1.13 : 0.885)));
|
|
1263
|
+
S.tf.x = mx - (mx - S.tf.x) * (k2 / S.tf.k);
|
|
1264
|
+
S.tf.y = my - (my - S.tf.y) * (k2 / S.tf.k);
|
|
1265
|
+
S.tf.k = k2;
|
|
1266
|
+
applyTf();
|
|
1267
|
+
}, { passive: false });
|
|
1268
|
+
|
|
1269
|
+
$("zoom-in").onclick = () => animateTf({ ...S.tf, k: Math.min(3.2, S.tf.k * 1.3) });
|
|
1270
|
+
$("zoom-out").onclick = () => animateTf({ ...S.tf, k: Math.max(0.12, S.tf.k / 1.3) });
|
|
1271
|
+
$("zoom-fit").onclick = () => fitView(0.82);
|
|
1272
|
+
$("layout-toggle").onclick = () => {
|
|
1273
|
+
const i = MODE_CYCLE.indexOf(S.mode);
|
|
1274
|
+
S.mode = MODE_CYCLE[(i + 1) % MODE_CYCLE.length];
|
|
1275
|
+
const btn = $("layout-toggle");
|
|
1276
|
+
btn.textContent = MODE_ICON[S.mode];
|
|
1277
|
+
btn.title = MODE_TITLE[S.mode];
|
|
1278
|
+
btn.classList.toggle("active", S.mode === "orbit");
|
|
1279
|
+
S.alpha = 0.9;
|
|
1280
|
+
};
|
|
1281
|
+
$("detail-close").onclick = () => select(null);
|
|
1282
|
+
$("warnings-close").onclick = () => $("warnings-panel").classList.add("hidden");
|
|
1283
|
+
$("insights-close").onclick = () => $("insights-panel").classList.add("hidden");
|
|
1284
|
+
$("onboarding-dismiss").onclick = dismissOnboarding;
|
|
1285
|
+
$("focus-toggle").onclick = toggleFocusMode;
|
|
1286
|
+
$("onboarding").addEventListener("click", (ev) => {
|
|
1287
|
+
if (ev.target.id === "onboarding") dismissOnboarding();
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
document.querySelectorAll("#layer-filter button").forEach(b =>
|
|
1291
|
+
b.addEventListener("click", () => setFilter(b.dataset.layer)));
|
|
1292
|
+
|
|
1293
|
+
const search = $("search");
|
|
1294
|
+
search.addEventListener("input", () => renderSearch(search.value));
|
|
1295
|
+
search.addEventListener("keydown", (ev) => {
|
|
1296
|
+
const items = [...document.querySelectorAll(".sr-item")];
|
|
1297
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
1298
|
+
ev.preventDefault();
|
|
1299
|
+
S.searchSel = ev.key === "ArrowDown"
|
|
1300
|
+
? Math.min(items.length - 1, S.searchSel + 1)
|
|
1301
|
+
: Math.max(0, S.searchSel - 1);
|
|
1302
|
+
items.forEach((el, i) => el.classList.toggle("sel", i === S.searchSel));
|
|
1303
|
+
items[S.searchSel]?.scrollIntoView({ block: "nearest" });
|
|
1304
|
+
} else if (ev.key === "Enter") {
|
|
1305
|
+
(items[S.searchSel] || items[0])?.click();
|
|
1306
|
+
} else if (ev.key === "Escape") {
|
|
1307
|
+
closeSearch(); search.blur();
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
$("ide-close").onclick = closeIde;
|
|
1311
|
+
document.querySelectorAll("#view-mode button").forEach(b =>
|
|
1312
|
+
b.addEventListener("click", () => setView(b.dataset.view)));
|
|
1313
|
+
document.addEventListener("keydown", (ev) => {
|
|
1314
|
+
const typing = document.activeElement === search ||
|
|
1315
|
+
document.activeElement?.tagName === "INPUT";
|
|
1316
|
+
if (ev.key === "/" && !typing && $("ide").classList.contains("hidden")) {
|
|
1317
|
+
ev.preventDefault(); search.focus(); search.select();
|
|
1318
|
+
}
|
|
1319
|
+
if (ev.key === "Escape") {
|
|
1320
|
+
if (!$("context-menu").classList.contains("hidden")) { hideContextMenu(); return; }
|
|
1321
|
+
if (!$("onboarding").classList.contains("hidden")) { dismissOnboarding(); return; }
|
|
1322
|
+
if (!$("ide").classList.contains("hidden")) { closeIde(); return; }
|
|
1323
|
+
select(null); closeSearch();
|
|
1324
|
+
}
|
|
1325
|
+
if (typing || !$("ide").classList.contains("hidden")) return;
|
|
1326
|
+
if (ev.key === "ArrowRight") { if (S.selected) { ev.preventDefault(); navStep(1); } }
|
|
1327
|
+
else if (ev.key === "ArrowLeft") { if (S.selected) { ev.preventDefault(); navStep(-1); } }
|
|
1328
|
+
else if (ev.key === "ArrowUp") { if (S.crumbIdx > 0) { ev.preventDefault(); historyBack(); } }
|
|
1329
|
+
else if (ev.key === "ArrowDown") { if (S.crumbIdx < S.crumbs.length - 1) { ev.preventDefault(); historyForward(); } }
|
|
1330
|
+
});
|
|
1331
|
+
document.addEventListener("click", (ev) => {
|
|
1332
|
+
if (!ev.target.closest("#search-wrap")) closeSearch();
|
|
1333
|
+
});
|
|
1334
|
+
// capture phase: fires before node/edge click handlers can stopPropagation()
|
|
1335
|
+
// and swallow the bubble-phase event this would otherwise rely on
|
|
1336
|
+
document.addEventListener("mousedown", (ev) => {
|
|
1337
|
+
if (!ev.target.closest("#context-menu")) hideContextMenu();
|
|
1338
|
+
}, true);
|
|
1339
|
+
window.addEventListener("blur", hideContextMenu);
|
|
1340
|
+
window.addEventListener("resize", hideContextMenu);
|
|
1341
|
+
svg.addEventListener("wheel", hideContextMenu, { passive: true });
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function setFilter(layer) {
|
|
1345
|
+
S.filter = layer;
|
|
1346
|
+
document.querySelectorAll("#layer-filter button").forEach(b =>
|
|
1347
|
+
b.classList.toggle("active", b.dataset.layer === layer));
|
|
1348
|
+
if (S.selected && !passesFilter(S.nodesById.get(S.selected))) select(null);
|
|
1349
|
+
rebuild(); reheat();
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function startDragNode(ev, id) {
|
|
1353
|
+
ev.stopPropagation();
|
|
1354
|
+
const drag = { id, moved: false, sx: ev.clientX, sy: ev.clientY, kids: [] };
|
|
1355
|
+
// dragging a file carries its expanded functions along, keeping offsets
|
|
1356
|
+
const n = S.nodesById.get(id);
|
|
1357
|
+
const p0 = S.pos.get(id);
|
|
1358
|
+
if (n && p0 && n.kind === "file" && S.expanded.has(id)) {
|
|
1359
|
+
for (const fnId of S.fileFns.get(id) || []) {
|
|
1360
|
+
if (!S.visible.has(fnId) || !S.elNodes.has(fnId)) continue;
|
|
1361
|
+
const cp = S.pos.get(fnId);
|
|
1362
|
+
if (cp) drag.kids.push({ id: fnId, dx: cp.x - p0.x, dy: cp.y - p0.y });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
S.drag = drag;
|
|
1366
|
+
}
|
|
1367
|
+
function dragNodeMove(ev) {
|
|
1368
|
+
const d = S.drag;
|
|
1369
|
+
if (Math.abs(ev.clientX - d.sx) + Math.abs(ev.clientY - d.sy) > 3) d.moved = true;
|
|
1370
|
+
if (!d.moved) return;
|
|
1371
|
+
const r = svg.getBoundingClientRect();
|
|
1372
|
+
const p = S.pos.get(d.id);
|
|
1373
|
+
p.fx = (ev.clientX - r.left - S.tf.x) / S.tf.k;
|
|
1374
|
+
p.fy = (ev.clientY - r.top - S.tf.y) / S.tf.k;
|
|
1375
|
+
for (const k of d.kids) {
|
|
1376
|
+
const cp = S.pos.get(k.id);
|
|
1377
|
+
if (cp) { cp.fx = p.fx + k.dx; cp.fy = p.fy + k.dy; }
|
|
1378
|
+
}
|
|
1379
|
+
reheat();
|
|
1380
|
+
}
|
|
1381
|
+
function endDragNode() {
|
|
1382
|
+
const ids = [S.drag.id, ...S.drag.kids.map(k => k.id)];
|
|
1383
|
+
for (const id of ids) {
|
|
1384
|
+
const p = S.pos.get(id);
|
|
1385
|
+
if (!p) continue;
|
|
1386
|
+
if (p.fx !== undefined) { p.x = p.fx; p.y = p.fy; delete p.fx; delete p.fy; }
|
|
1387
|
+
}
|
|
1388
|
+
const moved = S.drag.moved;
|
|
1389
|
+
setTimeout(() => { S.drag = null; }, moved ? 50 : 0);
|
|
1390
|
+
if (!moved) S.drag = null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/* ---------------- context menu ---------------- */
|
|
1394
|
+
function showContextMenu(x, y, items) {
|
|
1395
|
+
hideTooltip();
|
|
1396
|
+
const menu = $("context-menu");
|
|
1397
|
+
menu.innerHTML = items.map((it, i) => it.separator
|
|
1398
|
+
? `<div class="cm-sep"></div>`
|
|
1399
|
+
: `<button class="cm-item${it.disabled ? " disabled" : ""}" data-i="${i}">${esc(it.label)}</button>`
|
|
1400
|
+
).join("");
|
|
1401
|
+
menu.style.left = x + "px";
|
|
1402
|
+
menu.style.top = y + "px";
|
|
1403
|
+
menu.classList.remove("hidden");
|
|
1404
|
+
|
|
1405
|
+
// clamp on-screen after layout so it never renders off the edge
|
|
1406
|
+
requestAnimationFrame(() => {
|
|
1407
|
+
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
|
1408
|
+
const left = Math.min(x, window.innerWidth - mw - 8);
|
|
1409
|
+
const top = Math.min(y, window.innerHeight - mh - 8);
|
|
1410
|
+
menu.style.left = Math.max(8, left) + "px";
|
|
1411
|
+
menu.style.top = Math.max(8, top) + "px";
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
menu.querySelectorAll(".cm-item").forEach((el) => {
|
|
1415
|
+
const it = items[parseInt(el.dataset.i, 10)];
|
|
1416
|
+
if (it.disabled) return;
|
|
1417
|
+
el.addEventListener("click", () => { hideContextMenu(); it.onClick(); });
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function hideContextMenu() {
|
|
1422
|
+
$("context-menu").classList.add("hidden");
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function copyToClipboard(text) {
|
|
1426
|
+
if (navigator.clipboard?.writeText) {
|
|
1427
|
+
navigator.clipboard.writeText(text).catch(() => {});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const ta = document.createElement("textarea");
|
|
1431
|
+
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
|
1432
|
+
document.body.appendChild(ta); ta.select();
|
|
1433
|
+
try { document.execCommand("copy"); } catch (e) { /* clipboard unavailable */ }
|
|
1434
|
+
document.body.removeChild(ta);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function onNodeContextMenu(n, ev) {
|
|
1438
|
+
select(n.id);
|
|
1439
|
+
const items = [];
|
|
1440
|
+
const srcFileId = n.kind === "file" ? n.id : n.file;
|
|
1441
|
+
const srcFile = srcFileId && S.nodesById.get(srcFileId);
|
|
1442
|
+
|
|
1443
|
+
if (n.kind === "file") {
|
|
1444
|
+
if (n.nFunctions) {
|
|
1445
|
+
const isExpanded = S.expanded.has(n.id);
|
|
1446
|
+
items.push({ label: isExpanded ? "Collapse functions" : "Expand functions",
|
|
1447
|
+
onClick: () => isExpanded ? collapseFile(n.id) : expandFile(n.id) });
|
|
1448
|
+
}
|
|
1449
|
+
items.push({ label: "Reveal connections", onClick: () => revealNeighbors(n.id) });
|
|
1450
|
+
items.push({ label: "Focus only this file", onClick: () => {
|
|
1451
|
+
if (!S.focusMode) toggleFocusMode();
|
|
1452
|
+
spotlightFile(n.id);
|
|
1453
|
+
} });
|
|
1454
|
+
if (n.source) items.push({ label: "Open full file (IDE view)", onClick: () => openIde(n.id) });
|
|
1455
|
+
items.push({ separator: true });
|
|
1456
|
+
} else if (n.kind === "function" || n.kind === "method") {
|
|
1457
|
+
items.push({ label: "Reveal connections", onClick: () => revealNeighbors(n.id) });
|
|
1458
|
+
if (n.code) items.push({ label: "View code", onClick: () => {
|
|
1459
|
+
select(n.id);
|
|
1460
|
+
setTimeout(() => document.querySelector(".d-code-toggle:not(.d-ide-open)")?.click(), 30);
|
|
1461
|
+
} });
|
|
1462
|
+
if (srcFile?.source) items.push({ label: "Open in IDE view", onClick: () => openIde(srcFileId, n.id) });
|
|
1463
|
+
items.push({ separator: true });
|
|
1464
|
+
} else if (n.kind === "table") {
|
|
1465
|
+
items.push({ label: "Reveal connections", onClick: () => revealNeighbors(n.id) });
|
|
1466
|
+
if (n.file) items.push({ label: "Go to defining file", onClick: () => jumpTo(n.file) });
|
|
1467
|
+
items.push({ separator: true });
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
items.push({ label: "Center view here", onClick: () => centerOn(n.id) });
|
|
1471
|
+
items.push({ label: "Copy name", onClick: () => copyToClipboard(n.label) });
|
|
1472
|
+
if (n.file) items.push({ label: "Copy file path", onClick: () => copyToClipboard(n.file) });
|
|
1473
|
+
|
|
1474
|
+
showContextMenu(ev.clientX, ev.clientY, items);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function onEdgeContextMenu(e, ev) {
|
|
1478
|
+
const items = [
|
|
1479
|
+
{ label: "Go to source", onClick: () => jumpTo(e.source) },
|
|
1480
|
+
{ label: "Go to target", onClick: () => jumpTo(e.target) },
|
|
1481
|
+
];
|
|
1482
|
+
if (e.label) {
|
|
1483
|
+
items.push({ separator: true });
|
|
1484
|
+
items.push({ label: "Copy connection label", onClick: () => copyToClipboard(e.label) });
|
|
1485
|
+
}
|
|
1486
|
+
showContextMenu(ev.clientX, ev.clientY, items);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function onCanvasContextMenu(ev) {
|
|
1490
|
+
const items = [
|
|
1491
|
+
{ label: "Fit view", onClick: () => fitView(0.82) },
|
|
1492
|
+
{ label: "Zoom in", onClick: () => $("zoom-in").click() },
|
|
1493
|
+
{ label: "Zoom out", onClick: () => $("zoom-out").click() },
|
|
1494
|
+
{ separator: true },
|
|
1495
|
+
{ label: S.mode === "orbit" ? "Switch to Free layout" : "Switch to Orbit layout",
|
|
1496
|
+
onClick: () => $("layout-toggle").click() },
|
|
1497
|
+
{ label: S.focusMode ? "Exit Focus mode" : "Enter Focus mode",
|
|
1498
|
+
onClick: () => toggleFocusMode() },
|
|
1499
|
+
{ separator: true },
|
|
1500
|
+
{ label: "Deselect", disabled: !S.selected, onClick: () => select(null) },
|
|
1501
|
+
];
|
|
1502
|
+
showContextMenu(ev.clientX, ev.clientY, items);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/* ---------------- tooltip ---------------- */
|
|
1506
|
+
function showNodeTooltip(n, ev) {
|
|
1507
|
+
const tt = $("tooltip");
|
|
1508
|
+
let html = `<div class="tt-name">${esc(n.label)}${n.kind === "function" || n.kind === "method" ? "()" : ""}</div>`;
|
|
1509
|
+
if (n.description) html += `<div class="tt-desc">${esc(n.description)}</div>`;
|
|
1510
|
+
const meta = [LAYER_LABEL[nodeLayer(n)], n.kind];
|
|
1511
|
+
if (n.file && n.kind !== "file") meta.push(n.file);
|
|
1512
|
+
html += `<div class="tt-meta">${meta.map(esc).join(" · ")}</div>`;
|
|
1513
|
+
if (n.kind === "file" && S.fileFns.get(n.id)?.length && !S.expanded.has(n.id))
|
|
1514
|
+
html += `<div class="tt-meta">click to slice open ${S.fileFns.get(n.id).length} function(s)</div>`;
|
|
1515
|
+
tt.innerHTML = html;
|
|
1516
|
+
positionTooltip(ev);
|
|
1517
|
+
}
|
|
1518
|
+
function showEdgeTooltip(e, ev) {
|
|
1519
|
+
const tt = $("tooltip");
|
|
1520
|
+
const kind = { call: "function call", api: "API call", db: "database access",
|
|
1521
|
+
include: "file include/import", link: "page navigation",
|
|
1522
|
+
contains: "contains" }[e.kind] || e.kind;
|
|
1523
|
+
tt.innerHTML = `<div class="tt-name">${esc(e.label || kind)}</div>` +
|
|
1524
|
+
`<div class="tt-meta">${esc(kind)}</div>`;
|
|
1525
|
+
positionTooltip(ev);
|
|
1526
|
+
}
|
|
1527
|
+
function positionTooltip(ev) {
|
|
1528
|
+
const tt = $("tooltip"), r = $("canvas-wrap").getBoundingClientRect();
|
|
1529
|
+
tt.classList.remove("hidden");
|
|
1530
|
+
let x = ev.clientX - r.left + 16, y = ev.clientY - r.top + 14;
|
|
1531
|
+
if (x + tt.offsetWidth > r.width - 12) x = ev.clientX - r.left - tt.offsetWidth - 12;
|
|
1532
|
+
if (y + tt.offsetHeight > r.height - 12) y = ev.clientY - r.top - tt.offsetHeight - 10;
|
|
1533
|
+
tt.style.left = x + "px"; tt.style.top = y + "px";
|
|
1534
|
+
}
|
|
1535
|
+
function hideTooltip() { $("tooltip").classList.add("hidden"); }
|
|
1536
|
+
|
|
1537
|
+
/* ---------------- search ---------------- */
|
|
1538
|
+
function renderSearch(q) {
|
|
1539
|
+
const box = $("search-results");
|
|
1540
|
+
S.searchSel = -1;
|
|
1541
|
+
q = q.trim().toLowerCase();
|
|
1542
|
+
if (!q) { closeSearch(); return; }
|
|
1543
|
+
const hits = [];
|
|
1544
|
+
for (const n of S.data.nodes) {
|
|
1545
|
+
const label = (n.label || "").toLowerCase();
|
|
1546
|
+
const file = (n.file || "").toLowerCase();
|
|
1547
|
+
let score = -1;
|
|
1548
|
+
if (label === q) score = 0;
|
|
1549
|
+
else if (label.startsWith(q)) score = 1;
|
|
1550
|
+
else if (label.includes(q)) score = 2;
|
|
1551
|
+
else if (file.includes(q)) score = 3;
|
|
1552
|
+
if (score >= 0) hits.push([score, n]);
|
|
1553
|
+
if (hits.length > 300) break;
|
|
1554
|
+
}
|
|
1555
|
+
hits.sort((a, b) => a[0] - b[0] || a[1].label.length - b[1].label.length);
|
|
1556
|
+
box.innerHTML = "";
|
|
1557
|
+
if (!hits.length) box.innerHTML = "<div class='sr-empty'>No matches.</div>";
|
|
1558
|
+
for (const [, n] of hits.slice(0, 30)) {
|
|
1559
|
+
const d = document.createElement("div");
|
|
1560
|
+
d.className = "sr-item";
|
|
1561
|
+
const color = LAYER_COLOR[nodeLayer(n)];
|
|
1562
|
+
d.innerHTML = `<span class="tdot" style="background:${color}"></span>` +
|
|
1563
|
+
`<span class="sr-name">${esc(n.label)}${n.kind === "function" || n.kind === "method" ? "()" : ""}</span>` +
|
|
1564
|
+
`<span class="sr-path">${esc(n.kind === "file" ? n.language : (n.file || n.kind))}</span>`;
|
|
1565
|
+
d.onclick = () => { closeSearch(); $("search").value = ""; jumpTo(n.id); };
|
|
1566
|
+
box.appendChild(d);
|
|
1567
|
+
}
|
|
1568
|
+
box.classList.remove("hidden");
|
|
1569
|
+
}
|
|
1570
|
+
function closeSearch() { $("search-results").classList.add("hidden"); S.searchSel = -1; }
|
|
1571
|
+
|
|
1572
|
+
/* ---------------- code highlighting ---------------- */
|
|
1573
|
+
const CODE_KW = new Set(("function def class return if elif else for foreach " +
|
|
1574
|
+
"while switch case break continue const let var import from export default " +
|
|
1575
|
+
"async await public private protected internal static final void new try " +
|
|
1576
|
+
"catch except finally raise throw throws lambda yield global nonlocal pass " +
|
|
1577
|
+
"None True False null true false undefined this self func go defer type " +
|
|
1578
|
+
"struct interface package use namespace echo require end do then begin " +
|
|
1579
|
+
"module when unless until match string int float bool byte long double " +
|
|
1580
|
+
"extends implements abstract readonly override virtual sealed with as in " +
|
|
1581
|
+
"of is not and or instanceof typeof delete print").split(" "));
|
|
1582
|
+
const CODE_CTL = new Set(("if elif else for foreach while switch case break " +
|
|
1583
|
+
"continue return try catch except finally raise throw yield do then when " +
|
|
1584
|
+
"unless until match await default in of not and or is as with " +
|
|
1585
|
+
"instanceof typeof delete").split(" "));
|
|
1586
|
+
const CODE_TOK =
|
|
1587
|
+
/(\/\*[\s\S]*?\*\/|\/\/[^\n]*|#[^\n]*|--[^\n]*)|("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\$\w+)|([A-Za-z_][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|(\d[\w.]*)/g;
|
|
1588
|
+
|
|
1589
|
+
function hlLine(line) {
|
|
1590
|
+
let out = "", last = 0, m;
|
|
1591
|
+
CODE_TOK.lastIndex = 0;
|
|
1592
|
+
while ((m = CODE_TOK.exec(line))) {
|
|
1593
|
+
out += esc(line.slice(last, m.index));
|
|
1594
|
+
if (m[1]) out += `<span class="c-com">${esc(m[0])}</span>`;
|
|
1595
|
+
else if (m[2]) out += `<span class="c-str">${esc(m[0])}</span>`;
|
|
1596
|
+
else if (m[3]) out += `<span class="c-var">${esc(m[0])}</span>`;
|
|
1597
|
+
else if (m[4]) out += CODE_CTL.has(m[0])
|
|
1598
|
+
? `<span class="c-ctl">${esc(m[0])}</span>`
|
|
1599
|
+
: (CODE_KW.has(m[0])
|
|
1600
|
+
? `<span class="c-kw">${esc(m[0])}</span>`
|
|
1601
|
+
: `<span class="c-call">${esc(m[0])}</span>`);
|
|
1602
|
+
else if (m[5]) out += CODE_CTL.has(m[0])
|
|
1603
|
+
? `<span class="c-ctl">${esc(m[0])}</span>`
|
|
1604
|
+
: (CODE_KW.has(m[0])
|
|
1605
|
+
? `<span class="c-kw">${esc(m[0])}</span>` : esc(m[0]));
|
|
1606
|
+
else out += `<span class="c-num">${esc(m[0])}</span>`;
|
|
1607
|
+
last = m.index + m[0].length;
|
|
1608
|
+
}
|
|
1609
|
+
return out + esc(line.slice(last));
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/* ---------------- view modes: chart / IDE ---------------- */
|
|
1613
|
+
function setView(view) {
|
|
1614
|
+
S.view = view;
|
|
1615
|
+
document.querySelectorAll("#view-mode button").forEach(b =>
|
|
1616
|
+
b.classList.toggle("active", b.dataset.view === view));
|
|
1617
|
+
$("canvas-wrap").classList.toggle("hidden", view === "ide");
|
|
1618
|
+
$("ide").classList.toggle("hidden", view !== "ide");
|
|
1619
|
+
if (view === "ide") {
|
|
1620
|
+
$("detail").classList.add("hidden"); // keep the editor clean
|
|
1621
|
+
if (!S.ideFile) {
|
|
1622
|
+
// open the selected node's file, else show a hint
|
|
1623
|
+
const sel = S.selected && S.nodesById.get(S.selected);
|
|
1624
|
+
const fileId = sel && (sel.kind === "file" ? sel.id : sel.file);
|
|
1625
|
+
if (fileId && S.nodesById.get(fileId)?.source) {
|
|
1626
|
+
openIde(fileId, sel.kind === "file" ? null : sel.id);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
$("ide-path").textContent = "no file open";
|
|
1630
|
+
$("ide-lang").textContent = "";
|
|
1631
|
+
$("ide-outline-list").innerHTML = "";
|
|
1632
|
+
$("ide-code").innerHTML =
|
|
1633
|
+
"<div class='ide-placeholder'>⌗<br>Pick a file in the Explorer" +
|
|
1634
|
+
" on the left,<br>or click a node's “Open full file” button.</div>";
|
|
1635
|
+
}
|
|
1636
|
+
} else if (S.selected) {
|
|
1637
|
+
renderDetail(); // bring the info panel back
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/* ---------------- IDE full-file viewer ---------------- */
|
|
1642
|
+
function openIde(fileId, focusId) {
|
|
1643
|
+
const f = S.nodesById.get(fileId);
|
|
1644
|
+
if (!f || !f.source) return;
|
|
1645
|
+
S.ideFile = fileId;
|
|
1646
|
+
if (S.view !== "ide") setView("ide");
|
|
1647
|
+
$("ide-path").textContent = f.file || f.id;
|
|
1648
|
+
$("ide-lang").textContent = f.language || "";
|
|
1649
|
+
|
|
1650
|
+
const list = $("ide-outline-list");
|
|
1651
|
+
list.innerHTML = "";
|
|
1652
|
+
const fns = (S.fileFns.get(fileId) || []).map(id => S.nodesById.get(id));
|
|
1653
|
+
if (!fns.length)
|
|
1654
|
+
list.innerHTML = "<div class='ol-empty'>No functions in this file.</div>";
|
|
1655
|
+
for (const fn of fns) {
|
|
1656
|
+
const d = document.createElement("div");
|
|
1657
|
+
d.className = "ol-item";
|
|
1658
|
+
d.dataset.id = fn.id;
|
|
1659
|
+
d.title = fn.description || "";
|
|
1660
|
+
d.innerHTML = `<span class="ol-num">${fn.index}</span>` +
|
|
1661
|
+
`<span class="ol-name">${esc(fn.label)}()</span>` +
|
|
1662
|
+
`<span class="ol-line">:${fn.line}</span>`;
|
|
1663
|
+
d.onclick = () => {
|
|
1664
|
+
focusIde(fn);
|
|
1665
|
+
if (S.visible.has(fn.id)) select(fn.id); // sync graph selection
|
|
1666
|
+
};
|
|
1667
|
+
list.appendChild(d);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
$("ide-code").innerHTML = f.source.split("\n").map((line, i) =>
|
|
1671
|
+
`<div class="cl" data-l="${i + 1}"><span class="c-ln">${i + 1}</span>` +
|
|
1672
|
+
`<span class="c-tx">${hlLine(line)}</span></div>`).join("");
|
|
1673
|
+
|
|
1674
|
+
const focusFn = focusId && S.nodesById.get(focusId);
|
|
1675
|
+
if (focusFn) focusIde(focusFn);
|
|
1676
|
+
else $("ide-code").scrollTop = 0;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function focusIde(fn) {
|
|
1680
|
+
const codeEl = $("ide-code");
|
|
1681
|
+
codeEl.querySelectorAll(".cl.focus").forEach(e => e.classList.remove("focus"));
|
|
1682
|
+
const a = fn.line || 1, b = Math.max(fn.endLine || a, a);
|
|
1683
|
+
for (let l = a; l <= b; l++)
|
|
1684
|
+
codeEl.querySelector(`.cl[data-l="${l}"]`)?.classList.add("focus");
|
|
1685
|
+
codeEl.querySelector(`.cl[data-l="${a}"]`)
|
|
1686
|
+
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
1687
|
+
$("ide-outline-list").querySelectorAll(".ol-item").forEach(el =>
|
|
1688
|
+
el.classList.toggle("active", el.dataset.id === fn.id));
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function closeIde() { setView("chart"); }
|
|
1692
|
+
|
|
1693
|
+
function highlightCode(code, startLine) {
|
|
1694
|
+
return code.split("\n").map((line, i) =>
|
|
1695
|
+
`<span class="c-ln">${String(startLine + i).padStart(4, " ")}</span>` +
|
|
1696
|
+
hlLine(line)).join("\n");
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/* ---------------- onboarding ---------------- */
|
|
1700
|
+
function maybeShowOnboarding() {
|
|
1701
|
+
let seen = false;
|
|
1702
|
+
try { seen = localStorage.getItem("codebread_onboarded") === "1"; } catch (e) { /* no storage */ }
|
|
1703
|
+
if (!seen) $("onboarding").classList.remove("hidden");
|
|
1704
|
+
}
|
|
1705
|
+
function dismissOnboarding() {
|
|
1706
|
+
$("onboarding").classList.add("hidden");
|
|
1707
|
+
try { localStorage.setItem("codebread_onboarded", "1"); } catch (e) { /* no storage */ }
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/* ---------------- util ---------------- */
|
|
1711
|
+
function esc(s) {
|
|
1712
|
+
return String(s ?? "").replace(/[&<>"']/g,
|
|
1713
|
+
c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
})();
|