a73x

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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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>