use serde::Serialize;
#[derive(Serialize)]
pub struct GraphView {
pub package: Option<String>,
pub base: String,
pub task: String,
pub scope: String,
pub nodes: Vec<GraphNode>,
pub links: Vec<GraphLink>,
}
#[derive(Serialize)]
pub struct GraphNode {
pub id: usize,
pub label: String,
pub file: String,
pub symbol: String,
pub package: String,
pub kind: NodeKind,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub details: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub path: Vec<usize>,
}
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
Seed,
Affected,
Dependency,
Target,
Normal,
}
#[derive(Serialize)]
pub struct GraphLink {
pub source: usize,
pub target: usize,
#[serde(rename = "type")]
pub type_only: bool,
}
pub fn render_html(view: &GraphView) -> String {
let data = serde_json::to_string(view).unwrap_or_else(|_| "{}".to_string());
TEMPLATE.replace("/*__DATA__*/null", &data)
}
const TEMPLATE: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>monoripple graph</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0f1117; color: #e6e6e6; }
#app { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: auto 1fr; height: 100vh; }
header { grid-column: 1 / 3; padding: 10px 16px; border-bottom: 1px solid #232732; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
header .meta { color: #8b93a7; font-size: 12px; }
header input { margin-left: auto; background: #171a22; border: 1px solid #2a2f3b; color: #e6e6e6; padding: 6px 10px; border-radius: 6px; width: 240px; }
canvas { display: block; width: 100%; height: 100%; }
#side { border-left: 1px solid #232732; padding: 14px 16px; overflow: auto; }
#side h2 { font-size: 13px; margin: 0 0 6px; color: #9aa3b7; text-transform: uppercase; letter-spacing: .05em; }
#side .value { font-size: 13px; word-break: break-word; margin-bottom: 12px; }
#side ul { margin: 6px 0 12px; padding-left: 18px; }
#side li { font-size: 12px; margin-bottom: 4px; }
.legend { display: flex; gap: 14px; font-size: 12px; color: #b9c0d0; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
.hint { color: #6b7280; font-size: 11px; }
#side ol { margin: 6px 0 12px; padding-left: 18px; }
#side ol li { font-size: 12px; margin-bottom: 4px; font-family: ui-monospace, monospace; }
#side .targets a { color: #a78bfa; text-decoration: none; }
#side .targets a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div id="app">
<header>
<h1>monoripple graph</h1>
<span class="meta" id="summary"></span>
<span class="legend">
<span><span class="dot" style="background:#f97316"></span>changed</span>
<span><span class="dot" style="background:#38bdf8"></span>affected</span>
<span><span class="dot" style="background:#a78bfa"></span>target</span>
<span><span class="dot" style="background:#34d399"></span>shared dependency</span>
<span><span class="dot" style="background:#3f4759"></span>other</span>
</span>
<input id="filter" placeholder="filter nodes…" />
</header>
<canvas id="canvas"></canvas>
<aside id="side">
<h2>Selected node</h2>
<div class="value" id="selected">Click a node to inspect it.</div>
<div id="details"></div>
<p class="hint">Drag nodes to reposition. Scroll to zoom. Drag background to pan.</p>
</aside>
</div>
<script>
const DATA = /*__DATA__*/null;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const colors = { seed: "#f97316", affected: "#38bdf8", dependency: "#34d399", target: "#a78bfa", normal: "#3f4759" };
const nodes = DATA.nodes.map((n) => ({ ...n, x: 0, y: 0, vx: 0, vy: 0 }));
const links = DATA.links.map((l) => ({ ...l, source: nodes[l.source], target: nodes[l.target] }));
const adjacency = new Map(nodes.map((n) => [n.id, new Set()]));
for (const link of links) {
adjacency.get(link.source.id).add(link.target.id);
adjacency.get(link.target.id).add(link.source.id);
}
const targetNodes = nodes.filter((n) => n.kind === "target");
let width = 0, height = 0, dpr = window.devicePixelRatio || 1;
function layoutAnchors() {
const r = Math.max(160, Math.min(width, height) * 0.34);
targetNodes.forEach((n, i) => {
const angle = (i / Math.max(1, targetNodes.length)) * Math.PI * 2 - Math.PI / 2;
n.ax = width / 2 + Math.cos(angle) * r;
n.ay = height / 2 + Math.sin(angle) * r;
n.anchored = true;
});
}
function resize() {
const rect = canvas.getBoundingClientRect();
width = rect.width; height = rect.height;
canvas.width = width * dpr; canvas.height = height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
layoutAnchors();
}
window.addEventListener("resize", resize);
resize();
nodes.forEach((n, i) => {
if (n.anchored) { n.x = n.ax; n.y = n.ay; return; }
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
const spread = 120 + Math.random() * 160;
n.x = width / 2 + Math.cos(angle) * spread;
n.y = height / 2 + Math.sin(angle) * spread;
});
const view = { x: 0, y: 0, scale: 1 };
let selected = null;
let filterText = "";
let alpha = 1;
let dragging = null, panning = false, lastX = 0, lastY = 0;
function tick() {
const repulsion = 6000;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
let dx = a.x - b.x, dy = a.y - b.y;
let dist2 = dx * dx + dy * dy + 0.01;
const force = repulsion / dist2;
const dist = Math.sqrt(dist2);
const fx = (dx / dist) * force, fy = (dy / dist) * force;
a.vx += fx * alpha; a.vy += fy * alpha;
b.vx -= fx * alpha; b.vy -= fy * alpha;
}
}
for (const link of links) {
let dx = link.target.x - link.source.x, dy = link.target.y - link.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) + 0.01;
const force = (dist - 90) * 0.02;
const fx = (dx / dist) * force, fy = (dy / dist) * force;
link.source.vx += fx * alpha; link.source.vy += fy * alpha;
link.target.vx -= fx * alpha; link.target.vy -= fy * alpha;
}
for (const n of nodes) {
if (n.anchored) {
n.vx += (n.ax - n.x) * 0.08 * alpha;
n.vy += (n.ay - n.y) * 0.08 * alpha;
} else {
n.vx += (width / 2 - n.x) * 0.0005 * alpha;
n.vy += (height / 2 - n.y) * 0.0005 * alpha;
}
if (n === dragging) continue;
n.x += n.vx; n.y += n.vy;
n.vx *= 0.86; n.vy *= 0.86;
}
alpha *= 0.995;
if (alpha < 0.02) alpha = 0.02;
}
function draw() {
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.scale, view.scale);
const pathIds = selected ? pathNodeSet(selected) : null;
const pathEdges = selected ? pathEdgeSet(selected) : null;
const neighbors = selected ? adjacency.get(selected.id) : null;
ctx.lineWidth = 1;
for (const link of links) {
const onPath = pathEdges && pathEdges.has(edgeKey(link.source.id, link.target.id));
const touches = selected && (link.source.id === selected.id || link.target.id === selected.id);
if (onPath) {
ctx.strokeStyle = "#f8fafc"; ctx.lineWidth = 2.4;
} else {
ctx.strokeStyle = touches ? "#e2e8f0" : link.type ? "#2b3550" : "#232a38";
ctx.lineWidth = 1;
}
ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y);
ctx.stroke();
}
ctx.lineWidth = 1;
for (const n of nodes) {
const matches = filterText && n.label.toLowerCase().includes(filterText);
const onPath = pathIds ? pathIds.has(n.id) : false;
const dim =
(selected && !onPath && n !== selected && !neighbors.has(n.id)) ||
(filterText && !matches);
ctx.globalAlpha = dim ? 0.12 : 1;
const r = n.kind === "target" ? 13 : n.kind === "dependency" ? 10 : n.kind === "seed" ? 7 : 5;
if (n.kind === "target") {
ctx.fillStyle = colors.target;
ctx.strokeStyle = "#0f1117";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.fillStyle = colors[n.kind] || colors.normal;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fill();
}
if (n === selected) {
ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 2; ctx.stroke();
}
if (n.kind === "target") {
ctx.globalAlpha = dim ? 0.3 : 1;
ctx.fillStyle = "#e9ddff";
ctx.font = "bold 13px ui-sans-serif, system-ui, sans-serif";
ctx.fillText(n.symbol, n.x + r + 4, n.y + 4);
} else if (n.kind === "dependency") {
ctx.globalAlpha = dim ? 0.3 : 1;
ctx.fillStyle = "#a7f3d0";
ctx.font = "bold 11px ui-monospace, monospace";
ctx.fillText(n.label, n.x + r + 4, n.y + 3);
} else if (view.scale > 1.1 || n === selected || matches) {
ctx.globalAlpha = dim ? 0.2 : 0.9;
ctx.fillStyle = "#c7ccd8";
ctx.font = "10px ui-monospace, monospace";
ctx.fillText(n.symbol === "<module>" ? shortFile(n.file) : n.symbol, n.x + 9, n.y + 3);
}
}
ctx.globalAlpha = 1;
ctx.restore();
}
function shortFile(file) {
const parts = file.split("/");
return parts.slice(Math.max(0, parts.length - 2)).join("/");
}
function edgeKey(a, b) { return a < b ? a + "|" + b : b + "|" + a; }
function pathNodeSet(node) {
const set = new Set([node.id]);
for (const id of node.path || []) set.add(id);
return set;
}
function pathEdgeSet(node) {
const set = new Set();
const p = node.path || [];
for (let i = 0; i + 1 < p.length; i++) set.add(edgeKey(p[i], p[i + 1]));
return set;
}
function frame() { tick(); draw(); requestAnimationFrame(frame); }
frame();
function screenToWorld(px, py) {
return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
}
function nodeAt(px, py) {
const p = screenToWorld(px, py);
let best = null, bestDist = 12 / view.scale;
for (const n of nodes) {
const d = Math.hypot(n.x - p.x, n.y - p.y);
if (d < bestDist) { best = n; bestDist = d; }
}
return best;
}
canvas.addEventListener("mousedown", (e) => {
const node = nodeAt(e.offsetX, e.offsetY);
if (node) { dragging = node; select(node); }
else { panning = true; }
lastX = e.offsetX; lastY = e.offsetY;
});
window.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const ox = e.clientX - rect.left, oy = e.clientY - rect.top;
if (dragging) {
const p = screenToWorld(ox, oy);
dragging.x = p.x; dragging.y = p.y; dragging.vx = 0; dragging.vy = 0;
alpha = Math.max(alpha, 0.3);
} else if (panning) {
view.x += ox - lastX; view.y += oy - lastY;
}
lastX = ox; lastY = oy;
});
window.addEventListener("mouseup", () => { dragging = null; panning = false; });
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const p = screenToWorld(e.offsetX, e.offsetY);
view.scale *= factor;
view.x = e.offsetX - p.x * view.scale;
view.y = e.offsetY - p.y * view.scale;
}, { passive: false });
function select(node) {
selected = node;
document.getElementById("selected").textContent = node.label;
const details = document.getElementById("details");
let html = `<h2>Package</h2><div class="value">${escapeHtml(node.package || "—")}</div>`;
html += `<h2>Kind</h2><div class="value">${node.kind}</div>`;
if (node.details && node.details.length) {
html += `<h2>What changed</h2><ul>${node.details.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>`;
}
const path = (node.path || []).map((id) => nodes[id]);
if (path.length > 1) {
const heading = node.kind === "target" ? "Why it deploys" : "Why it is affected";
const steps = path
.map((step, i) => {
const arrow = i === 0 ? "" : "↓ ";
return `<li>${arrow}${escapeHtml(step.symbol === "<module>" ? step.file : step.symbol)}</li>`;
})
.join("");
html += `<h2>${heading}</h2><ol>${steps}</ol>`;
} else if (node.kind === "seed") {
html += `<h2>Why it deploys</h2><div class="value">This declaration changed.</div>`;
}
details.innerHTML = html;
}
function renderTargetList() {
if (selected) return;
const targetNodes = nodes.filter((n) => n.kind === "target");
if (!targetNodes.length) {
document.getElementById("details").innerHTML =
`<div class="value">No deployment targets are affected by this change.</div>`;
return;
}
const list = targetNodes
.map((n) => `<li><a href="#" data-id="${n.id}">${escapeHtml(n.symbol)}</a></li>`)
.join("");
document.getElementById("details").innerHTML =
`<h2>Affected targets</h2><ul class="targets">${list}</ul>`;
for (const link of document.querySelectorAll("#details a[data-id]")) {
link.addEventListener("click", (e) => {
e.preventDefault();
const node = nodes[Number(link.dataset.id)];
focusNode(node);
select(node);
});
}
}
function focusNode(node) {
view.scale = Math.max(view.scale, 1.4);
view.x = width / 2 - node.x * view.scale;
view.y = height / 2 - node.y * view.scale;
}
function escapeHtml(s) {
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
document.getElementById("filter").addEventListener("input", (e) => {
filterText = e.target.value.trim().toLowerCase();
});
const seeds = nodes.filter((n) => n.kind === "seed").length;
const targets = nodes.filter((n) => n.kind === "target").length;
document.getElementById("summary").textContent =
`${DATA.scope} scope · base ${DATA.base} · task ${DATA.task} · ${nodes.length} nodes · ${links.length} edges · ${seeds} changed · ${targets} targets`;
renderTargetList();
</script>
</body>
</html>
"##;