@enigmax/dashboard 0.1.109 → 0.1.110
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/assets/index.html +605 -4
- package/package.json +1 -1
package/assets/index.html
CHANGED
|
@@ -759,6 +759,30 @@
|
|
|
759
759
|
rather than a second label competing with the name. */
|
|
760
760
|
.set-def{opacity:.72;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
761
761
|
.set-ctl .gate-reset{flex:none}
|
|
762
|
+
|
|
763
|
+
/* --- Code graph canvas -------------------------------------------------------
|
|
764
|
+
The picture is drawn on one canvas: at a few hundred nodes an SVG/DOM node per
|
|
765
|
+
symbol costs more in layout than the whole simulation does in maths, and the
|
|
766
|
+
label/zoom rules below need per-frame control the DOM cannot give. */
|
|
767
|
+
.cg-wrap { position: relative; height: 460px; margin-top: 10px; border: 1px solid var(--border); border-radius: 10px; background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.03), transparent 70%), var(--surface2); overflow: hidden; }
|
|
768
|
+
.cg-wrap canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
769
|
+
.cg-wrap canvas.grabbing { cursor: grabbing; }
|
|
770
|
+
.cg-wrap canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
771
|
+
.cg-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 20px; color: var(--muted); font-size: 12.5px; line-height: 1.6; background: rgba(8,10,16,0.55); }
|
|
772
|
+
/* `display: flex` outranks the UA's [hidden] rule, so without this the dismissed overlay
|
|
773
|
+
keeps covering the canvas and swallows every click, drag and wheel event on it. */
|
|
774
|
+
.cg-overlay[hidden], .cg-detail[hidden] { display: none; }
|
|
775
|
+
.cg-zoom { position: absolute; right: 10px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
776
|
+
.cg-zoom .iconbtn { background: rgba(8,10,16,0.72); border: 1px solid var(--border); border-radius: 7px; padding: 5px; }
|
|
777
|
+
.cg-legend { position: absolute; left: 10px; top: 10px; display: flex; flex-wrap: wrap; gap: 4px 10px; max-width: 62%; pointer-events: none; }
|
|
778
|
+
.cg-legend span { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
|
|
779
|
+
.cg-legend i { width: 8px; height: 8px; border-radius: 50%; display: block; }
|
|
780
|
+
.cg-detail { position: absolute; right: 10px; top: 10px; width: min(300px, 62%); max-height: calc(100% - 70px); overflow: auto; background: rgba(8,10,16,0.9); border: 1px solid var(--border); border-radius: 9px; padding: 10px 11px; }
|
|
781
|
+
.cg-detail h4 { margin: 0 6px 2px 0; font-size: 12.5px; font-family: var(--mono); color: var(--text); word-break: break-all; }
|
|
782
|
+
.cg-detail .cg-sig { font-family: var(--mono); font-size: 10.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; margin: 6px 0 0; }
|
|
783
|
+
.cg-detail .cg-where { font-family: var(--mono); font-size: 10.5px; color: var(--accent2); word-break: break-all; }
|
|
784
|
+
.cg-detail .cg-acts { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
|
|
785
|
+
.cg-detail .cg-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
|
|
762
786
|
</style>
|
|
763
787
|
</head>
|
|
764
788
|
<body>
|
|
@@ -1345,6 +1369,36 @@
|
|
|
1345
1369
|
<button id="cgRefresh" type="button" class="iconbtn" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
|
1346
1370
|
</div>
|
|
1347
1371
|
<div id="cgNote" class="set-note"></div>
|
|
1372
|
+
<div class="panel">
|
|
1373
|
+
<div class="set-cat-h">Graph</div>
|
|
1374
|
+
<div class="set-hint" style="padding:4px 0">What depends on what. Click a node for its signature and location, double-click to pull in its neighbours. Empty focus draws the code the rest of the repo leans on most.</div>
|
|
1375
|
+
<div class="ladd" style="flex-wrap:wrap;align-items:center">
|
|
1376
|
+
<input id="cgFocus" class="linput" placeholder="centre on a symbol or file (optional)" style="flex:1;min-width:220px">
|
|
1377
|
+
<select id="cgScope" aria-label="What the nodes are">
|
|
1378
|
+
<option value="symbols">Symbols</option>
|
|
1379
|
+
<option value="files">Files</option>
|
|
1380
|
+
</select>
|
|
1381
|
+
<select id="cgDepth" aria-label="Hops from the focus">
|
|
1382
|
+
<option value="1">1 hop</option>
|
|
1383
|
+
<option value="2">2 hops</option>
|
|
1384
|
+
<option value="3">3 hops</option>
|
|
1385
|
+
<option value="4">4 hops</option>
|
|
1386
|
+
</select>
|
|
1387
|
+
<button id="cgDraw" type="button" class="toggle on">Draw</button>
|
|
1388
|
+
</div>
|
|
1389
|
+
<div id="cgWrap" class="cg-wrap">
|
|
1390
|
+
<canvas id="cgCanvas" tabindex="0" role="img" aria-label="Code graph"></canvas>
|
|
1391
|
+
<div class="cg-legend" id="cgLegend"></div>
|
|
1392
|
+
<div class="cg-zoom">
|
|
1393
|
+
<button id="cgZoomIn" type="button" class="iconbtn" title="Zoom in" aria-label="Zoom in"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
1394
|
+
<button id="cgZoomOut" type="button" class="iconbtn" title="Zoom out" aria-label="Zoom out"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
|
1395
|
+
<button id="cgFit" type="button" class="iconbtn" title="Fit to view" aria-label="Fit to view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg></button>
|
|
1396
|
+
</div>
|
|
1397
|
+
<div id="cgDetail" class="cg-detail" hidden></div>
|
|
1398
|
+
<div id="cgGraphOverlay" class="cg-overlay">Drawing...</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
<div id="cgGraphMeta" class="set-hint" style="padding:6px 0 0"></div>
|
|
1401
|
+
</div>
|
|
1348
1402
|
<div class="panel">
|
|
1349
1403
|
<div class="set-cat-h">Index a project</div>
|
|
1350
1404
|
<div class="set-hint" style="padding:4px 0">Build the graph for a codebase. Leave the path empty to index enigma's own working directory.</div>
|
|
@@ -2481,7 +2535,7 @@ export {
|
|
|
2481
2535
|
"cli": "CLI (enigma compress)", "unknown": "Unattributed",
|
|
2482
2536
|
};
|
|
2483
2537
|
function prettySource(k) { return SOURCE_LABELS[k] || String(k).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
2484
|
-
const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", unknown: "Other" };
|
|
2538
|
+
const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", shell: "Command output", unknown: "Other" };
|
|
2485
2539
|
function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
2486
2540
|
|
|
2487
2541
|
// One bar per key (source or type), sorted by tokens saved, with an optional $ column.
|
|
@@ -5578,6 +5632,539 @@ export {
|
|
|
5578
5632
|
+ entries.map(([k, v]) => '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(k) + '</div><div class="set-hint">' + cgVal(v) + "</div></div></div>").join("")
|
|
5579
5633
|
+ "</div>";
|
|
5580
5634
|
}
|
|
5635
|
+
// --- Code graph canvas ------------------------------------------------------------
|
|
5636
|
+
// A force-directed picture of the slice the server cut. Everything is hand-rolled on one
|
|
5637
|
+
// canvas: no graph library, no CDN - the dashboard ships as a single offline file, and at
|
|
5638
|
+
// a few hundred nodes the whole simulation costs less than a DOM node per symbol would.
|
|
5639
|
+
// Colour says WHERE a node lives, shape says WHAT it is (a file is a rounded square, a
|
|
5640
|
+
// symbol a circle). Colouring by kind instead made the file view - where every node is a
|
|
5641
|
+
// file - a single grey cloud, and told you nothing you could not read off the shape.
|
|
5642
|
+
const CG_GROUP_COLOR = ["#e0a458", "#5aa9e6", "#6fcf97", "#a78bfa", "#d7875f", "#56c8c8", "#e3c14e", "#c586c0", "#7f9cf5", "#8a93a3"];
|
|
5643
|
+
const CG_UNGROUPED = "#8a93a3";
|
|
5644
|
+
const CG_REL_COLOR = {
|
|
5645
|
+
imports: "rgba(138,147,163,0.55)", calls: "rgba(224,164,88,0.55)",
|
|
5646
|
+
references: "rgba(138,147,163,0.30)", extends: "rgba(111,207,151,0.55)",
|
|
5647
|
+
implements: "rgba(90,169,230,0.55)", contains: "rgba(138,147,163,0.20)"
|
|
5648
|
+
};
|
|
5649
|
+
// Tuned together: weaker links or a longer cutoff and dense clusters never separate.
|
|
5650
|
+
const CG_LINK_LEN = 82, CG_LINK_K = 0.22, CG_CHARGE = 4200, CG_CUTOFF = 260, CG_GRAVITY = 0.05, CG_DAMP = 0.74;
|
|
5651
|
+
const CG_ALPHA_DECAY = 0.021, CG_ALPHA_MIN = 0.004, CG_MAX_TICKS_PER_FRAME = 3, CG_SEPARATION = 7;
|
|
5652
|
+
const cg = {
|
|
5653
|
+
nodes: [], byId: new Map(), edges: [], meta: null,
|
|
5654
|
+
alpha: 0, raf: 0, seq: 0,
|
|
5655
|
+
zoom: 1, tx: 0, ty: 0,
|
|
5656
|
+
hover: null, selected: null, drag: null, pan: null, moved: false,
|
|
5657
|
+
groupColor: new Map(), groupCount: new Map()
|
|
5658
|
+
};
|
|
5659
|
+
const cgColor = (group) => cg.groupColor.get(group) || CG_UNGROUPED;
|
|
5660
|
+
/**
|
|
5661
|
+
* Group every node by the first directory segment that actually separates them: the paths
|
|
5662
|
+
* in a slice usually share a long prefix (a whole slice can sit under one package), and
|
|
5663
|
+
* grouping on the raw first segment would put every node in the same bucket.
|
|
5664
|
+
*/
|
|
5665
|
+
function cgRegroup() {
|
|
5666
|
+
const paths = cg.nodes.map((n) => n.path || "");
|
|
5667
|
+
let prefix = "";
|
|
5668
|
+
if (paths.length > 1) {
|
|
5669
|
+
const parts = paths[0].split("/").slice(0, -1);
|
|
5670
|
+
let common = parts.length;
|
|
5671
|
+
for (const path of paths) {
|
|
5672
|
+
const p = path.split("/");
|
|
5673
|
+
let i = 0;
|
|
5674
|
+
while (i < common && i < p.length - 1 && p[i] === parts[i]) i++;
|
|
5675
|
+
common = i;
|
|
5676
|
+
if (!common) break;
|
|
5677
|
+
}
|
|
5678
|
+
prefix = parts.slice(0, common).join("/");
|
|
5679
|
+
}
|
|
5680
|
+
const counts = new Map();
|
|
5681
|
+
for (const n of cg.nodes) {
|
|
5682
|
+
const rest = prefix && n.path.startsWith(prefix + "/") ? n.path.slice(prefix.length + 1) : n.path;
|
|
5683
|
+
const cut = rest.indexOf("/");
|
|
5684
|
+
// Files sitting directly in the shared prefix have no segment of their own; "."
|
|
5685
|
+
// reads as a path fragment in the legend, where the label has to name a place.
|
|
5686
|
+
n.group = cut === -1 ? "(root)" : rest.slice(0, cut);
|
|
5687
|
+
counts.set(n.group, (counts.get(n.group) || 0) + 1);
|
|
5688
|
+
}
|
|
5689
|
+
// The biggest groups take the most distinct colours; everything past the palette
|
|
5690
|
+
// shares the neutral one rather than repeating a colour that already means something.
|
|
5691
|
+
cg.groupColor = new Map([...counts.entries()]
|
|
5692
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
5693
|
+
.slice(0, CG_GROUP_COLOR.length - 1)
|
|
5694
|
+
.map(([name], i) => [name, CG_GROUP_COLOR[i]]));
|
|
5695
|
+
cg.groupCount = counts;
|
|
5696
|
+
}
|
|
5697
|
+
// In WORLD units, so the separation pass in cgTick can guarantee the drawn circles never
|
|
5698
|
+
// overlap: a radius measured in screen pixels is a size the simulation cannot see, and at
|
|
5699
|
+
// any zoom below 1 the dots grow past the spacing the layout worked out for them.
|
|
5700
|
+
const cgRadius = (n) => 4 + Math.min(8, Math.sqrt(n.inDegree || 0) * 2.2);
|
|
5701
|
+
// Labels declutter by zoom: far out only the hubs are named, and every step in lowers the
|
|
5702
|
+
// bar. Without it a 300-node slice is a wall of overlapping text at every scale - but the
|
|
5703
|
+
// bar is set by how crowded the picture actually is, not by the zoom alone, or a slice of
|
|
5704
|
+
// fifteen nodes hides most of its own names for no reason.
|
|
5705
|
+
const cgLabelFloor = (z, count) => {
|
|
5706
|
+
if (count <= 40) return z >= 0.45 ? 0 : 4;
|
|
5707
|
+
if (count <= 120) return z >= 1 ? 0 : z >= 0.7 ? 3 : 9;
|
|
5708
|
+
return z >= 2.6 ? 0 : z >= 1.8 ? 2 : z >= 1.2 ? 5 : z >= 0.8 ? 10 : 22;
|
|
5709
|
+
};
|
|
5710
|
+
|
|
5711
|
+
function cgCanvasSize() {
|
|
5712
|
+
const c = $("cgCanvas");
|
|
5713
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
5714
|
+
// Bail when unchanged: a ResizeObserver fires for subpixel and browser-zoom rounding
|
|
5715
|
+
// too, and resizing the canvas every time clears it and can feed back into a shake.
|
|
5716
|
+
const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr);
|
|
5717
|
+
if (w > 0 && h > 0 && (c.width !== w || c.height !== h)) { c.width = w; c.height = h; }
|
|
5718
|
+
return { c, dpr, w: c.clientWidth, h: c.clientHeight };
|
|
5719
|
+
}
|
|
5720
|
+
const cgToWorld = (px, py, box) => ({ x: (px - box.w / 2 - cg.tx) / cg.zoom, y: (py - box.h / 2 - cg.ty) / cg.zoom });
|
|
5721
|
+
|
|
5722
|
+
function cgDraw() {
|
|
5723
|
+
const box = cgCanvasSize();
|
|
5724
|
+
const ctx = box.c.getContext("2d");
|
|
5725
|
+
ctx.setTransform(box.dpr, 0, 0, box.dpr, 0, 0);
|
|
5726
|
+
ctx.clearRect(0, 0, box.w, box.h);
|
|
5727
|
+
ctx.save();
|
|
5728
|
+
ctx.translate(box.w / 2 + cg.tx, box.h / 2 + cg.ty);
|
|
5729
|
+
ctx.scale(cg.zoom, cg.zoom);
|
|
5730
|
+
const focusId = (cg.selected && cg.selected.id) || (cg.hover && cg.hover.id) || null;
|
|
5731
|
+
const lit = new Set();
|
|
5732
|
+
if (focusId) for (const e of cg.edges) { if (e.source === focusId) lit.add(e.target); else if (e.target === focusId) lit.add(e.source); }
|
|
5733
|
+
ctx.lineWidth = 1 / cg.zoom;
|
|
5734
|
+
for (const e of cg.edges) {
|
|
5735
|
+
const s = e.s, t = e.t;
|
|
5736
|
+
if (!s || !t) continue;
|
|
5737
|
+
const on = focusId && (e.source === focusId || e.target === focusId);
|
|
5738
|
+
ctx.strokeStyle = on ? "rgba(224,164,88,0.85)" : (focusId ? "rgba(138,147,163,0.13)" : (CG_REL_COLOR[e.relation] || CG_REL_COLOR.references));
|
|
5739
|
+
ctx.beginPath();
|
|
5740
|
+
ctx.moveTo(s.x, s.y);
|
|
5741
|
+
ctx.lineTo(t.x, t.y);
|
|
5742
|
+
ctx.stroke();
|
|
5743
|
+
}
|
|
5744
|
+
const z = cg.zoom;
|
|
5745
|
+
const floor = cgLabelFloor(z, cg.nodes.length);
|
|
5746
|
+
const seeded = !!(cg.meta && cg.meta.focus && cg.meta.focus.length);
|
|
5747
|
+
const dimmed = (n) => focusId && n.id !== focusId && !lit.has(n.id);
|
|
5748
|
+
for (const n of cg.nodes) {
|
|
5749
|
+
const r = cgRadius(n);
|
|
5750
|
+
ctx.globalAlpha = dimmed(n) ? 0.28 : 1;
|
|
5751
|
+
ctx.beginPath();
|
|
5752
|
+
if (n.kind === "file") ctx.roundRect(n.x - r, n.y - r, r * 2, r * 2, r * 0.35);
|
|
5753
|
+
else ctx.arc(n.x, n.y, r, 0, 6.283185);
|
|
5754
|
+
ctx.fillStyle = cgColor(n.group);
|
|
5755
|
+
ctx.fill();
|
|
5756
|
+
if (n.id === focusId) { ctx.strokeStyle = "#eceef2"; ctx.lineWidth = 2 / z; ctx.stroke(); }
|
|
5757
|
+
else if (n.depth === 0 && seeded) { ctx.strokeStyle = "rgba(236,238,242,0.55)"; ctx.lineWidth = 1.5 / z; ctx.stroke(); }
|
|
5758
|
+
ctx.globalAlpha = 1;
|
|
5759
|
+
}
|
|
5760
|
+
// Labels in a second pass so a node drawn later cannot cover the caption of one drawn
|
|
5761
|
+
// earlier - the single most common way a readable graph turns into half-words.
|
|
5762
|
+
ctx.textBaseline = "middle";
|
|
5763
|
+
ctx.font = (11 / z) + "px ui-monospace, Menlo, Consolas, monospace";
|
|
5764
|
+
for (const n of cg.nodes) {
|
|
5765
|
+
if (dimmed(n) || (n.id !== focusId && (n.inDegree || 0) < floor)) continue;
|
|
5766
|
+
const r = cgRadius(n);
|
|
5767
|
+
ctx.textAlign = "left";
|
|
5768
|
+
const tw = ctx.measureText(n.name).width;
|
|
5769
|
+
ctx.fillStyle = "rgba(8,10,16,0.78)";
|
|
5770
|
+
ctx.fillRect(n.x + r + 3 / z, n.y - 7 / z, tw + 5 / z, 14 / z);
|
|
5771
|
+
ctx.fillStyle = n.id === focusId ? "#eceef2" : "#c3cad6";
|
|
5772
|
+
ctx.fillText(n.name, n.x + r + 5 / z, n.y);
|
|
5773
|
+
}
|
|
5774
|
+
// The "expand me" badge sits above both, or a neighbouring node hides the one cue that
|
|
5775
|
+
// says this node is standing in front of more of the graph.
|
|
5776
|
+
for (const n of cg.nodes) {
|
|
5777
|
+
if (!n.hidden || z < 1.1 || dimmed(n)) continue;
|
|
5778
|
+
const r = cgRadius(n);
|
|
5779
|
+
const bx = n.x + r * 0.78, by = n.y - r * 0.78;
|
|
5780
|
+
ctx.beginPath();
|
|
5781
|
+
ctx.arc(bx, by, 5.5 / z, 0, 6.283185);
|
|
5782
|
+
ctx.fillStyle = "rgba(8,10,16,0.9)";
|
|
5783
|
+
ctx.fill();
|
|
5784
|
+
ctx.fillStyle = "#8a93a3";
|
|
5785
|
+
ctx.font = (9 / z) + "px ui-monospace, Menlo, Consolas, monospace";
|
|
5786
|
+
ctx.textAlign = "center";
|
|
5787
|
+
ctx.fillText("+", bx, by);
|
|
5788
|
+
ctx.font = (11 / z) + "px ui-monospace, Menlo, Consolas, monospace";
|
|
5789
|
+
}
|
|
5790
|
+
ctx.restore();
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5793
|
+
function cgTick() {
|
|
5794
|
+
const nodes = cg.nodes;
|
|
5795
|
+
if (!nodes.length) return;
|
|
5796
|
+
const a = cg.alpha;
|
|
5797
|
+
for (const e of cg.edges) {
|
|
5798
|
+
const s = e.s, t = e.t;
|
|
5799
|
+
if (!s || !t || s === t) continue;
|
|
5800
|
+
const dx = t.x - s.x, dy = t.y - s.y;
|
|
5801
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
5802
|
+
const f = ((d - CG_LINK_LEN) / d) * CG_LINK_K * a;
|
|
5803
|
+
s.vx += dx * f; s.vy += dy * f;
|
|
5804
|
+
t.vx -= dx * f; t.vy -= dy * f;
|
|
5805
|
+
}
|
|
5806
|
+
// Repulsion over a uniform grid, not every pair: at the 2000-node cap the all-pairs
|
|
5807
|
+
// version is four million distance checks a tick and the panel stops responding.
|
|
5808
|
+
const grid = new Map();
|
|
5809
|
+
for (const n of nodes) {
|
|
5810
|
+
const k = ((n.x / CG_CUTOFF) | 0) + ":" + ((n.y / CG_CUTOFF) | 0);
|
|
5811
|
+
const arr = grid.get(k);
|
|
5812
|
+
if (arr) arr.push(n); else grid.set(k, [n]);
|
|
5813
|
+
}
|
|
5814
|
+
for (const n of nodes) {
|
|
5815
|
+
const cx = (n.x / CG_CUTOFF) | 0, cy = (n.y / CG_CUTOFF) | 0;
|
|
5816
|
+
const rn = cgRadius(n);
|
|
5817
|
+
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
|
5818
|
+
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
|
5819
|
+
const arr = grid.get(gx + ":" + gy);
|
|
5820
|
+
if (!arr) continue;
|
|
5821
|
+
for (const o of arr) {
|
|
5822
|
+
if (o === n) continue;
|
|
5823
|
+
const dx = n.x - o.x, dy = n.y - o.y;
|
|
5824
|
+
const d2 = Math.max(1, dx * dx + dy * dy);
|
|
5825
|
+
if (d2 > CG_CUTOFF * CG_CUTOFF) continue;
|
|
5826
|
+
const d = Math.sqrt(d2);
|
|
5827
|
+
n.vx += (dx * CG_CHARGE * a) / (d2 * d);
|
|
5828
|
+
n.vy += (dy * CG_CHARGE * a) / (d2 * d);
|
|
5829
|
+
// Separation, applied to the POSITION and not the velocity: charge
|
|
5830
|
+
// alone loses to a node's own springs once it has a dozen neighbours,
|
|
5831
|
+
// and the result is the hairball where every hub sits under its
|
|
5832
|
+
// callers. This is the only thing that guarantees drawn circles do
|
|
5833
|
+
// not overlap, so it runs regardless of how cold the layout is.
|
|
5834
|
+
const minD = rn + cgRadius(o) + CG_SEPARATION;
|
|
5835
|
+
if (d >= minD) continue;
|
|
5836
|
+
const push = ((minD - d) / d) * 0.5;
|
|
5837
|
+
if (!n.pinned) { n.x += dx * push; n.y += dy * push; }
|
|
5838
|
+
if (!o.pinned) { o.x -= dx * push; o.y -= dy * push; }
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5842
|
+
}
|
|
5843
|
+
for (const n of nodes) {
|
|
5844
|
+
if (n.pinned) { n.vx = 0; n.vy = 0; continue; }
|
|
5845
|
+
n.vx -= n.x * CG_GRAVITY * a;
|
|
5846
|
+
n.vy -= n.y * CG_GRAVITY * a;
|
|
5847
|
+
n.vx *= CG_DAMP; n.vy *= CG_DAMP;
|
|
5848
|
+
// Clamped so a node that lands on top of another cannot be flung off the canvas
|
|
5849
|
+
// by the inverse-square term and take the layout's whole scale with it.
|
|
5850
|
+
n.x += Math.max(-40, Math.min(40, n.vx));
|
|
5851
|
+
n.y += Math.max(-40, Math.min(40, n.vy));
|
|
5852
|
+
}
|
|
5853
|
+
cg.alpha -= cg.alpha * CG_ALPHA_DECAY;
|
|
5854
|
+
if (cg.alpha < CG_ALPHA_MIN) cg.alpha = 0;
|
|
5855
|
+
}
|
|
5856
|
+
|
|
5857
|
+
function cgLoop() {
|
|
5858
|
+
cg.raf = 0;
|
|
5859
|
+
// A hidden tab must not keep a simulation running: the loop stops, and anything that
|
|
5860
|
+
// matters re-enters through cgKick when the tab comes back.
|
|
5861
|
+
if ($("view-codegraph").style.display === "none") { cg.alpha = 0; return; }
|
|
5862
|
+
for (let i = 0; i < CG_MAX_TICKS_PER_FRAME && cg.alpha > 0; i++) cgTick();
|
|
5863
|
+
cgDraw();
|
|
5864
|
+
if (cg.alpha > 0 || cg.drag) cg.raf = requestAnimationFrame(cgLoop);
|
|
5865
|
+
}
|
|
5866
|
+
function cgKick(alpha) {
|
|
5867
|
+
if (typeof alpha === "number") cg.alpha = Math.max(cg.alpha, alpha);
|
|
5868
|
+
if (!cg.raf) cg.raf = requestAnimationFrame(cgLoop);
|
|
5869
|
+
}
|
|
5870
|
+
|
|
5871
|
+
function cgFit() {
|
|
5872
|
+
if (!cg.nodes.length) return;
|
|
5873
|
+
const box = cgCanvasSize();
|
|
5874
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
5875
|
+
for (const n of cg.nodes) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x); y1 = Math.max(y1, n.y); }
|
|
5876
|
+
const bw = Math.max(1, x1 - x0), bh = Math.max(1, y1 - y0);
|
|
5877
|
+
cg.zoom = Math.max(0.12, Math.min(1.6, Math.min((box.w - 90) / bw, (box.h - 70) / bh)));
|
|
5878
|
+
cg.tx = -((x0 + x1) / 2) * cg.zoom;
|
|
5879
|
+
cg.ty = -((y0 + y1) / 2) * cg.zoom;
|
|
5880
|
+
cgKick(0);
|
|
5881
|
+
}
|
|
5882
|
+
function cgZoomBy(factor, at) {
|
|
5883
|
+
const box = cgCanvasSize();
|
|
5884
|
+
const next = Math.max(0.08, Math.min(8, cg.zoom * factor));
|
|
5885
|
+
const px = at ? at.x : box.w / 2, py = at ? at.y : box.h / 2;
|
|
5886
|
+
// Anchor the point under the cursor: without it wheel-zoom drifts the graph away from
|
|
5887
|
+
// whatever the user was pointing at.
|
|
5888
|
+
const w = cgToWorld(px, py, box);
|
|
5889
|
+
cg.zoom = next;
|
|
5890
|
+
cg.tx = px - box.w / 2 - w.x * next;
|
|
5891
|
+
cg.ty = py - box.h / 2 - w.y * next;
|
|
5892
|
+
cgKick(0);
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
function cgNodeAt(px, py) {
|
|
5896
|
+
const box = cgCanvasSize();
|
|
5897
|
+
const w = cgToWorld(px, py, box);
|
|
5898
|
+
let best = null, bestD = Infinity;
|
|
5899
|
+
for (const n of cg.nodes) {
|
|
5900
|
+
const r = cgRadius(n) + 4 / cg.zoom;
|
|
5901
|
+
const d = (n.x - w.x) * (n.x - w.x) + (n.y - w.y) * (n.y - w.y);
|
|
5902
|
+
if (d <= r * r && d < bestD) { best = n; bestD = d; }
|
|
5903
|
+
}
|
|
5904
|
+
return best;
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5907
|
+
function cgRenderLegend() {
|
|
5908
|
+
const rest = cg.groupCount.size - cg.groupColor.size;
|
|
5909
|
+
$("cgLegend").innerHTML = [...cg.groupColor.keys()]
|
|
5910
|
+
.map((g) => '<span><i style="background:' + cgColor(g) + '"></i>' + esc(g) + "</span>").join("")
|
|
5911
|
+
+ (rest > 0 ? '<span><i style="background:' + CG_UNGROUPED + '"></i>' + rest + " more</span>" : "");
|
|
5912
|
+
}
|
|
5913
|
+
function cgRenderDetail() {
|
|
5914
|
+
const el = $("cgDetail");
|
|
5915
|
+
const n = cg.selected;
|
|
5916
|
+
if (!n) { el.hidden = true; el.innerHTML = ""; return; }
|
|
5917
|
+
const where = n.kind === "file" ? n.path : n.path + ":" + n.line;
|
|
5918
|
+
el.hidden = false;
|
|
5919
|
+
el.innerHTML = '<div class="cg-head"><h4>' + esc(n.name) + "</h4>"
|
|
5920
|
+
+ '<button type="button" class="iconbtn" data-cg-close title="Close" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>'
|
|
5921
|
+
+ '<div class="cg-where">' + esc(where) + "</div>"
|
|
5922
|
+
+ '<div class="set-hint" style="padding:4px 0 0">' + esc(n.kind) + " - " + n.inDegree + " file" + (n.inDegree === 1 ? "" : "s") + " depend on it"
|
|
5923
|
+
+ (n.hidden > 0 ? " - " + n.hidden + " neighbour" + (n.hidden === 1 ? "" : "s") + " not shown" : "") + "</div>"
|
|
5924
|
+
+ (n.signature ? '<pre class="cg-sig">' + esc(n.signature.slice(0, 400)) + "</pre>" : "")
|
|
5925
|
+
+ '<div class="cg-acts">'
|
|
5926
|
+
+ '<button type="button" class="toggle on" data-cg-focus="' + esc(n.id) + '">Centre here</button>'
|
|
5927
|
+
+ (n.hidden > 0 ? '<button type="button" class="toggle" data-cg-expand="' + esc(n.id) + '">Expand</button>' : "")
|
|
5928
|
+
+ '<button type="button" class="iconbtn" data-cg-copy="' + esc(where) + '" title="Copy location" aria-label="Copy location"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
|
|
5929
|
+
+ "</div>";
|
|
5930
|
+
}
|
|
5931
|
+
function cgRenderMeta() {
|
|
5932
|
+
const m = cg.meta;
|
|
5933
|
+
if (!m) { $("cgGraphMeta").textContent = ""; return; }
|
|
5934
|
+
// A real but tiny share rounds to "0%", which reads as "nothing" - the one thing it
|
|
5935
|
+
// is not - so it is reported as "<1%" instead. Measured against the population this
|
|
5936
|
+
// scope draws from: the file view carries import edges only.
|
|
5937
|
+
const totals = m.totals || {};
|
|
5938
|
+
const total = (m.scope === "files" ? totals.importEdges : totals.edges) || 0;
|
|
5939
|
+
const pct = total && cg.edges.length ? (cg.edges.length / total) * 100 : 0;
|
|
5940
|
+
const share = !pct ? "0%" : pct < 1 ? "<1%" : Math.round(pct) + "%";
|
|
5941
|
+
const bits = [cg.nodes.length + " nodes", cg.edges.length + " edges", share + " of the graph's wiring"];
|
|
5942
|
+
if (m.focus && m.focus.length) bits.push("centred on " + m.focus.map((f) => f.name).join(", "));
|
|
5943
|
+
// Both caps are stated: one hid part of the picture, the other means part of the tree
|
|
5944
|
+
// was never indexed - a drawn graph reads as complete unless it says otherwise.
|
|
5945
|
+
if (m.truncated) bits.push("truncated at the node cap");
|
|
5946
|
+
if (m.incomplete) bits.push("index incomplete: the scan hit a limit");
|
|
5947
|
+
$("cgGraphMeta").textContent = bits.join(" - ");
|
|
5948
|
+
$("cgCanvas").setAttribute("aria-label",
|
|
5949
|
+
"Code graph: " + cg.nodes.length + " nodes and " + cg.edges.length + " edges"
|
|
5950
|
+
+ (m.focus && m.focus.length ? " around " + m.focus.map((f) => f.name).join(", ") : ""));
|
|
5951
|
+
}
|
|
5952
|
+
function cgOverlay(msg) {
|
|
5953
|
+
const el = $("cgGraphOverlay");
|
|
5954
|
+
if (!msg) { el.hidden = true; return; }
|
|
5955
|
+
el.hidden = false;
|
|
5956
|
+
el.textContent = msg;
|
|
5957
|
+
}
|
|
5958
|
+
|
|
5959
|
+
/** Seed a node deterministically: phyllotaxis, or beside the node it was expanded from. */
|
|
5960
|
+
function cgPlace(node, index, near) {
|
|
5961
|
+
const th = index * 2.399963;
|
|
5962
|
+
if (near) {
|
|
5963
|
+
node.x = near.x + Math.cos(th) * (30 + index * 1.5);
|
|
5964
|
+
node.y = near.y + Math.sin(th) * (30 + index * 1.5);
|
|
5965
|
+
} else {
|
|
5966
|
+
const r = 12 * Math.sqrt(index + 1);
|
|
5967
|
+
node.x = Math.cos(th) * r;
|
|
5968
|
+
node.y = Math.sin(th) * r;
|
|
5969
|
+
}
|
|
5970
|
+
node.vx = 0;
|
|
5971
|
+
node.vy = 0;
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5974
|
+
/**
|
|
5975
|
+
* Recount, per node, the neighbours NOT on screen. The server's `hidden` is relative to
|
|
5976
|
+
* the slice it came from, so after a merge it would claim neighbours already drawn beside
|
|
5977
|
+
* it - a "+" badge and an Expand button that add nothing. `neighbours` is the whole-graph
|
|
5978
|
+
* total, so the honest count is that minus what the merged picture actually draws.
|
|
5979
|
+
*
|
|
5980
|
+
* Same asymmetry the engine counts by: expanding a node re-seeds the symbols a file
|
|
5981
|
+
* contains, but a symbol's own file is never reachable across a `contains` edge.
|
|
5982
|
+
*/
|
|
5983
|
+
function cgRecount() {
|
|
5984
|
+
const drawn = new Map();
|
|
5985
|
+
const bump = (id) => drawn.set(id, (drawn.get(id) || 0) + 1);
|
|
5986
|
+
for (const e of cg.edges) {
|
|
5987
|
+
if (cg.byId.has(e.source)) bump(e.source);
|
|
5988
|
+
if (cg.byId.has(e.target) && e.relation !== "contains") bump(e.target);
|
|
5989
|
+
}
|
|
5990
|
+
for (const n of cg.nodes) n.hidden = Math.max(0, (n.neighbours || 0) - (drawn.get(n.id) || 0));
|
|
5991
|
+
}
|
|
5992
|
+
|
|
5993
|
+
function cgApply(slice, merge, near) {
|
|
5994
|
+
const before = cg.nodes.length;
|
|
5995
|
+
if (!merge) { cg.nodes = []; cg.byId = new Map(); cg.edges = []; cg.selected = null; cg.hover = null; }
|
|
5996
|
+
let added = 0;
|
|
5997
|
+
for (const raw of slice.nodes) {
|
|
5998
|
+
const seen = cg.byId.get(raw.id);
|
|
5999
|
+
// Keep the live object on a merge: replacing it would reset its position, and every
|
|
6000
|
+
// expand would look like the whole layout jumped.
|
|
6001
|
+
if (seen) { seen.inDegree = raw.inDegree; seen.neighbours = raw.neighbours; continue; }
|
|
6002
|
+
const n = Object.assign({}, raw);
|
|
6003
|
+
cgPlace(n, merge ? added : cg.nodes.length, near);
|
|
6004
|
+
cg.byId.set(n.id, n);
|
|
6005
|
+
cg.nodes.push(n);
|
|
6006
|
+
added++;
|
|
6007
|
+
}
|
|
6008
|
+
const key = (e) => e.source + "" + e.target + "" + e.relation;
|
|
6009
|
+
const have = new Set(cg.edges.map(key));
|
|
6010
|
+
for (const e of slice.edges) {
|
|
6011
|
+
if (have.has(key(e))) continue;
|
|
6012
|
+
have.add(key(e));
|
|
6013
|
+
cg.edges.push({ source: e.source, target: e.target, relation: e.relation });
|
|
6014
|
+
}
|
|
6015
|
+
for (const e of cg.edges) { e.s = cg.byId.get(e.source); e.t = cg.byId.get(e.target); }
|
|
6016
|
+
cgRecount();
|
|
6017
|
+
// An expand widens the picture; it does not re-answer what the picture is OF. Taking
|
|
6018
|
+
// the sub-slice's meta would relabel the whole canvas with the expanded node's focus
|
|
6019
|
+
// and report its cap instead of the one the drawn graph was cut at.
|
|
6020
|
+
cg.meta = merge && cg.meta
|
|
6021
|
+
? Object.assign({}, cg.meta, { truncated: cg.meta.truncated || slice.truncated })
|
|
6022
|
+
: slice;
|
|
6023
|
+
cgRegroup();
|
|
6024
|
+
cgRenderLegend();
|
|
6025
|
+
cgRenderMeta();
|
|
6026
|
+
cgRenderDetail();
|
|
6027
|
+
cgKick(merge ? 0.55 : 1);
|
|
6028
|
+
if (!merge || before === 0) setTimeout(cgFit, 420);
|
|
6029
|
+
return added;
|
|
6030
|
+
}
|
|
6031
|
+
|
|
6032
|
+
async function cgFetchGraph(payload) {
|
|
6033
|
+
try {
|
|
6034
|
+
const res = await fetch("/api/codegraph", {
|
|
6035
|
+
method: "POST",
|
|
6036
|
+
headers: { "Content-Type": "application/json" },
|
|
6037
|
+
body: JSON.stringify(Object.assign({ op: "graph", project: cgProject || undefined }, payload))
|
|
6038
|
+
});
|
|
6039
|
+
return await res.json();
|
|
6040
|
+
} catch { return { ok: false, error: "Could not reach the server." }; }
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
async function cgDrawGraph(opts) {
|
|
6044
|
+
const o = opts || {};
|
|
6045
|
+
const seq = ++cg.seq;
|
|
6046
|
+
if (!o.merge) cgOverlay("Drawing...");
|
|
6047
|
+
const out = await cgFetchGraph({
|
|
6048
|
+
focus: o.focus || undefined,
|
|
6049
|
+
depth: Number($("cgDepth").value) || 1,
|
|
6050
|
+
scope: $("cgScope").value,
|
|
6051
|
+
// Deliberately below the engine's cap: past roughly this many nodes an overview
|
|
6052
|
+
// stops being a picture you can read and becomes one you can only zoom into, and
|
|
6053
|
+
// the meta line says when the cap cut something off.
|
|
6054
|
+
limit: 200
|
|
6055
|
+
});
|
|
6056
|
+
// A slower earlier request must not overwrite a newer answer: clicking through three
|
|
6057
|
+
// nodes quickly would otherwise leave whichever call happened to finish last.
|
|
6058
|
+
if (seq !== cg.seq) return;
|
|
6059
|
+
// The legend goes with the nodes: leaving the previous slice's colour key over an
|
|
6060
|
+
// empty canvas describes a picture that is no longer on it.
|
|
6061
|
+
const reset = () => {
|
|
6062
|
+
cg.nodes = []; cg.edges = []; cg.byId = new Map(); cg.selected = null;
|
|
6063
|
+
cgRegroup(); cgRenderLegend(); cgRenderDetail(); cgDraw();
|
|
6064
|
+
};
|
|
6065
|
+
// The overlay covers the whole canvas, so it belongs only to a draw that has nothing
|
|
6066
|
+
// to show. An expand that failed leaves a perfectly good graph on screen: veiling it
|
|
6067
|
+
// would swallow every click on it until the next Draw, so that one reports as a toast.
|
|
6068
|
+
if (!out || !out.ok || !out.graph) {
|
|
6069
|
+
const why = (out && out.error) || "Could not draw the graph.";
|
|
6070
|
+
if (o.merge) { toast.error("Could not expand that node", { description: why }); return; }
|
|
6071
|
+
reset();
|
|
6072
|
+
cg.meta = null;
|
|
6073
|
+
cgRenderMeta();
|
|
6074
|
+
cgOverlay(why);
|
|
6075
|
+
return;
|
|
6076
|
+
}
|
|
6077
|
+
const slice = out.graph;
|
|
6078
|
+
if (!slice.nodes.length) {
|
|
6079
|
+
const why = slice.note || "Nothing to draw for this project yet.";
|
|
6080
|
+
if (o.merge) { toast.info("Nothing to add", { description: why }); return; }
|
|
6081
|
+
reset();
|
|
6082
|
+
cg.meta = slice;
|
|
6083
|
+
cgRenderMeta();
|
|
6084
|
+
cgOverlay(why);
|
|
6085
|
+
return;
|
|
6086
|
+
}
|
|
6087
|
+
cgOverlay("");
|
|
6088
|
+
const added = cgApply(slice, !!o.merge, o.merge ? cg.byId.get(o.focus) : null);
|
|
6089
|
+
if (o.merge && !added) toast.info("Nothing to add", { description: "Every neighbour of that node is already drawn." });
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
function cgWireCanvas() {
|
|
6093
|
+
const c = $("cgCanvas");
|
|
6094
|
+
const local = (e) => { const r = c.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
|
|
6095
|
+
c.addEventListener("pointerdown", (e) => {
|
|
6096
|
+
const p = local(e);
|
|
6097
|
+
const hit = cgNodeAt(p.x, p.y);
|
|
6098
|
+
cg.moved = false;
|
|
6099
|
+
c.setPointerCapture(e.pointerId);
|
|
6100
|
+
if (hit) { cg.drag = { node: hit, wasPinned: hit.pinned }; hit.pinned = true; }
|
|
6101
|
+
else { cg.pan = { x: p.x - cg.tx, y: p.y - cg.ty }; c.classList.add("grabbing"); }
|
|
6102
|
+
});
|
|
6103
|
+
c.addEventListener("pointermove", (e) => {
|
|
6104
|
+
const p = local(e);
|
|
6105
|
+
if (cg.drag) {
|
|
6106
|
+
const w = cgToWorld(p.x, p.y, cgCanvasSize());
|
|
6107
|
+
cg.drag.node.x = w.x;
|
|
6108
|
+
cg.drag.node.y = w.y;
|
|
6109
|
+
cg.moved = true;
|
|
6110
|
+
cgKick(0.28);
|
|
6111
|
+
return;
|
|
6112
|
+
}
|
|
6113
|
+
if (cg.pan) { cg.tx = p.x - cg.pan.x; cg.ty = p.y - cg.pan.y; cg.moved = true; cgKick(0); return; }
|
|
6114
|
+
const hit = cgNodeAt(p.x, p.y);
|
|
6115
|
+
if (hit !== cg.hover) { cg.hover = hit; c.style.cursor = hit ? "pointer" : ""; cgKick(0); }
|
|
6116
|
+
});
|
|
6117
|
+
const release = (e) => {
|
|
6118
|
+
if (cg.drag) { if (!cg.moved) cg.drag.node.pinned = cg.drag.wasPinned; cg.drag = null; }
|
|
6119
|
+
if (cg.pan) { cg.pan = null; c.classList.remove("grabbing"); }
|
|
6120
|
+
if (!cg.moved && e) {
|
|
6121
|
+
const p = local(e);
|
|
6122
|
+
cg.selected = cgNodeAt(p.x, p.y) || null;
|
|
6123
|
+
cgRenderDetail();
|
|
6124
|
+
}
|
|
6125
|
+
cgKick(0);
|
|
6126
|
+
};
|
|
6127
|
+
c.addEventListener("pointerup", release);
|
|
6128
|
+
c.addEventListener("pointercancel", () => release(null));
|
|
6129
|
+
c.addEventListener("dblclick", (e) => {
|
|
6130
|
+
const p = local(e);
|
|
6131
|
+
const hit = cgNodeAt(p.x, p.y);
|
|
6132
|
+
if (hit) cgDrawGraph({ focus: hit.id, merge: true });
|
|
6133
|
+
});
|
|
6134
|
+
c.addEventListener("wheel", (e) => {
|
|
6135
|
+
e.preventDefault();
|
|
6136
|
+
cgZoomBy(e.deltaY < 0 ? 1.14 : 1 / 1.14, local(e));
|
|
6137
|
+
}, { passive: false });
|
|
6138
|
+
// The canvas is focusable, so the same picture is reachable without a pointer.
|
|
6139
|
+
c.addEventListener("keydown", (e) => {
|
|
6140
|
+
if (e.key === "Escape") { cg.selected = null; cgRenderDetail(); cgKick(0); }
|
|
6141
|
+
else if (e.key === "+" || e.key === "=") cgZoomBy(1.2);
|
|
6142
|
+
else if (e.key === "-") cgZoomBy(1 / 1.2);
|
|
6143
|
+
else if (e.key === "0") cgFit();
|
|
6144
|
+
else return;
|
|
6145
|
+
e.preventDefault();
|
|
6146
|
+
});
|
|
6147
|
+
new ResizeObserver(() => cgKick(0)).observe($("cgWrap"));
|
|
6148
|
+
const redraw = () => cgDrawGraph({ focus: $("cgFocus").value.trim() });
|
|
6149
|
+
$("cgZoomIn").addEventListener("click", () => cgZoomBy(1.25));
|
|
6150
|
+
$("cgZoomOut").addEventListener("click", () => cgZoomBy(1 / 1.25));
|
|
6151
|
+
$("cgFit").addEventListener("click", cgFit);
|
|
6152
|
+
$("cgDraw").addEventListener("click", redraw);
|
|
6153
|
+
$("cgFocus").addEventListener("keydown", (e) => { if (e.key === "Enter") redraw(); });
|
|
6154
|
+
$("cgScope").addEventListener("change", redraw);
|
|
6155
|
+
$("cgDepth").addEventListener("change", redraw);
|
|
6156
|
+
$("cgDetail").addEventListener("click", (e) => {
|
|
6157
|
+
const close = e.target.closest("[data-cg-close]");
|
|
6158
|
+
if (close) { cg.selected = null; cgRenderDetail(); cgKick(0); return; }
|
|
6159
|
+
const focus = e.target.closest("[data-cg-focus]");
|
|
6160
|
+
if (focus) { $("cgFocus").value = focus.dataset.cgFocus; cgDrawGraph({ focus: focus.dataset.cgFocus }); return; }
|
|
6161
|
+
const expand = e.target.closest("[data-cg-expand]");
|
|
6162
|
+
if (expand) { cgDrawGraph({ focus: expand.dataset.cgExpand, merge: true }); return; }
|
|
6163
|
+
const copy = e.target.closest("[data-cg-copy]");
|
|
6164
|
+
if (copy) copyToClipboard(copy.dataset.cgCopy, copy);
|
|
6165
|
+
});
|
|
6166
|
+
}
|
|
6167
|
+
|
|
5581
6168
|
function renderCodeGraph(view) {
|
|
5582
6169
|
view = view || {};
|
|
5583
6170
|
const on = !!view.enabled;
|
|
@@ -5617,7 +6204,12 @@ export {
|
|
|
5617
6204
|
$("cgNote").textContent = "";
|
|
5618
6205
|
try {
|
|
5619
6206
|
const q = cgProject ? "?project=" + encodeURIComponent(cgProject) : "";
|
|
5620
|
-
|
|
6207
|
+
const view = await (await fetch("/api/codegraph" + q, { cache: "no-store" })).json();
|
|
6208
|
+
renderCodeGraph(view);
|
|
6209
|
+
// The picture is a second request on purpose: it is the expensive half, and the
|
|
6210
|
+
// panel around it (projects, freshness, architecture) must not wait on it.
|
|
6211
|
+
if (view && (view.projects || []).length) cgDrawGraph({ focus: $("cgFocus").value.trim() });
|
|
6212
|
+
else cgOverlay("Index a project to draw its graph.");
|
|
5621
6213
|
} catch { $("cgBody").innerHTML = '<div class="empty" style="padding:12px 0">Code graph unavailable.</div>'; }
|
|
5622
6214
|
}
|
|
5623
6215
|
async function cgPost(payload, working) {
|
|
@@ -5646,7 +6238,10 @@ export {
|
|
|
5646
6238
|
async function cgIndexAction() {
|
|
5647
6239
|
const root = $("cgRoot").value.trim();
|
|
5648
6240
|
const out = await cgPost({ op: "index", root: root || undefined }, "Indexing...");
|
|
5649
|
-
if (out)
|
|
6241
|
+
if (!out) return;
|
|
6242
|
+
if (out.view && out.view.selected) cgProject = out.view.selected;
|
|
6243
|
+
setNote("cgNote", out.ok ? "Indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err");
|
|
6244
|
+
if (out.ok) cgDrawGraph({ focus: $("cgFocus").value.trim() });
|
|
5650
6245
|
}
|
|
5651
6246
|
async function cgAskAction() {
|
|
5652
6247
|
const query = $("cgQuery").value.trim();
|
|
@@ -5655,6 +6250,7 @@ export {
|
|
|
5655
6250
|
if (out) setNote("cgNote", out.ok ? "" : (out.error || "Query failed."), out.ok ? "ok" : "err");
|
|
5656
6251
|
}
|
|
5657
6252
|
function wireCodeGraph() {
|
|
6253
|
+
cgWireCanvas();
|
|
5658
6254
|
$("cgRefresh").addEventListener("click", loadCodeGraph);
|
|
5659
6255
|
$("cgToggle").addEventListener("click", cgToggleAction);
|
|
5660
6256
|
$("cgIndex").addEventListener("click", cgIndexAction);
|
|
@@ -5664,12 +6260,17 @@ export {
|
|
|
5664
6260
|
const reindex = e.target.closest("button[data-cg-reindex]");
|
|
5665
6261
|
if (reindex) {
|
|
5666
6262
|
cgPost({ op: "index", root: reindex.dataset.cgReindex }, "Re-indexing...")
|
|
5667
|
-
.then((out) => {
|
|
6263
|
+
.then((out) => {
|
|
6264
|
+
if (!out) return;
|
|
6265
|
+
setNote("cgNote", out.ok ? "Re-indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err");
|
|
6266
|
+
if (out.ok) cgDrawGraph({ focus: $("cgFocus").value.trim() });
|
|
6267
|
+
});
|
|
5668
6268
|
return;
|
|
5669
6269
|
}
|
|
5670
6270
|
const b = e.target.closest("button[data-cg-project]");
|
|
5671
6271
|
if (!b) return;
|
|
5672
6272
|
cgProject = b.dataset.cgProject;
|
|
6273
|
+
$("cgFocus").value = "";
|
|
5673
6274
|
loadCodeGraph();
|
|
5674
6275
|
});
|
|
5675
6276
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/dashboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.110",
|
|
4
4
|
"description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|