- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot - docs/plans/2026-05-13-public-landing-routing-refactor.md - docs/tutorials/build-a-page.md - abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root) Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and agent .remember/ state are intentionally left untracked. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
808 lines
27 KiB
HTML
808 lines
27 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ResolutionFlow — Workflow Diagram</title>
|
|
<style>
|
|
:root {
|
|
--bg-sidebar: #0e1016;
|
|
--bg-page: #16181f;
|
|
--bg-card: #1e2028;
|
|
--bg-elev: #2a2d38;
|
|
--border: #2a2d38;
|
|
--border-hover: #3a3d48;
|
|
--text-heading: #f4f5f7;
|
|
--text-primary: #d6d8df;
|
|
--text-muted: #8b8e98;
|
|
--text-dim: #5a5d68;
|
|
--accent: #60a5fa;
|
|
--accent-dim: rgba(96, 165, 250, 0.15);
|
|
--warning: #fbbf24;
|
|
--info: #67e8f9;
|
|
--success: #34d399;
|
|
--danger: #f87171;
|
|
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
|
|
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
|
|
/* layer colors */
|
|
--layer-page: #a78bfa;
|
|
--layer-component: #60a5fa;
|
|
--layer-hook: #38bdf8;
|
|
--layer-store: #22d3ee;
|
|
--layer-api_client: #34d399;
|
|
--layer-http_client: #6ee7b7;
|
|
--layer-endpoint: #fbbf24;
|
|
--layer-service: #fb923c;
|
|
--layer-core: #f87171;
|
|
--layer-model: #ec4899;
|
|
--layer-db: #c084fc;
|
|
--layer-external: #94a3b8;
|
|
--layer-unknown: #6b7280;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; height: 100%; }
|
|
body {
|
|
background: var(--bg-sidebar);
|
|
color: var(--text-primary);
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
overflow: hidden;
|
|
}
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: 280px 1fr 380px;
|
|
grid-template-rows: 52px 1fr;
|
|
height: 100vh;
|
|
}
|
|
header {
|
|
grid-column: 1 / -1;
|
|
background: var(--bg-sidebar);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
gap: 16px;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-family: var(--heading);
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
color: var(--text-heading);
|
|
letter-spacing: -0.01em;
|
|
}
|
|
header .badge {
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 2px 8px;
|
|
}
|
|
header .spacer { flex: 1; }
|
|
header .meta { font-size: 11px; color: var(--text-muted); }
|
|
header a { color: var(--accent); text-decoration: none; }
|
|
|
|
.sidebar {
|
|
background: var(--bg-sidebar);
|
|
border-right: 1px solid var(--border);
|
|
overflow-y: auto;
|
|
padding: 12px 0;
|
|
}
|
|
.sidebar .group {
|
|
padding: 8px 16px 4px;
|
|
font-family: var(--heading);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-top: 8px;
|
|
}
|
|
.sidebar .group:first-child { margin-top: 0; }
|
|
.sidebar button.flow-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: 0;
|
|
border-left: 2px solid transparent;
|
|
color: var(--text-primary);
|
|
font-family: var(--sans);
|
|
font-size: 13px;
|
|
text-align: left;
|
|
padding: 7px 14px 7px 14px;
|
|
cursor: pointer;
|
|
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
|
|
}
|
|
.sidebar button.flow-item:hover {
|
|
background: var(--bg-card);
|
|
color: var(--text-heading);
|
|
}
|
|
.sidebar button.flow-item.active {
|
|
background: var(--accent-dim);
|
|
border-left-color: var(--accent);
|
|
color: var(--text-heading);
|
|
}
|
|
.sidebar button.flow-item .count {
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
}
|
|
.sidebar button.flow-item.active .count { color: var(--accent); }
|
|
|
|
.canvas-wrap {
|
|
background: var(--bg-page);
|
|
overflow: auto;
|
|
position: relative;
|
|
}
|
|
.canvas-wrap .placeholder {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
text-align: center;
|
|
padding: 24px;
|
|
}
|
|
.canvas-wrap .placeholder h2 {
|
|
font-family: var(--heading);
|
|
color: var(--text-heading);
|
|
margin: 0 0 8px;
|
|
}
|
|
.canvas-wrap .placeholder p { max-width: 420px; margin: 4px 0; }
|
|
|
|
svg.graph { display: block; background: var(--bg-page); }
|
|
.layer-header {
|
|
fill: var(--text-muted);
|
|
font-family: var(--heading);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.column-band { fill: rgba(255,255,255,0.012); }
|
|
|
|
.node rect {
|
|
fill: var(--bg-card);
|
|
stroke: var(--border-hover);
|
|
stroke-width: 1;
|
|
rx: 6;
|
|
}
|
|
.node.in-flow rect { stroke-width: 1.5; }
|
|
.node text.label {
|
|
fill: var(--text-primary);
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
dominant-baseline: middle;
|
|
}
|
|
.node text.sublabel {
|
|
fill: var(--text-dim);
|
|
font-family: var(--sans);
|
|
font-size: 10px;
|
|
dominant-baseline: middle;
|
|
}
|
|
.node .layer-pill {
|
|
rx: 2;
|
|
height: 4;
|
|
}
|
|
.edge {
|
|
fill: none;
|
|
stroke: #4a5061;
|
|
color: #4a5061;
|
|
stroke-width: 1.25;
|
|
opacity: 0.55;
|
|
transition: opacity 0.18s, stroke-width 0.18s, stroke 0.18s, color 0.18s;
|
|
}
|
|
.edge.has-active { opacity: 0.15; }
|
|
.edge.highlight {
|
|
stroke-width: 2.5;
|
|
opacity: 1;
|
|
stroke: var(--warning);
|
|
color: var(--warning);
|
|
}
|
|
|
|
.edge-num {
|
|
cursor: pointer;
|
|
transition: opacity 0.18s;
|
|
}
|
|
.edge-num.dim { opacity: 0.35; }
|
|
.edge-num-bg {
|
|
fill: var(--bg-card);
|
|
stroke: var(--accent);
|
|
stroke-width: 1.25;
|
|
transition: fill 0.18s, stroke 0.18s, r 0.18s;
|
|
}
|
|
.edge-num.highlight .edge-num-bg {
|
|
fill: var(--warning);
|
|
stroke: var(--warning);
|
|
}
|
|
.edge-num-text {
|
|
fill: var(--accent);
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-anchor: middle;
|
|
dominant-baseline: central;
|
|
pointer-events: none;
|
|
transition: fill 0.18s;
|
|
}
|
|
.edge-num.highlight .edge-num-text {
|
|
fill: var(--bg-sidebar);
|
|
}
|
|
.self-indicator {
|
|
fill: var(--text-dim);
|
|
font-family: var(--mono);
|
|
font-size: 9px;
|
|
}
|
|
.self-indicator.highlight { fill: var(--warning); }
|
|
|
|
.panel {
|
|
background: var(--bg-sidebar);
|
|
border-left: 1px solid var(--border);
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
.panel h2 {
|
|
font-family: var(--heading);
|
|
font-size: 15px;
|
|
margin: 0 0 4px;
|
|
color: var(--text-heading);
|
|
}
|
|
.panel .desc {
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
margin: 0 0 16px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.step {
|
|
display: grid;
|
|
grid-template-columns: 24px 1fr;
|
|
gap: 10px;
|
|
padding: 10px 8px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background-color 0.12s;
|
|
margin-bottom: 2px;
|
|
}
|
|
.step:hover { background: var(--bg-card); }
|
|
.step.active { background: var(--accent-dim); }
|
|
.step .num {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-hover);
|
|
border-radius: 50%;
|
|
width: 22px; height: 22px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.step.active .num { background: var(--accent); color: var(--bg-sidebar); border-color: var(--accent); }
|
|
.step .body { min-width: 0; }
|
|
.step .from-to {
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 3px;
|
|
word-break: break-all;
|
|
}
|
|
.step .from-to .arrow { color: var(--accent); }
|
|
.step .label {
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
margin-bottom: 3px;
|
|
}
|
|
.step .passes {
|
|
color: var(--text-muted);
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.step .passes::before { content: "passes: "; color: var(--text-dim); }
|
|
.step .meta {
|
|
display: inline-flex; gap: 6px;
|
|
margin-top: 4px;
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
}
|
|
.step .via-tag {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
.step .unverified {
|
|
background: rgba(251, 191, 36, 0.1);
|
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
|
color: var(--warning);
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* Legend */
|
|
.legend {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
pointer-events: none;
|
|
}
|
|
.legend .row { display: flex; align-items: center; gap: 6px; }
|
|
.legend .dot { width: 8px; height: 8px; border-radius: 2px; }
|
|
|
|
/* Scrollbars */
|
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
::-webkit-scrollbar-track { background: var(--bg-sidebar); }
|
|
::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 5px; }
|
|
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<header>
|
|
<h1>ResolutionFlow Workflows</h1>
|
|
<span class="badge" id="counts">loading…</span>
|
|
<span class="spacer"></span>
|
|
<span class="meta">Click a flow to trace its path. Node-per-file granularity.</span>
|
|
</header>
|
|
<aside class="sidebar" id="sidebar"></aside>
|
|
<main class="canvas-wrap" id="canvas">
|
|
<div class="placeholder" id="placeholder">
|
|
<h2>Pick a flow</h2>
|
|
<p>Each flow is an ordered trace of how a user action moves through the codebase — page → component → API client → endpoint → service → DB/external.</p>
|
|
<p>Nodes are <strong>individual files</strong>. Numbered arrows show data flow direction; click a step in the right panel to highlight it.</p>
|
|
</div>
|
|
</main>
|
|
<aside class="panel" id="panel">
|
|
<h2>Steps</h2>
|
|
<p class="desc">Select a flow on the left.</p>
|
|
</aside>
|
|
</div>
|
|
|
|
<script>
|
|
const LAYER_ORDER = [
|
|
"page", "component", "hook", "store",
|
|
"api_client", "http_client", "endpoint",
|
|
"service", "core", "model", "external"
|
|
];
|
|
const LAYER_LABEL = {
|
|
page: "Page", component: "Component", hook: "Hook", store: "Store",
|
|
api_client: "API Client", http_client: "HTTP Client", endpoint: "Endpoint",
|
|
service: "Service", core: "Core", model: "Model", external: "External"
|
|
};
|
|
const LAYER_COLOR = (l) => getComputedStyle(document.documentElement).getPropertyValue(`--layer-${l}`).trim() || "#888";
|
|
|
|
const COLUMN_WIDTH = 260;
|
|
const COLUMN_PADDING = 28;
|
|
const NODE_HEIGHT = 44;
|
|
const NODE_WIDTH = 220;
|
|
const NODE_GAP = 12;
|
|
const HEADER_HEIGHT = 36;
|
|
const CANVAS_PADDING_TOP = 24;
|
|
const CANVAS_PADDING_BOTTOM = 40;
|
|
|
|
let DATA = null;
|
|
let nodeById = {};
|
|
let activeFlowId = null;
|
|
let activeStepIndex = null;
|
|
|
|
fetch("workflows.json").then(r => r.json()).then(d => { DATA = d; init(); }).catch(err => {
|
|
document.getElementById("placeholder").innerHTML = `<h2>Couldn't load workflows.json</h2><p>${err}</p><p>Serve this directory with a static server, then open workflows.html — opening directly via file:// may be blocked by CORS.</p>`;
|
|
});
|
|
|
|
function init() {
|
|
for (const n of DATA.nodes) nodeById[n.id] = n;
|
|
document.getElementById("counts").textContent = `${DATA.nodes.length} files · ${DATA.flows.length} flows`;
|
|
renderSidebar();
|
|
}
|
|
|
|
function renderSidebar() {
|
|
const sb = document.getElementById("sidebar");
|
|
const groups = {};
|
|
for (const f of DATA.flows) (groups[f.group] = groups[f.group] || []).push(f);
|
|
const groupOrder = ["Auth & Access", "Sessions & FlowPilot", "Flow Authoring", "Integrations", "Team & Billing", "Tools"];
|
|
const ordered = groupOrder.filter(g => groups[g]).concat(Object.keys(groups).filter(g => !groupOrder.includes(g)));
|
|
let html = "";
|
|
for (const g of ordered) {
|
|
html += `<div class="group">${escapeHtml(g)}</div>`;
|
|
for (const f of groups[g]) {
|
|
html += `<button class="flow-item" data-flow="${escapeHtml(f.id)}">
|
|
<span>${escapeHtml(f.label)}</span>
|
|
<span class="count">${f.steps.length}</span>
|
|
</button>`;
|
|
}
|
|
}
|
|
sb.innerHTML = html;
|
|
sb.querySelectorAll("button.flow-item").forEach(btn => {
|
|
btn.addEventListener("click", () => selectFlow(btn.dataset.flow));
|
|
});
|
|
}
|
|
|
|
function selectFlow(flowId) {
|
|
activeFlowId = flowId;
|
|
activeStepIndex = null;
|
|
document.querySelectorAll(".sidebar .flow-item").forEach(b => b.classList.toggle("active", b.dataset.flow === flowId));
|
|
const flow = DATA.flows.find(f => f.id === flowId);
|
|
renderGraph(flow);
|
|
renderPanel(flow);
|
|
}
|
|
|
|
// Compute layout: each flow's nodes positioned in layer columns.
|
|
function layoutFlow(flow) {
|
|
// Collect distinct nodes referenced by the flow (in step order)
|
|
const seen = new Set();
|
|
const nodes = [];
|
|
for (const s of flow.steps) {
|
|
for (const ep of [s.from, s.to]) {
|
|
if (!seen.has(ep) && nodeById[ep]) { seen.add(ep); nodes.push(nodeById[ep]); }
|
|
}
|
|
}
|
|
// Group by layer, preserve first-appearance order within layer
|
|
const byLayer = {};
|
|
for (const n of nodes) (byLayer[n.layer] = byLayer[n.layer] || []).push(n);
|
|
|
|
const activeLayers = LAYER_ORDER.filter(l => byLayer[l] && byLayer[l].length);
|
|
const positions = {};
|
|
let maxRows = 0;
|
|
activeLayers.forEach((layer, colIdx) => {
|
|
const col = byLayer[layer];
|
|
col.forEach((node, rowIdx) => {
|
|
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH;
|
|
const y = CANVAS_PADDING_TOP + HEADER_HEIGHT + rowIdx * (NODE_HEIGHT + NODE_GAP);
|
|
positions[node.id] = { x, y, w: NODE_WIDTH, h: NODE_HEIGHT, node, layer, col: colIdx, row: rowIdx };
|
|
});
|
|
maxRows = Math.max(maxRows, col.length);
|
|
});
|
|
|
|
const width = COLUMN_PADDING * 2 + activeLayers.length * COLUMN_WIDTH;
|
|
const height = CANVAS_PADDING_TOP + HEADER_HEIGHT + maxRows * (NODE_HEIGHT + NODE_GAP) + CANVAS_PADDING_BOTTOM;
|
|
return { positions, activeLayers, width, height };
|
|
}
|
|
|
|
function renderGraph(flow) {
|
|
const canvas = document.getElementById("canvas");
|
|
const placeholder = document.getElementById("placeholder");
|
|
if (placeholder) placeholder.style.display = "none";
|
|
const layout = layoutFlow(flow);
|
|
const svg = createSvg(layout.width, layout.height);
|
|
|
|
// Column band backgrounds
|
|
layout.activeLayers.forEach((layer, colIdx) => {
|
|
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH - 10;
|
|
svg.appendChild(rect(x, CANVAS_PADDING_TOP - 8, NODE_WIDTH + 20, layout.height - CANVAS_PADDING_TOP, "column-band"));
|
|
const headerEl = text(x + NODE_WIDTH / 2 + 10, CANVAS_PADDING_TOP + 12, LAYER_LABEL[layer] || layer, "layer-header");
|
|
headerEl.setAttribute("text-anchor", "middle");
|
|
svg.appendChild(headerEl);
|
|
});
|
|
|
|
// Dedupe: group steps by (from, to). One curve per unique pair.
|
|
// Track separate counts of mutual pairs (A→B and B→A both exist) so we offset their curves.
|
|
const edgeGroups = new Map();
|
|
const selfSteps = new Map(); // node id → [step indexes] for from===to (state updates)
|
|
flow.steps.forEach((step, idx) => {
|
|
if (step.from === step.to) {
|
|
const arr = selfSteps.get(step.from) || [];
|
|
arr.push(idx);
|
|
selfSteps.set(step.from, arr);
|
|
return;
|
|
}
|
|
const key = step.from + ">>" + step.to;
|
|
const grp = edgeGroups.get(key) || { from: step.from, to: step.to, steps: [] };
|
|
grp.steps.push(idx);
|
|
edgeGroups.set(key, grp);
|
|
});
|
|
// Detect mutual pairs (both A→B and B→A present) so we curve them apart
|
|
const mutualPairs = new Set();
|
|
for (const [key, grp] of edgeGroups) {
|
|
const reverseKey = grp.to + ">>" + grp.from;
|
|
if (edgeGroups.has(reverseKey)) mutualPairs.add(key);
|
|
}
|
|
|
|
const edgesGroup = group("edges");
|
|
svg.appendChild(edgesGroup);
|
|
const badgesGroup = group("badges");
|
|
// (badges added later — drawn over nodes)
|
|
|
|
for (const [key, grp] of edgeGroups) {
|
|
const fromPos = layout.positions[grp.from];
|
|
const toPos = layout.positions[grp.to];
|
|
if (!fromPos || !toPos) continue;
|
|
const isMutual = mutualPairs.has(key);
|
|
drawEdgeGroup(edgesGroup, badgesGroup, fromPos, toPos, grp, isMutual, flow.steps);
|
|
}
|
|
|
|
// Nodes
|
|
const nodeGroup = group("nodes");
|
|
svg.appendChild(nodeGroup);
|
|
for (const id in layout.positions) {
|
|
const pos = layout.positions[id];
|
|
drawNode(nodeGroup, pos, selfSteps.get(id) || []);
|
|
}
|
|
|
|
// Append badges last so they sit above nodes
|
|
svg.appendChild(badgesGroup);
|
|
|
|
canvas.innerHTML = "";
|
|
canvas.appendChild(svg);
|
|
svg.addEventListener("click", (e) => {
|
|
// Click on empty SVG background clears highlight
|
|
if (e.target === svg || e.target.classList.contains("column-band")) clearStepHighlight();
|
|
});
|
|
|
|
// Legend
|
|
const legend = document.createElement("div");
|
|
legend.className = "legend";
|
|
legend.innerHTML = `
|
|
<div class="row"><strong style="color:var(--text-primary)">Layers</strong></div>
|
|
${layout.activeLayers.map(l => `<div class="row"><span class="dot" style="background:${LAYER_COLOR(l)}"></span>${LAYER_LABEL[l]||l}</div>`).join("")}
|
|
`;
|
|
canvas.appendChild(legend);
|
|
}
|
|
|
|
function drawEdgeGroup(edgeLayer, badgeLayer, from, to, grp, isMutual, allSteps) {
|
|
const forward = to.col >= from.col;
|
|
// Anchor on the side of the node that fits the direction.
|
|
let fromX, fromY, toX, toY;
|
|
if (forward) {
|
|
fromX = from.x + from.w; toX = to.x;
|
|
} else {
|
|
// Backward: exit from left of source, enter right of target
|
|
fromX = from.x; toX = to.x + to.w;
|
|
}
|
|
fromY = from.y + from.h / 2;
|
|
toY = to.y + to.h / 2;
|
|
|
|
// Offset mutual pairs perpendicularly so the two arrows don't sit on top of each other.
|
|
// Forward edges get a small upward offset; backward gets downward (or vice versa) when mutual.
|
|
let perpOffset = 0;
|
|
if (isMutual) perpOffset = forward ? -18 : 18;
|
|
|
|
// Bezier control points
|
|
const dx = toX - fromX;
|
|
const dy = toY - fromY;
|
|
let c1x, c1y, c2x, c2y;
|
|
if (forward) {
|
|
const horiz = Math.max(50, Math.abs(dx) * 0.4);
|
|
c1x = fromX + horiz;
|
|
c2x = toX - horiz;
|
|
c1y = fromY + perpOffset;
|
|
c2y = toY + perpOffset;
|
|
} else {
|
|
// Backward — arc out to the side, then back. We use a wide horizontal sweep.
|
|
const sweep = 80 + Math.abs(dy) * 0.15;
|
|
c1x = fromX - sweep;
|
|
c2x = toX + sweep;
|
|
c1y = fromY + perpOffset;
|
|
c2y = toY + perpOffset;
|
|
}
|
|
const pathD = `M ${fromX} ${fromY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${toX} ${toY}`;
|
|
const stepNums = grp.steps.map(i => i + 1).join(",");
|
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
path.setAttribute("d", pathD);
|
|
path.setAttribute("class", "edge");
|
|
path.setAttribute("data-steps", stepNums);
|
|
path.setAttribute("marker-end", "url(#arrow)");
|
|
edgeLayer.appendChild(path);
|
|
|
|
// Step-number badges placed along the curve at evenly spaced t-values.
|
|
const k = grp.steps.length;
|
|
const SPREAD = Math.min(0.18, 0.55 / Math.max(1, k));
|
|
grp.steps.forEach((stepIdx, i) => {
|
|
// t centered around 0.5 — for k=1, t=0.5; for k=2 t=0.41, 0.59; etc.
|
|
const t = 0.5 + (i - (k - 1) / 2) * SPREAD;
|
|
const p = cubicAt(t, [fromX, fromY], [c1x, c1y], [c2x, c2y], [toX, toY]);
|
|
const numG = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
numG.setAttribute("data-step", stepIdx + 1);
|
|
numG.setAttribute("class", "edge-num");
|
|
numG.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
|
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
circle.setAttribute("cx", p.x);
|
|
circle.setAttribute("cy", p.y);
|
|
circle.setAttribute("r", 9);
|
|
circle.setAttribute("class", "edge-num-bg");
|
|
numG.appendChild(circle);
|
|
const numText = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
numText.setAttribute("x", p.x);
|
|
numText.setAttribute("y", p.y);
|
|
numText.setAttribute("class", "edge-num-text");
|
|
numText.textContent = stepIdx + 1;
|
|
numG.appendChild(numText);
|
|
const step = allSteps[stepIdx];
|
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
title.textContent = `${stepIdx + 1}. ${step.label}\n${step.via}\npasses: ${step.passes}`;
|
|
numG.appendChild(title);
|
|
badgeLayer.appendChild(numG);
|
|
});
|
|
}
|
|
|
|
function cubicAt(t, P0, P1, P2, P3) {
|
|
const mt = 1 - t;
|
|
const x = mt*mt*mt*P0[0] + 3*mt*mt*t*P1[0] + 3*mt*t*t*P2[0] + t*t*t*P3[0];
|
|
const y = mt*mt*mt*P0[1] + 3*mt*mt*t*P1[1] + 3*mt*t*t*P2[1] + t*t*t*P3[1];
|
|
return { x, y };
|
|
}
|
|
|
|
function drawNode(parent, pos, selfStepIdxs) {
|
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
g.setAttribute("class", "node in-flow");
|
|
g.setAttribute("transform", `translate(${pos.x}, ${pos.y})`);
|
|
const r = rect(0, 0, pos.w, pos.h);
|
|
g.appendChild(r);
|
|
// Layer-color top pill
|
|
const pill = rect(0, 0, pos.w, 4, "layer-pill");
|
|
pill.setAttribute("fill", LAYER_COLOR(pos.layer));
|
|
pill.setAttribute("rx", 6);
|
|
g.appendChild(pill);
|
|
|
|
const label = text(10, pos.h / 2 - 7, pos.node.label, "label");
|
|
g.appendChild(label);
|
|
const fp = pos.node.file;
|
|
let sub = fp;
|
|
if (sub.startsWith("external:")) sub = sub.replace("external:", "ext · ");
|
|
else if (sub.length > 32) {
|
|
const parts = sub.split("/");
|
|
sub = parts.slice(-3).join("/");
|
|
if (sub.length > 32) sub = "…/" + parts[parts.length - 1];
|
|
}
|
|
const subEl = text(10, pos.h / 2 + 8, sub, "sublabel");
|
|
g.appendChild(subEl);
|
|
|
|
// Self-loop steps (from === to, typically state_update) — render as a small "↻ N" indicator
|
|
// in the upper-right of the node, clickable to highlight that step.
|
|
if (selfStepIdxs.length) {
|
|
const xRight = pos.w - 8;
|
|
selfStepIdxs.slice(0, 3).forEach((stepIdx, i) => {
|
|
const sg = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
sg.setAttribute("class", "edge-num self-step");
|
|
sg.setAttribute("data-step", stepIdx + 1);
|
|
sg.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
|
|
const cx = xRight - i * 18;
|
|
const cy = 14;
|
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
circle.setAttribute("cx", cx);
|
|
circle.setAttribute("cy", cy);
|
|
circle.setAttribute("r", 8);
|
|
circle.setAttribute("class", "edge-num-bg");
|
|
sg.appendChild(circle);
|
|
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
t.setAttribute("x", cx);
|
|
t.setAttribute("y", cy);
|
|
t.setAttribute("class", "edge-num-text");
|
|
t.textContent = stepIdx + 1;
|
|
sg.appendChild(t);
|
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
title.textContent = `Step ${stepIdx + 1} (self · stays on this node)`;
|
|
sg.appendChild(title);
|
|
g.appendChild(sg);
|
|
});
|
|
}
|
|
|
|
// Hover tooltip
|
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
title.textContent = `${pos.node.label}\n${pos.node.file}\n${pos.node.description || ""}`;
|
|
g.appendChild(title);
|
|
|
|
parent.appendChild(g);
|
|
}
|
|
|
|
function renderPanel(flow) {
|
|
const p = document.getElementById("panel");
|
|
let html = `<h2>${escapeHtml(flow.label)}</h2>`;
|
|
if (flow.description) html += `<p class="desc">${escapeHtml(flow.description)}</p>`;
|
|
flow.steps.forEach((step, idx) => {
|
|
const fromNode = nodeById[step.from], toNode = nodeById[step.to];
|
|
const fromLabel = fromNode ? fromNode.label : step.from;
|
|
const toLabel = toNode ? toNode.label : step.to;
|
|
html += `<div class="step" data-step="${idx}">
|
|
<div class="num">${idx + 1}</div>
|
|
<div class="body">
|
|
<div class="from-to">${escapeHtml(fromLabel)} <span class="arrow">→</span> ${escapeHtml(toLabel)}</div>
|
|
<div class="label">${escapeHtml(step.label || "(unlabeled)")}</div>
|
|
${step.passes ? `<div class="passes">${escapeHtml(step.passes)}</div>` : ""}
|
|
<div class="meta">
|
|
<span class="via-tag">${escapeHtml(step.via)}</span>
|
|
${step.unverified ? `<span class="unverified">unverified</span>` : ""}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
p.innerHTML = html;
|
|
p.querySelectorAll(".step").forEach(el => {
|
|
el.addEventListener("click", () => highlightStep(+el.dataset.step));
|
|
});
|
|
p.scrollTop = 0;
|
|
}
|
|
|
|
function highlightStep(idx) {
|
|
activeStepIndex = idx;
|
|
const stepNum = idx + 1;
|
|
document.querySelectorAll(".panel .step").forEach((el, i) => el.classList.toggle("active", i === idx));
|
|
// Edges: a path can carry multiple steps (comma-separated). Highlight if this stepNum is in its list.
|
|
document.querySelectorAll("svg.graph .edge").forEach(e => {
|
|
const nums = (e.getAttribute("data-steps") || "").split(",").map(Number);
|
|
const isOn = nums.includes(stepNum);
|
|
e.classList.toggle("highlight", isOn);
|
|
e.classList.toggle("has-active", !isOn);
|
|
});
|
|
// Step-number badges: highlight the one matching this step; others stay visible but dimmed.
|
|
document.querySelectorAll("svg.graph .edge-num").forEach(g => {
|
|
const n = +g.getAttribute("data-step");
|
|
g.classList.toggle("highlight", n === stepNum);
|
|
g.classList.toggle("dim", n !== stepNum);
|
|
});
|
|
const active = document.querySelector(".panel .step.active");
|
|
if (active && typeof active.scrollIntoView === "function") {
|
|
active.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
}
|
|
|
|
function clearStepHighlight() {
|
|
activeStepIndex = null;
|
|
document.querySelectorAll(".panel .step.active").forEach(el => el.classList.remove("active"));
|
|
document.querySelectorAll("svg.graph .edge").forEach(e => { e.classList.remove("highlight", "has-active"); });
|
|
document.querySelectorAll("svg.graph .edge-num").forEach(g => { g.classList.remove("highlight", "dim"); });
|
|
}
|
|
|
|
/* ---------- SVG helpers ---------- */
|
|
function createSvg(w, h) {
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
svg.setAttribute("class", "graph");
|
|
svg.setAttribute("width", w);
|
|
svg.setAttribute("height", h);
|
|
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
|
// Arrow marker
|
|
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
|
defs.innerHTML = `
|
|
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
|
|
</marker>`;
|
|
svg.appendChild(defs);
|
|
return svg;
|
|
}
|
|
function rect(x, y, w, h, cls) {
|
|
const r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
r.setAttribute("x", x); r.setAttribute("y", y);
|
|
r.setAttribute("width", w); r.setAttribute("height", h);
|
|
if (cls) r.setAttribute("class", cls);
|
|
return r;
|
|
}
|
|
function text(x, y, str, cls) {
|
|
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
t.setAttribute("x", x); t.setAttribute("y", y);
|
|
if (cls) t.setAttribute("class", cls);
|
|
t.textContent = str;
|
|
return t;
|
|
}
|
|
function group(cls) {
|
|
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
if (cls) g.setAttribute("class", cls);
|
|
return g;
|
|
}
|
|
function escapeHtml(s) {
|
|
if (s == null) return "";
|
|
return String(s).replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c]));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|