internal/shape/viewer.html
Ref: Size: 11.3 KiB History
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>eitri — architecture shape</title>
<style>
:root { font-family: system-ui, sans-serif; }
body { margin: 0; display: flex; height: 100vh; color: #1a1a1a; overflow: hidden; }
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
header.top { padding: 12px 16px; border-bottom: 1px solid #eee; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
header.top h1 { font-size: 15px; margin: 0; }
header.top .mod { color: #888; font-size: 12px; font-family: ui-monospace, monospace; }
#legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; align-items: center; }
#legend span { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
#legend span.off { opacity: 0.4; text-decoration: line-through; }
#legend i { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
.btn { font: inherit; font-size: 12px; padding: 2px 9px; border: 1px solid #ccc; border-radius: 5px;
background: #fff; color: #333; cursor: pointer; }
.btn:hover { background: #f0f0f0; }
#reset[hidden] { display: none; }
#canvas { flex: 1; min-height: 0; }
svg { width: 100%; height: 100%; display: block; background: #fcfcfc; cursor: grab; }
.edge { stroke: #c8c8cf; stroke-width: 1; }
.edge.hot { stroke: #333; stroke-width: 1.6; }
.node { cursor: pointer; }
.node circle { stroke: #fff; stroke-width: 1.5; }
.node text { font-size: 9px; fill: #333; pointer-events: none; font-family: ui-monospace, monospace; }
.node.dim { opacity: 0.18; }
.edge.dim { opacity: 0.07; }
.node.sel circle { stroke: #111; stroke-width: 2.5; }
#panel { width: 320px; border-left: 1px solid #ddd; padding: 20px; overflow: auto; background: #fafafa; }
#panel h2 { font-size: 13px; margin: 16px 0 6px; color: #444; }
#panel h1 { font-size: 14px; margin: 0 0 8px; }
code { font-family: ui-monospace, monospace; font-size: 12px; }
.imports { margin: 0; padding-left: 18px; }
.imports li { font-size: 12px; }
.empty { color: #888; }
.hint { color: #999; font-size: 12px; }
</style>
</head>
<body>
<div id="main">
<header class="top">
<h1>eitri — architecture shape <span class="mod" id="modtag"></span></h1>
<div id="legend"></div>
<button id="reset" class="btn" hidden>reset filters</button>
</header>
<div id="canvas"></div>
</div>
<div id="panel"><p class="hint">Drag to pull the graph apart. Hover a node to trace its edges. Click for details.</p></div>
<script type="application/json" id="shape-data">
/*SHAPE_DATA*/
</script>
<script>
const PLANES = [
["control", "control", "#4571c4"], ["data", "data", "#d04a4a"],
["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"],
["tooling", "tooling", "#7a7a7a"], ["mesh", "mesh", "#17a2b8"],
["unclassified", "unclassified", "#d4a017"],
];
const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c]));
const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]));
const model = JSON.parse(document.getElementById("shape-data").textContent);
document.getElementById("modtag").textContent = model.module;
// --- filter state --------------------------------------------------------
// A node is in the simulation/render iff it is not individually hidden and its
// plane is not toggled off. Hidden nodes are dropped from forces and edges, so
// the layout genuinely re-flows around what remains.
const hiddenPlanes = new Set();
const visible = n => !n.hidden && !hiddenPlanes.has(n.plane);
// --- build node + edge sets ---------------------------------------------
const W = 1000, H = 700;
const nodes = model.packages.map((p, i) => ({
id: p.importPath, plane: p.plane, synopsis: p.synopsis, imports: p.imports || [],
// deterministic initial placement on a circle (no RNG → reproducible layout)
x: W / 2 + Math.cos(i / model.packages.length * 2 * Math.PI) * 250,
y: H / 2 + Math.sin(i / model.packages.length * 2 * Math.PI) * 250,
vx: 0, vy: 0, fixed: false, hidden: false,
}));
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const edges = [];
for (const n of nodes)
for (const imp of n.imports)
if (byId[imp]) edges.push({ s: n, t: byId[imp] });
const neighbors = new Map(nodes.map(n => [n.id, new Set()]));
for (const e of edges) { neighbors.get(e.s.id).add(e.t.id); neighbors.get(e.t.id).add(e.s.id); }
// --- SVG ------------------------------------------------------------------
const SVGNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#c8c8cf"/></marker></defs>`;
document.getElementById("canvas").appendChild(svg);
const edgeEls = edges.map(e => {
const l = document.createElementNS(SVGNS, "line");
l.setAttribute("class", "edge");
l.setAttribute("marker-end", "url(#arrow)");
svg.appendChild(l);
e.el = l;
return l;
});
const nodeEls = nodes.map(n => {
const g = document.createElementNS(SVGNS, "g");
g.setAttribute("class", "node");
const c = document.createElementNS(SVGNS, "circle");
c.setAttribute("r", 7);
c.setAttribute("fill", COLOR[n.plane] || "#999");
const t = document.createElementNS(SVGNS, "text");
t.setAttribute("x", 10); t.setAttribute("y", 3);
t.textContent = n.id.split("/").pop();
g.appendChild(c); g.appendChild(t);
svg.appendChild(g);
n.g = g;
g.addEventListener("mouseenter", () => highlight(n));
g.addEventListener("mouseleave", () => highlight(null));
g.addEventListener("mousedown", ev => startDrag(n, ev));
g.addEventListener("click", () => select(n));
return g;
});
function draw() {
for (const e of edges) {
if (!visible(e.s) || !visible(e.t)) { e.el.style.display = "none"; continue; }
e.el.style.display = "";
e.el.setAttribute("x1", e.s.x); e.el.setAttribute("y1", e.s.y);
e.el.setAttribute("x2", e.t.x); e.el.setAttribute("y2", e.t.y);
}
for (const n of nodes) {
n.g.style.display = visible(n) ? "" : "none";
n.g.setAttribute("transform", `translate(${n.x},${n.y})`);
}
}
// --- force simulation (velocity + alpha-decay, settles to rest) ----------
const REPULSION = 6000; // node-node push (charge)
const LINK_DIST = 140; // ideal edge length
const LINK_STR = 0.04; // edge spring stiffness
const CENTER = 0.015; // gravity toward centre (keeps graph on-screen)
const DECAY = 0.6; // velocity damping per tick
const ALPHA_DECAY = 0.02, ALPHA_MIN = 0.003;
let alpha = 1, running = false;
function tick() {
// node-node repulsion (all pairs; n=30 so O(n²) is trivial)
for (let i = 0; i < nodes.length; i++)
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
if (!visible(a) || !visible(b)) continue;
let dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx * dx + dy * dy + 1, d = Math.sqrt(d2);
const f = REPULSION / d2 * alpha;
const ux = dx / d * f, uy = dy / d * f;
a.vx += ux; a.vy += uy; b.vx -= ux; b.vy -= uy;
}
// edge springs toward LINK_DIST
for (const e of edges) {
if (!visible(e.s) || !visible(e.t)) continue;
let dx = e.t.x - e.s.x, dy = e.t.y - e.s.y;
const d = Math.hypot(dx, dy) || 0.01;
const f = (d - LINK_DIST) * LINK_STR * alpha;
const ux = dx / d * f, uy = dy / d * f;
e.s.vx += ux; e.s.vy += uy; e.t.vx -= ux; e.t.vy -= uy;
}
// gravity + integrate with damping
for (const n of nodes) {
if (!visible(n)) continue;
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
n.vx += (W / 2 - n.x) * CENTER * alpha;
n.vy += (H / 2 - n.y) * CENTER * alpha;
n.vx *= DECAY; n.vy *= DECAY;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(16, Math.min(W - 16, n.x));
n.y = Math.max(16, Math.min(H - 16, n.y));
}
alpha *= (1 - ALPHA_DECAY); // cool toward rest
}
function loop() {
tick(); draw();
// keep running while settling, or while a drag is reheating the layout
if (alpha > ALPHA_MIN || dragging) requestAnimationFrame(loop);
else running = false;
}
function kick(a = 0.5) { // (re)heat and ensure the loop is running
alpha = Math.max(alpha, a);
if (!running) { running = true; requestAnimationFrame(loop); }
}
kick(1);
// --- interaction ----------------------------------------------------------
function highlight(n) {
if (!n) {
nodeEls.forEach(g => g.classList.remove("dim"));
edges.forEach(e => { e.el.classList.remove("hot"); e.el.classList.remove("dim"); });
return;
}
const nb = neighbors.get(n.id);
nodes.forEach(m => m.g.classList.toggle("dim", m.id !== n.id && !nb.has(m.id)));
edges.forEach(e => {
const hot = e.s.id === n.id || e.t.id === n.id;
e.el.classList.toggle("hot", hot);
e.el.classList.toggle("dim", !hot);
});
}
function select(n) {
nodeEls.forEach(g => g.classList.remove("sel"));
n.g.classList.add("sel");
const list = n.imports;
const imps = list.length
? `<ul class="imports">${list.map(i => `<li><code>${esc(i)}</code></li>`).join("")}</ul>`
: `<p class="empty">No internal imports.</p>`;
document.getElementById("panel").innerHTML =
`<h1><span style="color:${COLOR[n.plane] || "#999"}">●</span> <code>${esc(n.id)}</code></h1>` +
`<p>${n.synopsis ? esc(n.synopsis) : '<span class="empty">(no package doc)</span>'}</p>` +
`<h2>Plane</h2><p>${esc(n.plane)}</p>` +
`<h2>Production imports</h2>${imps}` +
`<p style="margin-top:18px"><button class="btn" id="hidebtn">hide this node</button></p>`;
document.getElementById("hidebtn").onclick = () => hide(n);
}
// --- filtering -----------------------------------------------------------
function buildLegend() {
const el = document.getElementById("legend");
el.innerHTML = "";
for (const [k, label, c] of PLANES) {
const span = document.createElement("span");
span.innerHTML = `<i style="background:${c}"></i>${label}`;
span.classList.toggle("off", hiddenPlanes.has(k));
span.onclick = () => {
hiddenPlanes.has(k) ? hiddenPlanes.delete(k) : hiddenPlanes.add(k);
span.classList.toggle("off", hiddenPlanes.has(k));
afterFilterChange();
};
el.appendChild(span);
}
}
function hide(n) {
n.hidden = true;
document.getElementById("panel").innerHTML =
`<p class="hint">Hid <code>${esc(n.id)}</code>. Use “reset filters” to bring it back.</p>`;
afterFilterChange();
}
function resetFilters() {
hiddenPlanes.clear();
for (const n of nodes) n.hidden = false;
buildLegend();
afterFilterChange();
}
function afterFilterChange() {
const anyHidden = hiddenPlanes.size > 0 || nodes.some(n => n.hidden);
document.getElementById("reset").hidden = !anyHidden;
kick(0.4); // re-settle the layout around what remains
}
document.getElementById("reset").onclick = resetFilters;
buildLegend();
let dragging = null;
function pt(ev) {
const r = svg.getBoundingClientRect();
return { x: (ev.clientX - r.left) / r.width * W, y: (ev.clientY - r.top) / r.height * H };
}
function startDrag(n, ev) {
ev.preventDefault();
dragging = n; n.fixed = true; kick(0.3);
}
window.addEventListener("mousemove", ev => {
if (!dragging) return;
const p = pt(ev); dragging.x = p.x; dragging.y = p.y;
});
window.addEventListener("mouseup", () => {
if (dragging) { dragging.fixed = false; dragging = null; kick(0.1); }
});
</script>
</body>
</html>