docs/shape.html
Ref: Size: 31.6 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">
{
"module": "github.com/a73x/eitri",
"packages": [
{
"importPath": "cmd/eitri",
"plane": "binaries",
"synopsis": "Command eitri is the end-user client: SSH into fleet VMs through the jump gate with self-signed short-lived certs (eitri ssh) and register tenant user CAs (eitri ca upload).",
"imports": [
"internal/cli",
"internal/version"
]
},
{
"importPath": "cmd/eitri-agent",
"plane": "binaries",
"synopsis": "eitri-agent: BYO-hardware agent.",
"imports": [
"internal/agent/run",
"internal/version"
]
},
{
"importPath": "cmd/eitri-apispec",
"plane": "binaries",
"synopsis": "Command eitri-apispec regenerates docs/openapi.json from the api route table.",
"imports": [
"internal/server/api/spec"
]
},
{
"importPath": "cmd/eitri-oidc",
"plane": "binaries",
"synopsis": "eitri-oidc: the bundled OIDC issuer.",
"imports": [
"internal/oidcprovider",
"internal/version"
]
},
{
"importPath": "cmd/eitri-server",
"plane": "binaries",
"synopsis": "eitri-server: single-node control plane (no TLS termination here — front with a reverse proxy for TLS).",
"imports": [
"internal/server/boot",
"internal/version"
]
},
{
"importPath": "cmd/eitri-shape",
"plane": "binaries",
"synopsis": "eitri-shape regenerates the explorable architecture-shape diagram from the real package graph (`go list`).",
"imports": [
"internal/shape"
]
},
{
"importPath": "cmd/eitri-site",
"plane": "binaries",
"synopsis": "Command eitri-site generates the eitri.sh static site (default) or the release manifest (the \"manifest\" subcommand).",
"imports": [
"internal/site",
"internal/version"
]
},
{
"importPath": "cmd/eitri-smoke",
"plane": "binaries",
"synopsis": "eitri-smoke: the deploy boot-gate harness.",
"imports": [
"internal/smoke",
"internal/version"
]
},
{
"importPath": "internal/agent/bootstrap",
"plane": "data",
"synopsis": "Package bootstrap installs the agent's runtime — the cloud-hypervisor binary and its UEFI guest firmware (CLOUDHV.fd) — the first time it is missing on a host, by fetching sha-pinned artifacts from the eitri.sh release manifest (internal/relmanifest).",
"imports": [
"internal/relmanifest"
]
},
{
"importPath": "internal/agent/cloudhv",
"plane": "data",
"synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.",
"imports": [
"internal/agent/exec",
"internal/agent/hostinfo",
"internal/agent/hyperlog",
"internal/agent/permanent",
"internal/agent/pidfile",
"internal/agent/state"
]
},
{
"importPath": "internal/agent/dhcp",
"plane": "data",
"synopsis": "Package dhcp is an in-process, reservation-only DHCPv4 responder for the eitri bridge.",
"imports": [
"internal/agent/ipalloc"
]
},
{
"importPath": "internal/agent/enrollclient",
"plane": "data",
"synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.",
"imports": []
},
{
"importPath": "internal/agent/exec",
"plane": "data",
"synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv).",
"imports": []
},
{
"importPath": "internal/agent/exposeproxy",
"plane": "data",
"synopsis": "Package exposeproxy publishes guest ports on their host.",
"imports": [
"internal/pb"
]
},
{
"importPath": "internal/agent/hostinfo",
"plane": "data",
"synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.",
"imports": [
"internal/agent/exec",
"internal/pb"
]
},
{
"importPath": "internal/agent/hyperlog",
"plane": "data",
"synopsis": "Package hyperlog reads back the last thing a hypervisor said before it stopped running.",
"imports": []
},
{
"importPath": "internal/agent/imagecache",
"plane": "data",
"synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).",
"imports": [
"internal/agent/permanent",
"internal/names"
]
},
{
"importPath": "internal/agent/ipalloc",
"plane": "data",
"synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
"imports": []
},
{
"importPath": "internal/agent/netenv",
"plane": "data",
"synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.",
"imports": [
"internal/agent/dhcp",
"internal/agent/exec",
"internal/agent/netsnoop",
"internal/agent/permanent",
"internal/agent/state"
]
},
{
"importPath": "internal/agent/netsnoop",
"plane": "data",
"synopsis": "Package netsnoop discovers the address a guest's named-network NIC was granted, by watching that NIC's own tap for its DHCP exchange.",
"imports": []
},
{
"importPath": "internal/agent/permanent",
"plane": "data",
"synopsis": "Package permanent mints agent errors that no retry can fix.",
"imports": []
},
{
"importPath": "internal/agent/pidfile",
"plane": "data",
"synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.",
"imports": []
},
{
"importPath": "internal/agent/reconcile",
"plane": "data",
"synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.",
"imports": [
"internal/agent/permanent",
"internal/agent/seed",
"internal/agent/state",
"internal/pb",
"internal/version"
]
},
{
"importPath": "internal/agent/run",
"plane": "data",
"synopsis": "Package run implements the eitri-agent command line behind a tested RunCLI so cmd/eitri-agent stays thin wiring (arch R14).",
"imports": [
"internal/agent/bootstrap",
"internal/agent/cloudhv",
"internal/agent/enrollclient",
"internal/agent/exposeproxy",
"internal/agent/hostinfo",
"internal/agent/imagecache",
"internal/agent/netenv",
"internal/agent/reconcile",
"internal/agent/seed",
"internal/agent/serialpump",
"internal/agent/state",
"internal/agent/statelock",
"internal/agent/syncclient",
"internal/covsnap",
"internal/joinblob",
"internal/names",
"internal/version"
]
},
{
"importPath": "internal/agent/seed",
"plane": "data",
"synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
"imports": [
"internal/cloudinit",
"internal/guest"
]
},
{
"importPath": "internal/agent/selfupdate",
"plane": "data",
"synopsis": "Package selfupdate replaces the running agent binary with a server-instructed release and re-execs.",
"imports": []
},
{
"importPath": "internal/agent/serialpump",
"plane": "data",
"synopsis": "Package serialpump owns the durability of VM serial consoles.",
"imports": []
},
{
"importPath": "internal/agent/state",
"plane": "data",
"synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).",
"imports": [
"internal/names"
]
},
{
"importPath": "internal/agent/statelock",
"plane": "data",
"synopsis": "Package statelock enforces one running agent per identity.",
"imports": []
},
{
"importPath": "internal/agent/syncclient",
"plane": "data",
"synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.",
"imports": [
"internal/agent/exec",
"internal/agent/hostinfo",
"internal/agent/reconcile",
"internal/agent/selfupdate",
"internal/agent/state",
"internal/pb",
"internal/transport",
"internal/version"
]
},
{
"importPath": "internal/agent/vfkit",
"plane": "data",
"synopsis": "Package vfkit manages one vfkit process per VM: the macOS backend, where vfkit is the signed helper that drives Apple's Virtualization.framework.",
"imports": [
"internal/agent/exec",
"internal/agent/hostinfo",
"internal/agent/hyperlog",
"internal/agent/permanent",
"internal/agent/pidfile",
"internal/agent/state"
]
},
{
"importPath": "internal/arch",
"plane": "tooling",
"synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.",
"imports": []
},
{
"importPath": "internal/cli",
"plane": "tooling",
"synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
"imports": [
"internal/guest",
"internal/names",
"internal/server/api/client"
]
},
{
"importPath": "internal/cloudinit",
"plane": "wire",
"synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.",
"imports": []
},
{
"importPath": "internal/covsnap",
"plane": "tooling",
"synopsis": "Package covsnap lets a long-running binary flush integration-coverage counters on demand.",
"imports": []
},
{
"importPath": "internal/gateclient",
"plane": "tooling",
"synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.",
"imports": [
"internal/names"
]
},
{
"importPath": "internal/guest",
"plane": "wire",
"synopsis": "Package guest holds the handful of facts the control plane and the data plane must agree on about a guest VM.",
"imports": []
},
{
"importPath": "internal/joinblob",
"plane": "wire",
"synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.",
"imports": []
},
{
"importPath": "internal/mcpserver",
"plane": "tooling",
"synopsis": "Package mcpserver implements eitri's MCP toolset: tools that let a model create, control (SSH exec/files), and destroy eitri VMs.",
"imports": [
"internal/random",
"internal/server/api/client",
"internal/server/release"
]
},
{
"importPath": "internal/names",
"plane": "wire",
"synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.",
"imports": []
},
{
"importPath": "internal/oidcprovider",
"plane": "tooling",
"synopsis": "Package oidcprovider is a minimal, spec-compliant OIDC issuer: discovery, authorization-code + PKCE, token, and JWKS, with users in a flat file.",
"imports": []
},
{
"importPath": "internal/pb",
"plane": "wire",
"synopsis": "",
"imports": []
},
{
"importPath": "internal/random",
"plane": "wire",
"synopsis": "Package random provides small cryptographically-random helpers shared across the control plane, CLIs, and the integration harness — a leaf package so a CLI or test binary can reuse them without importing heavier deps (e.g.",
"imports": []
},
{
"importPath": "internal/relmanifest",
"plane": "wire",
"synopsis": "Package relmanifest is the eitri.sh release-manifest wire contract, shared by its producer (the site generator), the server (agent-upgrade offers), and the agent (runtime bootstrap).",
"imports": []
},
{
"importPath": "internal/server/api",
"plane": "control",
"synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.",
"imports": [
"internal/cloudinit",
"internal/joinblob",
"internal/names",
"internal/random",
"internal/server/api/types",
"internal/server/delegation",
"internal/server/hosttoken",
"internal/server/hub",
"internal/server/registry",
"internal/server/release",
"internal/server/sshca",
"internal/server/store",
"internal/version"
]
},
{
"importPath": "internal/server/api/client",
"plane": "control",
"synopsis": "Package client is THE Go client for the eitri control-plane HTTP API — the one consumer every in-repo caller (MCP server, smoke gate, CLI) goes through.",
"imports": [
"internal/server/api/types"
]
},
{
"importPath": "internal/server/api/spec",
"plane": "control",
"synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.",
"imports": [
"internal/server/api",
"internal/server/api/types"
]
},
{
"importPath": "internal/server/api/types",
"plane": "control",
"synopsis": "Package types is the server HTTP API's wire contract: every request and response JSON shape the API speaks, and nothing else.",
"imports": []
},
{
"importPath": "internal/server/boot",
"plane": "control",
"synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).",
"imports": [
"internal/covsnap",
"internal/guest",
"internal/joinblob",
"internal/server/api",
"internal/server/config",
"internal/server/delegation",
"internal/server/health",
"internal/server/hub",
"internal/server/mcphttp",
"internal/server/registry",
"internal/server/release",
"internal/server/sshca",
"internal/server/sshgate",
"internal/server/store",
"internal/server/syncsvc",
"internal/server/vmssh",
"internal/server/web",
"internal/transport"
]
},
{
"importPath": "internal/server/config",
"plane": "control",
"synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.",
"imports": [
"internal/names"
]
},
{
"importPath": "internal/server/delegation",
"plane": "control",
"synopsis": "Package delegation holds the credentials a tenant has lent eitri.",
"imports": []
},
{
"importPath": "internal/server/health",
"plane": "control",
"synopsis": "Package health serves the eitri-server liveness and readiness probes.",
"imports": []
},
{
"importPath": "internal/server/hosttoken",
"plane": "control",
"synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.",
"imports": []
},
{
"importPath": "internal/server/hub",
"plane": "control",
"synopsis": "Package hub wakes per-host QUIC streams when desired state changes.",
"imports": []
},
{
"importPath": "internal/server/mcphttp",
"plane": "control",
"synopsis": "Package mcphttp serves the eitri MCP toolset over HTTP at /mcp.",
"imports": [
"internal/mcpserver",
"internal/server/api",
"internal/server/api/client",
"internal/server/vmssh"
]
},
{
"importPath": "internal/server/registry",
"plane": "control",
"synopsis": "Package registry holds volatile actual state in memory.",
"imports": []
},
{
"importPath": "internal/server/release",
"plane": "control",
"synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and names the agent floors the control plane admits against.",
"imports": [
"internal/relmanifest",
"internal/version"
]
},
{
"importPath": "internal/server/seal",
"plane": "control",
"synopsis": "Package seal encrypts the key material eitri holds, so that what rests on disk is not what signs.",
"imports": []
},
{
"importPath": "internal/server/sshca",
"plane": "control",
"synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.",
"imports": [
"internal/server/seal"
]
},
{
"importPath": "internal/server/sshgate",
"plane": "control",
"synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003ctenant\u003e.\u003cvm\u003e`.",
"imports": []
},
{
"importPath": "internal/server/store",
"plane": "control",
"synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.",
"imports": [
"internal/random",
"internal/transport"
]
},
{
"importPath": "internal/server/syncsvc",
"plane": "control",
"synopsis": "Package syncsvc is the QUIC server end of the agent reconcile stream.",
"imports": [
"internal/pb",
"internal/server/hosttoken",
"internal/server/hub",
"internal/server/registry",
"internal/server/release",
"internal/server/store",
"internal/transport"
]
},
{
"importPath": "internal/server/vmssh",
"plane": "control",
"synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.",
"imports": [
"internal/names"
]
},
{
"importPath": "internal/server/web",
"plane": "control",
"synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).",
"imports": []
},
{
"importPath": "internal/shape",
"plane": "tooling",
"synopsis": "Package shape generates an explorable diagram of eitri's package graph from the real `go list` output, so the architecture view cannot silently drift from the code.",
"imports": []
},
{
"importPath": "internal/site",
"plane": "tooling",
"synopsis": "Package site generates the eitri.sh static site: docs/*.md and a markdown landing page rendered through one HTML template, plus the downloads page and the agent-upgrade release manifest.",
"imports": [
"internal/server/release"
]
},
{
"importPath": "internal/smoke",
"plane": "tooling",
"synopsis": "Package smoke is the deploy boot-gate harness.",
"imports": [
"internal/gateclient",
"internal/guest",
"internal/server/api/client"
]
},
{
"importPath": "internal/transport",
"plane": "wire",
"synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
"imports": []
},
{
"importPath": "internal/version",
"plane": "wire",
"synopsis": "Package version carries the build-stamped eitri version, set via -ldflags \"-X github.com/a73x/eitri/internal/version.Version=v0.0.2\".",
"imports": []
}
]
}
</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>