| | <!doctype html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="utf-8" /> |
| | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| | <title>Workflow Node Map</title> |
| | <style> |
| | :root{ |
| | --bg:#0f0f0f; |
| | --panel:#171717; |
| | --panel2:#1f1f1f; |
| | --text:#eaeaea; |
| | --muted:#b5b5b5; |
| | --border:#2c2c2c; |
| | --edge:#8a8a8a; |
| | --edge-muted:#3f3f3f; |
| | --shadow: 0 10px 30px rgba(0,0,0,.35); |
| | --radius: 12px; |
| | --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
| | --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; |
| | } |
| | *{ box-sizing:border-box; } |
| | html,body{ height:100%; } |
| | body{ |
| | margin:0; |
| | background:var(--bg); |
| | color:var(--text); |
| | font-family:var(--sans); |
| | overflow:hidden; |
| | } |
| | |
| | |
| | #topbar{ |
| | height:56px; |
| | display:flex; |
| | align-items:center; |
| | justify-content:space-between; |
| | padding:0 14px; |
| | border-bottom:1px solid var(--border); |
| | background:linear-gradient(to bottom, #121212, #0f0f0f); |
| | } |
| | #titleBlock{ |
| | display:flex; |
| | flex-direction:column; |
| | gap:2px; |
| | min-width: 220px; |
| | } |
| | #wfName{ |
| | font-weight:650; |
| | font-size:14px; |
| | letter-spacing:.2px; |
| | line-height:1.1; |
| | white-space:nowrap; |
| | overflow:hidden; |
| | text-overflow:ellipsis; |
| | } |
| | #wfDesc{ |
| | font-size:12px; |
| | color:var(--muted); |
| | white-space:nowrap; |
| | overflow:hidden; |
| | text-overflow:ellipsis; |
| | max-width: 54vw; |
| | } |
| | #controls{ |
| | display:flex; |
| | align-items:center; |
| | gap:10px; |
| | } |
| | .btn{ |
| | appearance:none; |
| | background:transparent; |
| | color:var(--text); |
| | border:1px solid var(--border); |
| | border-radius:10px; |
| | padding:8px 10px; |
| | font-size:12px; |
| | cursor:pointer; |
| | transition:transform .05s ease, border-color .2s ease, background .2s ease; |
| | user-select:none; |
| | } |
| | .btn:hover{ border-color:#3a3a3a; background:#141414; } |
| | .btn:active{ transform: translateY(1px); } |
| | .sep{ width:1px; height:20px; background:var(--border); margin:0 2px; } |
| | |
| | #hint{ |
| | font-size:12px; |
| | color:var(--muted); |
| | user-select:none; |
| | white-space:nowrap; |
| | } |
| | |
| | |
| | #viewport{ |
| | height: calc(100vh - 56px); |
| | overflow:auto; |
| | position:relative; |
| | } |
| | #canvas{ |
| | position:relative; |
| | |
| | |
| | |
| | |
| | |
| | min-width: 2600px; |
| | min-height: 1600px; |
| | padding: 24px; |
| | box-sizing: border-box; |
| | } |
| | |
| | |
| | #edges{ |
| | position:absolute; |
| | inset:0; |
| | width:100%; |
| | height:100%; |
| | pointer-events:none; |
| | overflow:visible; |
| | z-index: 1; |
| | } |
| | .edge{ |
| | stroke: var(--edge); |
| | stroke-width: 1.6; |
| | fill: none; |
| | opacity: .85; |
| | } |
| | .edge.dim{ opacity:.18; } |
| | .edge.highlight{ opacity:1; stroke-width:2.2; } |
| | |
| | |
| | .node{ |
| | position:absolute; |
| | width: 340px; |
| | background: var(--panel); |
| | border:1px solid var(--border); |
| | border-radius: var(--radius); |
| | box-shadow: var(--shadow); |
| | z-index: 2; |
| | user-select:none; |
| | touch-action: none; |
| | } |
| | .node:focus{ outline:none; box-shadow: 0 0 0 2px #3a3a3a, var(--shadow); } |
| | |
| | .node-header{ |
| | padding: 12px 12px 10px; |
| | border-bottom:1px solid var(--border); |
| | background: var(--panel2); |
| | border-top-left-radius: var(--radius); |
| | border-top-right-radius: var(--radius); |
| | cursor: grab; |
| | display:flex; |
| | align-items:flex-start; |
| | justify-content:space-between; |
| | gap:10px; |
| | } |
| | .node-header:active{ cursor: grabbing; } |
| | |
| | |
| | .node-titlewrap{ |
| | display:flex; |
| | flex-direction:column; |
| | gap:2px; |
| | min-width:0; |
| | flex: 1 1 auto; |
| | } |
| | |
| | .node-title{ |
| | font-weight: 650; |
| | font-size: 13px; |
| | line-height: 1.2; |
| | letter-spacing: .2px; |
| | overflow:hidden; |
| | text-overflow:ellipsis; |
| | white-space:nowrap; |
| | } |
| | |
| | |
| | .node-desc-collapsed{ |
| | display:none; |
| | font-size: 11px; |
| | line-height: 1.25; |
| | color: var(--muted); |
| | opacity: .92; |
| | overflow:hidden; |
| | text-overflow:ellipsis; |
| | } |
| | .node-desc-collapsed:empty{ display:none !important; } |
| | .node.collapsed .node-desc-collapsed{ |
| | display:block; |
| | display:-webkit-box; |
| | -webkit-box-orient: vertical; |
| | -webkit-line-clamp: 2; |
| | } |
| | |
| | .node-badges{ |
| | display:flex; |
| | align-items:center; |
| | gap:6px; |
| | flex: 0 0 auto; |
| | } |
| | .badge{ |
| | font-family: var(--mono); |
| | font-size: 10px; |
| | padding: 2px 6px; |
| | border-radius: 999px; |
| | border:1px solid var(--border); |
| | color: var(--muted); |
| | background: #141414; |
| | max-width: 120px; |
| | overflow:hidden; |
| | text-overflow:ellipsis; |
| | white-space:nowrap; |
| | } |
| | |
| | .node-body{ |
| | padding: 12px; |
| | display:block; |
| | font-size: 12px; |
| | line-height: 1.35; |
| | color: var(--text); |
| | } |
| | .node.collapsed .node-body{ |
| | display:none; |
| | } |
| | .node.collapsed .node-header{ |
| | border-bottom: none; |
| | } |
| | .node-meta{ |
| | color: var(--muted); |
| | font-size: 11px; |
| | margin-bottom: 10px; |
| | font-family: var(--mono); |
| | display:flex; |
| | flex-wrap:wrap; |
| | gap:8px; |
| | } |
| | .kv{ |
| | border:1px solid var(--border); |
| | padding: 3px 6px; |
| | border-radius: 8px; |
| | background:#121212; |
| | } |
| | |
| | .section{ |
| | margin-top: 10px; |
| | border-top: 1px dashed #2e2e2e; |
| | padding-top: 10px; |
| | } |
| | .section h4{ |
| | margin: 0 0 8px; |
| | font-size: 11px; |
| | letter-spacing: .2px; |
| | text-transform: uppercase; |
| | color: var(--muted); |
| | font-weight: 650; |
| | } |
| | .desc{ |
| | color: var(--text); |
| | margin: 0 0 8px; |
| | opacity: .95; |
| | } |
| | .pillRow{ |
| | display:flex; |
| | flex-wrap:wrap; |
| | gap:6px; |
| | } |
| | .pill{ |
| | font-size: 11px; |
| | color: var(--muted); |
| | border:1px solid var(--border); |
| | background:#121212; |
| | border-radius: 999px; |
| | padding: 3px 8px; |
| | font-family: var(--mono); |
| | } |
| | .empty{ |
| | color: var(--muted); |
| | font-style: italic; |
| | font-size: 11px; |
| | } |
| | |
| | table.schema{ |
| | width:100%; |
| | border-collapse: collapse; |
| | table-layout: fixed; |
| | border:1px solid var(--border); |
| | border-radius: 10px; |
| | overflow:hidden; |
| | } |
| | table.schema thead th{ |
| | background:#121212; |
| | color: var(--muted); |
| | font-size: 10px; |
| | letter-spacing: .2px; |
| | text-transform: uppercase; |
| | padding: 8px 8px; |
| | border-bottom: 1px solid var(--border); |
| | font-weight: 650; |
| | } |
| | table.schema td{ |
| | padding: 8px 8px; |
| | border-bottom: 1px solid #242424; |
| | vertical-align: top; |
| | word-break: break-word; |
| | } |
| | table.schema tr:last-child td{ border-bottom:none; } |
| | .mono{ font-family: var(--mono); } |
| | .right{ text-align:right; } |
| | .small{ font-size: 11px; color: var(--muted); } |
| | |
| | |
| | .node:hover{ border-color:#3a3a3a; } |
| | .node.dragging{ opacity: .95; border-color:#4a4a4a; } |
| | |
| | |
| | #overlay{ |
| | position:absolute; |
| | inset:0; |
| | display:flex; |
| | align-items:center; |
| | justify-content:center; |
| | z-index: 10; |
| | background: rgba(0,0,0,.35); |
| | backdrop-filter: blur(3px); |
| | } |
| | #overlay.hidden{ display:none; } |
| | #overlayCard{ |
| | width:min(560px, 92vw); |
| | background: var(--panel); |
| | border: 1px solid var(--border); |
| | border-radius: 14px; |
| | padding: 16px; |
| | box-shadow: var(--shadow); |
| | } |
| | #overlayTitle{ |
| | font-weight: 650; |
| | font-size: 14px; |
| | margin:0 0 8px; |
| | } |
| | #overlayText{ |
| | margin:0; |
| | color: var(--muted); |
| | font-size: 12px; |
| | line-height: 1.4; |
| | font-family: var(--mono); |
| | white-space: pre-wrap; |
| | } |
| | </style> |
| | </head> |
| | <body> |
| | <div id="topbar"> |
| | <div id="titleBlock"> |
| | <div id="wfName">Workflow Node Map</div> |
| | <div id="wfDesc">Reads workflow.json and renders a draggable node map (minimal grayscale).</div> |
| | </div> |
| |
|
| | <div id="controls"> |
| | <button class="btn" id="btnExpand">Expand All</button> |
| | <button class="btn" id="btnCollapse">Collapse All</button> |
| | <div class="sep"></div> |
| | <button class="btn" id="btnReset">Reset Layout</button> |
| | <div class="sep"></div> |
| | <div id="hint">Tip: drag nodes; click the header to expand/collapse.</div> |
| | </div> |
| | </div> |
| |
|
| | <div id="viewport"> |
| | <div id="canvas"> |
| | <svg id="edges" aria-hidden="true"> |
| | <defs> |
| | <marker id="arrowHead" markerWidth="10" markerHeight="10" refX="8.7" refY="3" orient="auto" markerUnits="strokeWidth"> |
| | <path d="M0,0 L9,3 L0,6 Z" fill="var(--edge)"></path> |
| | </marker> |
| | </defs> |
| | </svg> |
| | </div> |
| |
|
| | <div id="overlay"> |
| | <div id="overlayCard"> |
| | <p id="overlayTitle">Loading…</p> |
| | <p id="overlayText">Reading JSON and rendering nodes…</p> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <script> |
| | (function(){ |
| | const SVG_NS = "http://www.w3.org/2000/svg"; |
| | |
| | const viewport = document.getElementById("viewport"); |
| | const canvas = document.getElementById("canvas"); |
| | const edgesSvg = document.getElementById("edges"); |
| | |
| | const overlay = document.getElementById("overlay"); |
| | const overlayTitle = document.getElementById("overlayTitle"); |
| | const overlayText = document.getElementById("overlayText"); |
| | |
| | const wfNameEl = document.getElementById("wfName"); |
| | const wfDescEl = document.getElementById("wfDesc"); |
| | |
| | const btnExpand = document.getElementById("btnExpand"); |
| | const btnCollapse = document.getElementById("btnCollapse"); |
| | const btnReset = document.getElementById("btnReset"); |
| | |
| | const params = new URLSearchParams(location.search); |
| | const DATA_URL = params.get("data") || "node_map/workflow.json"; |
| | |
| | const state = { |
| | data: null, |
| | nodesById: new Map(), |
| | nodeEls: new Map(), |
| | edges: [], |
| | layoutKey: null, |
| | saveTimer: null |
| | }; |
| | |
| | function escapeHtml(s){ |
| | return String(s) |
| | .replaceAll("&","&") |
| | .replaceAll("<","<") |
| | .replaceAll(">",">") |
| | .replaceAll('"',""") |
| | .replaceAll("'","'"); |
| | } |
| | |
| | function formatValue(v){ |
| | if (v === undefined) return ""; |
| | if (v === null) return "null"; |
| | if (typeof v === "string") return v; |
| | try { return JSON.stringify(v); } catch (e) { return String(v); } |
| | } |
| | |
| | function setOverlay(title, text, hidden){ |
| | overlayTitle.textContent = title || ""; |
| | overlayText.textContent = text || ""; |
| | overlay.classList.toggle("hidden", !!hidden); |
| | } |
| | |
| | function computeDepths(nodes){ |
| | const byId = new Map(nodes.map(n => [n.id, n])); |
| | const depth = new Map(nodes.map(n => [n.id, 0])); |
| | |
| | |
| | const MAX_ITERS = nodes.length + 5; |
| | for(let i=0;i<MAX_ITERS;i++){ |
| | let changed = false; |
| | for(const n of nodes){ |
| | const deps = Array.isArray(n.dependencies) ? n.dependencies : []; |
| | if(!deps.length) continue; |
| | let maxD = 0; |
| | for(const depId of deps){ |
| | if(!byId.has(depId)) continue; |
| | maxD = Math.max(maxD, (depth.get(depId) || 0) + 1); |
| | } |
| | if(maxD !== (depth.get(n.id) || 0)){ |
| | depth.set(n.id, maxD); |
| | changed = true; |
| | } |
| | } |
| | if(!changed) break; |
| | } |
| | return depth; |
| | } |
| | |
| | function autoLayout(nodes){ |
| | const depth = computeDepths(nodes); |
| | const cols = new Map(); |
| | for(const n of nodes){ |
| | const d = depth.get(n.id) || 0; |
| | if(!cols.has(d)) cols.set(d, []); |
| | cols.get(d).push(n.id); |
| | } |
| | |
| | for(const [d, arr] of cols){ |
| | arr.sort((a,b)=>{ |
| | const na = state.nodesById.get(a)?.name || a; |
| | const nb = state.nodesById.get(b)?.name || b; |
| | return na.localeCompare(nb); |
| | }); |
| | } |
| | |
| | const columnWidth = 420; |
| | const rowHeight = 170; |
| | |
| | const positions = {}; |
| | const depths = Array.from(cols.keys()).sort((a,b)=>a-b); |
| | for(const d of depths){ |
| | const arr = cols.get(d); |
| | for(let i=0;i<arr.length;i++){ |
| | const id = arr[i]; |
| | positions[id] = { |
| | x: 40 + d * columnWidth, |
| | y: 40 + i * rowHeight |
| | }; |
| | } |
| | } |
| | return positions; |
| | } |
| | |
| | |
| | |
| | |
| | function ensureCanvasSize(){ |
| | const BASE_W = 2600; |
| | const BASE_H = 1600; |
| | const EXTRA = 260; |
| | |
| | if(!state.nodeEls || state.nodeEls.size === 0) return; |
| | |
| | let maxRight = 0; |
| | let maxBottom = 0; |
| | |
| | for(const el of state.nodeEls.values()){ |
| | const left = parseFloat(el.style.left || "0") || 0; |
| | const top = parseFloat(el.style.top || "0") || 0; |
| | const right = left + el.offsetWidth; |
| | const bottom = top + el.offsetHeight; |
| | maxRight = Math.max(maxRight, right); |
| | maxBottom = Math.max(maxBottom, bottom); |
| | } |
| | |
| | const desiredW = Math.max(BASE_W, Math.ceil(maxRight + EXTRA)); |
| | const desiredH = Math.max(BASE_H, Math.ceil(maxBottom + EXTRA)); |
| | |
| | |
| | const currentW = canvas.clientWidth || 0; |
| | const currentH = canvas.clientHeight || 0; |
| | |
| | if(desiredW > currentW) canvas.style.width = desiredW + "px"; |
| | if(desiredH > currentH) canvas.style.height = desiredH + "px"; |
| | } |
| | |
| | function loadLayout(){ |
| | if(!state.layoutKey) return null; |
| | try{ |
| | const raw = localStorage.getItem(state.layoutKey); |
| | if(!raw) return null; |
| | return JSON.parse(raw); |
| | }catch(e){ |
| | return null; |
| | } |
| | } |
| | |
| | function saveLayoutDebounced(){ |
| | if(!state.layoutKey) return; |
| | clearTimeout(state.saveTimer); |
| | state.saveTimer = setTimeout(saveLayout, 120); |
| | } |
| | |
| | function saveLayout(){ |
| | if(!state.layoutKey) return; |
| | const positions = {}; |
| | const collapsed = {}; |
| | for(const [id, el] of state.nodeEls){ |
| | positions[id] = { |
| | x: parseFloat(el.style.left || "0"), |
| | y: parseFloat(el.style.top || "0") |
| | }; |
| | collapsed[id] = el.classList.contains("collapsed"); |
| | } |
| | const payload = { positions, collapsed }; |
| | try{ |
| | localStorage.setItem(state.layoutKey, JSON.stringify(payload)); |
| | }catch(e){ |
| | |
| | } |
| | } |
| | |
| | function resetLayout(){ |
| | if(state.layoutKey){ |
| | localStorage.removeItem(state.layoutKey); |
| | } |
| | const positions = autoLayout(state.data.nodes); |
| | for(const n of state.data.nodes){ |
| | const el = state.nodeEls.get(n.id); |
| | if(!el) continue; |
| | const p = positions[n.id] || {x:40,y:40}; |
| | el.style.left = p.x + "px"; |
| | el.style.top = p.y + "px"; |
| | } |
| | ensureCanvasSize(); |
| | updateAllEdges(); |
| | } |
| | |
| | function schemaTable(schema){ |
| | if(!Array.isArray(schema) || schema.length === 0){ |
| | return '<div class="empty">—</div>'; |
| | } |
| | const rows = schema.map(f => { |
| | const name = escapeHtml(f.name ?? ""); |
| | const type = escapeHtml(f.type ?? ""); |
| | const def = escapeHtml(formatValue(f.default ?? "")); |
| | const opts = Array.isArray(f.options) ? escapeHtml(f.options.join(", ")) : ""; |
| | const desc = escapeHtml(f.description ?? ""); |
| | return `<tr> |
| | <td class="mono">${name}</td> |
| | <td class="mono">${type}</td> |
| | <td class="mono">${def}</td> |
| | <td class="mono">${opts}</td> |
| | <td>${desc}</td> |
| | </tr>`; |
| | }).join(""); |
| | return `<table class="schema"> |
| | <thead><tr> |
| | <th class="right">name</th> |
| | <th>type</th> |
| | <th>default</th> |
| | <th>options</th> |
| | <th>description</th> |
| | </tr></thead> |
| | <tbody>${rows}</tbody> |
| | </table>`; |
| | } |
| | |
| | function pillList(ids){ |
| | if(!Array.isArray(ids) || ids.length === 0){ |
| | return '<div class="empty">—</div>'; |
| | } |
| | const pills = ids.map(id => `<span class="pill">${escapeHtml(id)}</span>`).join(""); |
| | return `<div class="pillRow">${pills}</div>`; |
| | } |
| | |
| | function nodeBodyHtml(node){ |
| | const meta = ` |
| | <div class="node-meta"> |
| | <span class="kv">id: ${escapeHtml(node.id)}</span> |
| | <span class="kv">kind: ${escapeHtml(node.kind ?? "")}</span> |
| | <span class="kv">pro: ${node.pro ? "true" : "false"}</span> |
| | </div> |
| | `; |
| | |
| | const desc = node.description ? `<p class="desc">${escapeHtml(node.description)}</p>` : ''; |
| | |
| | const deps = ` |
| | <div class="section"> |
| | <h4>Dependencies</h4> |
| | ${pillList(node.dependencies)} |
| | </div> |
| | `; |
| | |
| | const nexts = ` |
| | <div class="section"> |
| | <h4>Next Nodes</h4> |
| | ${pillList(node.next_nodes)} |
| | </div> |
| | `; |
| | |
| | const input = ` |
| | <div class="section"> |
| | <h4>Input Schema</h4> |
| | ${schemaTable(node.input_schema)} |
| | </div> |
| | `; |
| | |
| | const output = ` |
| | <div class="section"> |
| | <h4>Output Schema</h4> |
| | ${schemaTable(node.output_schema)} |
| | </div> |
| | `; |
| | |
| | return meta + desc + deps + nexts + input + output; |
| | } |
| | |
| | function createNodeEl(node){ |
| | const el = document.createElement("div"); |
| | el.className = "node collapsed"; |
| | el.tabIndex = 0; |
| | el.dataset.id = node.id; |
| | |
| | const header = document.createElement("div"); |
| | header.className = "node-header"; |
| | |
| | const titleWrap = document.createElement("div"); |
| | titleWrap.className = "node-titlewrap"; |
| | |
| | const title = document.createElement("div"); |
| | title.className = "node-title"; |
| | title.textContent = node.name || node.id; |
| | |
| | const descCollapsed = document.createElement("div"); |
| | descCollapsed.className = "node-desc-collapsed"; |
| | descCollapsed.textContent = node.description || ""; |
| | |
| | titleWrap.appendChild(title); |
| | titleWrap.appendChild(descCollapsed); |
| | |
| | const badges = document.createElement("div"); |
| | badges.className = "node-badges"; |
| | |
| | |
| | const badgeKind = document.createElement("span"); |
| | badgeKind.className = "badge"; |
| | badgeKind.textContent = node.kind || "node"; |
| | const badgePro = document.createElement("span"); |
| | badgePro.className = "badge"; |
| | badgePro.textContent = node.pro ? "PRO" : "NORMAL"; |
| | badges.appendChild(badgeKind); |
| | badges.appendChild(badgePro); |
| | |
| | header.appendChild(titleWrap); |
| | header.appendChild(badges); |
| | |
| | const body = document.createElement("div"); |
| | body.className = "node-body"; |
| | body.innerHTML = nodeBodyHtml(node); |
| | |
| | el.appendChild(header); |
| | el.appendChild(body); |
| | |
| | |
| | badges.style.display = "none"; |
| | |
| | attachDragAndToggle(el, header, badges); |
| | |
| | |
| | el.addEventListener("mouseenter", () => highlightConnections(node.id, true)); |
| | el.addEventListener("mouseleave", () => highlightConnections(node.id, false)); |
| | |
| | return el; |
| | } |
| | |
| | function setCollapsed(el, collapsed){ |
| | const header = el.querySelector(".node-header"); |
| | const badges = header.querySelector(".node-badges"); |
| | el.classList.toggle("collapsed", !!collapsed); |
| | badges.style.display = collapsed ? "none" : "flex"; |
| | } |
| | |
| | function toggleNode(el){ |
| | const collapsed = el.classList.contains("collapsed"); |
| | setCollapsed(el, !collapsed); |
| | |
| | ensureCanvasSize(); |
| | |
| | updateAllEdges(); |
| | saveLayoutDebounced(); |
| | } |
| | |
| | function attachDragAndToggle(el, header, badges){ |
| | let startX = 0, startY = 0; |
| | let originLeft = 0, originTop = 0; |
| | let dragging = false; |
| | |
| | const DRAG_THRESHOLD = 4; |
| | |
| | header.addEventListener("pointerdown", (e) => { |
| | if(e.button !== 0) return; |
| | header.setPointerCapture(e.pointerId); |
| | dragging = false; |
| | startX = e.clientX; |
| | startY = e.clientY; |
| | |
| | const canvasRect = canvas.getBoundingClientRect(); |
| | const rect = el.getBoundingClientRect(); |
| | originLeft = rect.left - canvasRect.left; |
| | originTop = rect.top - canvasRect.top; |
| | |
| | el.classList.add("dragging"); |
| | e.preventDefault(); |
| | }); |
| | |
| | header.addEventListener("pointermove", (e) => { |
| | if(!header.hasPointerCapture(e.pointerId)) return; |
| | |
| | const dx = e.clientX - startX; |
| | const dy = e.clientY - startY; |
| | |
| | if(!dragging && (Math.abs(dx) + Math.abs(dy) > DRAG_THRESHOLD)){ |
| | dragging = true; |
| | } |
| | |
| | if(dragging){ |
| | |
| | const newLeft = Math.max(0, originLeft + dx); |
| | const newTop = Math.max(0, originTop + dy); |
| | el.style.left = newLeft + "px"; |
| | el.style.top = newTop + "px"; |
| | updateEdgesForNode(el.dataset.id); |
| | } |
| | }); |
| | |
| | function endPointer(e){ |
| | if(!header.hasPointerCapture(e.pointerId)) return; |
| | header.releasePointerCapture(e.pointerId); |
| | el.classList.remove("dragging"); |
| | |
| | if(!dragging){ |
| | |
| | toggleNode(el); |
| | }else{ |
| | |
| | ensureCanvasSize(); |
| | saveLayoutDebounced(); |
| | } |
| | } |
| | |
| | header.addEventListener("pointerup", endPointer); |
| | header.addEventListener("pointercancel", endPointer); |
| | |
| | |
| | el.addEventListener("keydown", (e) => { |
| | if(e.key === "Enter" || e.key === " "){ |
| | e.preventDefault(); |
| | toggleNode(el); |
| | } |
| | }); |
| | } |
| | |
| | function addEdge(fromId, toId, dedupe){ |
| | if(!state.nodesById.has(fromId) || !state.nodesById.has(toId)) return; |
| | const key = fromId + "→" + toId; |
| | if(dedupe.has(key)) return; |
| | dedupe.add(key); |
| | |
| | const path = document.createElementNS(SVG_NS, "path"); |
| | path.classList.add("edge"); |
| | path.setAttribute("marker-end", "url(#arrowHead)"); |
| | path.dataset.from = fromId; |
| | path.dataset.to = toId; |
| | edgesSvg.appendChild(path); |
| | |
| | state.edges.push({ from: fromId, to: toId, el: path }); |
| | } |
| | |
| | function buildEdges(){ |
| | |
| | state.edges = []; |
| | |
| | const defs = edgesSvg.querySelector("defs"); |
| | edgesSvg.innerHTML = ""; |
| | edgesSvg.appendChild(defs); |
| | |
| | const dedupe = new Set(); |
| | for(const node of state.data.nodes){ |
| | const deps = Array.isArray(node.dependencies) ? node.dependencies : []; |
| | for(const depId of deps){ |
| | addEdge(depId, node.id, dedupe); |
| | } |
| | const nexts = Array.isArray(node.next_nodes) ? node.next_nodes : []; |
| | for(const nxt of nexts){ |
| | addEdge(node.id, nxt, dedupe); |
| | } |
| | } |
| | } |
| | |
| | function rectInCanvas(el){ |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | const svgRect = edgesSvg.getBoundingClientRect(); |
| | const r = el.getBoundingClientRect(); |
| | |
| | const left = r.left - svgRect.left; |
| | const top = r.top - svgRect.top; |
| | const width = r.width; |
| | const height = r.height; |
| | |
| | return { |
| | left, |
| | top, |
| | width, |
| | height, |
| | right: left + width, |
| | bottom: top + height, |
| | cx: left + width/2, |
| | cy: top + height/2 |
| | }; |
| | } |
| | |
| | function anchorPoint(rect, side){ |
| | switch(side){ |
| | case "left": return { x: rect.left, y: rect.cy }; |
| | case "right": return { x: rect.right, y: rect.cy }; |
| | case "top": return { x: rect.cx, y: rect.top }; |
| | case "bottom": return { x: rect.cx, y: rect.bottom }; |
| | default: return { x: rect.cx, y: rect.cy }; |
| | } |
| | } |
| | |
| | function edgePath(fromRect, toRect){ |
| | const dx = toRect.cx - fromRect.cx; |
| | const dy = toRect.cy - fromRect.cy; |
| | |
| | const horizontal = Math.abs(dx) >= Math.abs(dy); |
| | |
| | let fromSide, toSide; |
| | if(horizontal){ |
| | fromSide = dx >= 0 ? "right" : "left"; |
| | toSide = dx >= 0 ? "left" : "right"; |
| | }else{ |
| | fromSide = dy >= 0 ? "bottom" : "top"; |
| | toSide = dy >= 0 ? "top" : "bottom"; |
| | } |
| | |
| | const p1 = anchorPoint(fromRect, fromSide); |
| | const p2 = anchorPoint(toRect, toSide); |
| | |
| | |
| | const curvature = 0.55; |
| | let c1, c2; |
| | |
| | if(horizontal){ |
| | const d = Math.max(60, Math.abs(p2.x - p1.x) * curvature); |
| | c1 = { x: p1.x + (fromSide === "right" ? d : -d), y: p1.y }; |
| | c2 = { x: p2.x + (toSide === "left" ? -d : d), y: p2.y }; |
| | }else{ |
| | const d = Math.max(60, Math.abs(p2.y - p1.y) * curvature); |
| | c1 = { x: p1.x, y: p1.y + (fromSide === "bottom" ? d : -d) }; |
| | c2 = { x: p2.x, y: p2.y + (toSide === "top" ? -d : d) }; |
| | } |
| | |
| | return `M ${p1.x.toFixed(1)} ${p1.y.toFixed(1)} C ${c1.x.toFixed(1)} ${c1.y.toFixed(1)}, ${c2.x.toFixed(1)} ${c2.y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`; |
| | } |
| | |
| | function updateEdge(edge){ |
| | const fromEl = state.nodeEls.get(edge.from); |
| | const toEl = state.nodeEls.get(edge.to); |
| | if(!fromEl || !toEl) return; |
| | |
| | const fromRect = rectInCanvas(fromEl); |
| | const toRect = rectInCanvas(toEl); |
| | |
| | edge.el.setAttribute("d", edgePath(fromRect, toRect)); |
| | } |
| | |
| | function updateAllEdges(){ |
| | |
| | const w = Math.max(1, edgesSvg.clientWidth); |
| | const h = Math.max(1, edgesSvg.clientHeight); |
| | edgesSvg.setAttribute("viewBox", `0 0 ${w} ${h}`); |
| | for(const e of state.edges) updateEdge(e); |
| | } |
| | |
| | function updateEdgesForNode(nodeId){ |
| | for(const e of state.edges){ |
| | if(e.from === nodeId || e.to === nodeId){ |
| | updateEdge(e); |
| | } |
| | } |
| | } |
| | |
| | function highlightConnections(nodeId, on){ |
| | for(const e of state.edges){ |
| | const connected = (e.from === nodeId || e.to === nodeId); |
| | e.el.classList.toggle("dim", on && !connected); |
| | e.el.classList.toggle("highlight", on && connected); |
| | } |
| | } |
| | |
| | function collapseAll(){ |
| | for(const [id, el] of state.nodeEls){ |
| | setCollapsed(el, true); |
| | } |
| | updateAllEdges(); |
| | saveLayoutDebounced(); |
| | } |
| | |
| | function expandAll(){ |
| | for(const [id, el] of state.nodeEls){ |
| | setCollapsed(el, false); |
| | } |
| | updateAllEdges(); |
| | saveLayoutDebounced(); |
| | } |
| | |
| | btnCollapse.addEventListener("click", collapseAll); |
| | btnExpand.addEventListener("click", expandAll); |
| | btnReset.addEventListener("click", resetLayout); |
| | |
| | async function main(){ |
| | setOverlay("Loading…", `fetch("${DATA_URL}")`, false); |
| | |
| | let data; |
| | try{ |
| | const resp = await fetch(DATA_URL, { cache: "no-store" }); |
| | if(!resp.ok) throw new Error(`HTTP ${resp.status}`); |
| | data = await resp.json(); |
| | }catch(err){ |
| | setOverlay("Load failed", [ |
| | "Unable to load the JSON file.", |
| | "If you opened index.html directly (file://), your browser may block fetch().", |
| | "", |
| | "Tip: run a local static server in this folder, for example:", |
| | " python -m http.server 8000", |
| | "Then open:", |
| | " http://localhost:8000/index.html", |
| | "", |
| | "Error:", |
| | String(err) |
| | ].join("\n"), false); |
| | return; |
| | } |
| | |
| | state.data = data; |
| | state.layoutKey = "node_layout_" + (data.workflow_meta?.id || "workflow"); |
| | state.nodesById = new Map((data.nodes || []).map(n => [n.id, n])); |
| | |
| | wfNameEl.textContent = data.workflow_meta?.name || "Workflow Node Map"; |
| | wfDescEl.textContent = data.workflow_meta?.description || `data: ${DATA_URL}`; |
| | document.title = wfNameEl.textContent; |
| | |
| | |
| | const saved = loadLayout(); |
| | const positions = saved?.positions || autoLayout(data.nodes || []); |
| | const collapsed = saved?.collapsed || null; |
| | |
| | |
| | Array.from(canvas.querySelectorAll(".node")).forEach(n => n.remove()); |
| | |
| | for(const node of (data.nodes || [])){ |
| | const el = createNodeEl(node); |
| | const p = positions[node.id] || {x:40, y:40}; |
| | el.style.left = p.x + "px"; |
| | el.style.top = p.y + "px"; |
| | const isCollapsed = collapsed ? !!collapsed[node.id] : true; |
| | setCollapsed(el, isCollapsed); |
| | |
| | canvas.appendChild(el); |
| | state.nodeEls.set(node.id, el); |
| | } |
| | |
| | |
| | ensureCanvasSize(); |
| | |
| | |
| | buildEdges(); |
| | |
| | requestAnimationFrame(() => { |
| | updateAllEdges(); |
| | setOverlay("", "", true); |
| | }); |
| | |
| | |
| | window.addEventListener("resize", () => updateAllEdges()); |
| | |
| | |
| | viewport.addEventListener("scroll", () => { |
| | |
| | updateAllEdges(); |
| | }, { passive: true }); |
| | } |
| | |
| | main(); |
| | })(); |
| | </script> |
| | </body> |
| | </html> |
| |
|