svrnty-hermes-webui-plugin/static/umbrella.js
Svrnty 28ffa92f6f
Some checks failed
plugin-tests / test (push) Failing after 5s
Improve umbrella graph visualization
2026-05-26 06:15:27 -04:00

765 lines
28 KiB
JavaScript

/* Cortex-OS Umbrella panel — Cytoscape.js render of /api/umbrella graph.
* Consumes UI-stable schema v1.0 per CORTEX-OS-UMBRELLA-VIZ-PRD.md.
* Curator-maintained graph artifact at graph/umbrella.json.
*/
(function () {
"use strict";
const TYPE_COLOR = {
doc: "#64748b",
profile: "#6ee7b7",
skill: "#a78bfa",
mcp_server: "#f97316",
sovereign_api: "#f97316",
cortex_tool: "#60a5fa",
external_dep: "#f87171",
credential: "#be647a",
};
const TYPE_SHAPE = {
doc: "round-rectangle",
profile: "hexagon",
skill: "ellipse",
mcp_server: "diamond",
sovereign_api: "rectangle",
cortex_tool: "round-rectangle",
external_dep: "triangle",
credential: "vee",
};
const EDGE_STYLE = {
depends_on: { color: "#475569", style: "solid" },
governs: { color: "#fbbf24", style: "solid" },
consumes: { color: "#6ee7b7", style: "solid" },
produces: { color: "#a78bfa", style: "dashed" },
supersedes: { color: "#f87171", style: "dashed" },
cites: { color: "#38bdf8", style: "dotted" },
derives_from: { color: "#f59e0b", style: "dashed" },
};
const SPINE_LAYERS = [
{ key: "system", label: "System Spine", color: "#e7eaf0", baseWidth: 920 },
{ key: "governance", label: "Governance / Protocols", color: "#fbbf24", baseWidth: 1240 },
{ key: "runtime", label: "Runtime / WebUI / Services", color: "#60a5fa", baseWidth: 880 },
{ key: "profiles", label: "Agents / Profiles", color: "#6ee7b7", baseWidth: 680 },
{ key: "capabilities", label: "Skills / Tools / APIs", color: "#a78bfa", baseWidth: 2200 },
{ key: "projects", label: "Projects / Domain Systems", color: "#14b8a6", baseWidth: 760 },
{ key: "knowledge", label: "SOT / Evidence / Outputs", color: "#94a3b8", baseWidth: 2200 },
];
let cy = null;
let graph = null;
let layerOverlayState = null;
const activeTypes = new Set();
const quietTypes = new Set(["credential"]);
const quietLayers = new Set(["knowledge"]);
const disclosedLayers = new Set();
window.__svrntyUmbrella = { ready: false, error: null, nodes: 0, edges: 0, view: "force" };
async function loadGraph() {
const res = await fetch("/api/umbrella", { cache: "no-store" });
if (!res.ok) throw new Error("graph load failed: " + res.status);
return await res.json();
}
function renderStats(g) {
const s = g.stats || {};
const byType = s.by_type || {};
document.getElementById("stats").textContent =
`nodes ${s.total_nodes || g.nodes.length} · edges ${s.total_edges || g.edges.length} · ` +
Object.entries(byType).map(([k, v]) => `${k}:${v}`).join(" ");
document.getElementById("genInfo").textContent =
`generated ${g.generated_at || "?"} by ${g.generated_by || "?"}`;
}
function renderFilters(g) {
const types = [...new Set(g.nodes.map(n => n.type))].sort();
const wrap = document.getElementById("filters");
const disclosure = document.getElementById("disclosure");
wrap.innerHTML = "";
disclosure.innerHTML = "";
types.forEach(t => {
activeTypes.add(t);
const chip = document.createElement("button");
chip.className = "chip active" + (quietTypes.has(t) ? " disclosure-chip" : "");
chip.dataset.type = t;
chip.style.borderColor = TYPE_COLOR[t] || "#475569";
chip.style.color = TYPE_COLOR[t] || "#e7eaf0";
chip.textContent = `${quietTypes.has(t) ? "show " : ""}${t} (${g.nodes.filter(n => n.type === t).length})`;
chip.addEventListener("click", () => toggleType(t, chip));
(quietTypes.has(t) ? disclosure : wrap).appendChild(chip);
});
quietLayers.forEach((layer) => {
const count = g.nodes.filter((n) => _spineLayerKey(n) === layer).length;
const chip = document.createElement("button");
chip.className = "chip disclosure-chip";
chip.dataset.layerDisclosure = layer;
chip.textContent = `show ${_layerLabel(layer)} (${count})`;
chip.addEventListener("click", () => toggleLayerDisclosure(layer, chip));
disclosure.appendChild(chip);
});
}
function applyDefaultDisclosure() {
quietTypes.forEach((t) => activeTypes.delete(t));
disclosedLayers.clear();
document.querySelectorAll(".chip[data-type]").forEach((chip) => {
chip.classList.toggle("active", activeTypes.has(chip.dataset.type));
});
document.querySelectorAll(".chip[data-layer-disclosure]").forEach((chip) => {
chip.classList.toggle("active", disclosedLayers.has(chip.dataset.layerDisclosure));
});
applyFilter();
}
function _layerLabel(layer) {
if (layer === "knowledge") return "SOT / evidence";
return layer;
}
function toggleLayerDisclosure(layer, chipEl) {
if (disclosedLayers.has(layer)) {
disclosedLayers.delete(layer);
chipEl.classList.remove("active");
} else {
disclosedLayers.add(layer);
chipEl.classList.add("active");
}
applyFilter();
if (window.__svrntyUmbrella.view === "spine") runPresetView("spine");
}
function toggleType(t, chipEl) {
if (activeTypes.has(t)) { activeTypes.delete(t); chipEl.classList.remove("active"); }
else { activeTypes.add(t); chipEl.classList.add("active"); }
applyFilter();
}
function applyFilter() {
if (!cy) return;
cy.batch(() => {
cy.nodes().forEach(n => {
const layer = n.data("layer");
const visible = activeTypes.has(n.data("type")) &&
(!quietLayers.has(layer) || disclosedLayers.has(layer));
n.style("display", visible ? "element" : "none");
});
cy.edges().forEach(e => {
const s = e.source().style("display");
const t = e.target().style("display");
e.style("display", (s === "element" && t === "element") ? "element" : "none");
});
});
}
function buildCyElements(g) {
const nodes = g.nodes.map(n => ({
data: {
id: n.id,
label: n.label || n.id,
shortLabel: _shortLabel(n.label || n.id),
type: n.type,
raw: n,
..._visualMeta(n),
},
}));
const edges = g.edges.map((e, i) => ({
data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e },
}));
return [...nodes, ...edges];
}
function renderGraph(g) {
cy = cytoscape({
container: document.getElementById("cy"),
elements: buildCyElements(g),
wheelSensitivity: 0.2,
style: [
{
selector: "node",
style: {
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
"label": ele => ele.data("important") ? ele.data("shortLabel") : "",
"color": "#e7eaf0",
"text-valign": "bottom",
"text-margin-y": 4,
"font-size": ele => ele.data("fontSize") || 10,
"font-family": "ui-monospace, monospace",
"width": ele => ele.data("nodeWidth") || 28,
"height": ele => ele.data("nodeHeight") || 28,
"border-width": 1,
"border-color": "#0f1115",
"text-wrap": "wrap",
"text-max-width": ele => Math.max(90, (ele.data("nodeWidth") || 28) + 44),
},
},
{
selector: "node:selected",
style: { "border-width": 3, "border-color": "#fbbf24", "label": "data(label)" },
},
{
selector: "node.hover",
style: { "label": "data(label)", "z-index": 20 },
},
{
selector: "edge",
style: {
"width": 1,
"line-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
"line-style": ele => (EDGE_STYLE[ele.data("type")] || {}).style || "solid",
"curve-style": "bezier",
"target-arrow-shape": "triangle",
"target-arrow-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
"opacity": 0.5,
},
},
{
selector: "edge:selected",
style: { "opacity": 1, "width": 2 },
},
],
layout: { name: "cose", animate: false, idealEdgeLength: 80, nodeRepulsion: 12000 },
});
window.cy = cy;
cy.on("tap", "node", (evt) => openSidePanel(evt.target.data("raw")));
cy.on("tap", (evt) => { if (evt.target === cy) closeSidePanel(); });
cy.on("mouseover", "node", (evt) => focusNode(evt.target));
cy.on("mouseout", "node", (evt) => {
evt.target.removeClass("hover");
resetGraphEmphasis();
});
cy.on("zoom", () => applyLabelDensity());
cy.on("pan zoom render", () => updateLayerOverlay());
}
function _shortLabel(value) {
const label = String(value || "");
if (label.length <= 24) return label;
return label.slice(0, 21) + "...";
}
function _estimatedLabelWidth(node) {
const label = String(node.label || node.id || "");
const layer = node.layer || _spineLayerKey(node);
const priority = node.labelPriority || _labelPriority(node, layer);
const size = node.nodeWidth ? node : _nodeSize(node, layer, priority);
return Math.min(320, Math.max(size.nodeWidth || size.width || 92, label.length * 7 + 44));
}
function _isImportantNode(node) {
const id = node.id || "";
return node.type === "profile" ||
["readme", "architecture", "cortex-os-framework", "cortex-os-roadmap"].includes(id);
}
function _visualMeta(node) {
const layer = _spineLayerKey(node);
const priority = _labelPriority(node, layer);
const size = _nodeSize(node, layer, priority);
return {
layer,
labelPriority: priority,
important: priority <= 1,
nodeWidth: size.width,
nodeHeight: size.height,
fontSize: size.fontSize,
};
}
function _labelPriority(node, layer) {
if (layer === "system") return 0;
if (node.type === "profile") return 0;
if (layer === "runtime" || layer === "projects") return 1;
if (["skill", "mcp_server", "cortex_tool", "sovereign_api", "external_dep"].includes(node.type)) return 2;
return 3;
}
function _nodeSize(node, layer, priority) {
if (layer === "system") return { width: 148, height: 54, fontSize: 12 };
if (node.type === "profile") return { width: 118, height: 48, fontSize: 11 };
if (layer === "runtime" || layer === "projects") return { width: 108, height: 42, fontSize: 10 };
if (priority === 2) return { width: 82, height: 34, fontSize: 9 };
return { width: 34, height: 28, fontSize: 9 };
}
function applyLabelDensity() {
if (!cy) return;
const zoom = cy.zoom();
cy.batch(() => {
cy.nodes().forEach((node) => {
if (node.selected() || node.hasClass("hover")) return;
const priority = node.data("labelPriority");
const label = priority <= 1 || (priority === 2 && zoom >= 0.85) || zoom >= 1.25
? node.data("shortLabel")
: "";
node.style("label", label);
});
});
}
function resetGraphEmphasis() {
if (!cy) return;
const inSpine = window.__svrntyUmbrella.view === "spine";
cy.batch(() => {
cy.nodes().style("opacity", 1);
cy.edges().style({ "opacity": inSpine ? 0.14 : 0.5, "width": 1 });
});
applyLabelDensity();
}
function focusNode(node) {
if (!cy) return;
node.addClass("hover");
const neighborhood = node.closedNeighborhood();
cy.batch(() => {
cy.elements().not(neighborhood).style("opacity", 0.16);
neighborhood.nodes().style("opacity", 1);
cy.edges().style({ "opacity": 0.05, "width": 1 });
node.connectedEdges().style({ "opacity": 0.9, "width": 2 });
});
}
function _sourcePath(node) {
return String((node && node.source_path) || "");
}
function _docCategory(node) {
const path = _sourcePath(node);
if (path.includes("/01-ROADMAP/")) return "roadmap";
if (path.includes("/02-FRAMEWORK/")) return "framework";
if (path.includes("/03-PROTOCOLS/")) return "protocols";
if (path.includes("/04-STANDARDS/")) return "standards";
if (path.includes("/05-RECIPES/")) return "recipes";
if (path.includes("/06-REGISTRY/")) return "registry";
if (path.includes("/07-BRAND/")) return "brand";
if (path.includes("/08-OUTPUTS/")) return "outputs";
if (path.includes("/99-RAW/")) return "raw";
return "docs";
}
function _spineLayerKey(node) {
const id = node.id || "";
const source = _sourcePath(node);
if (["readme", "architecture", "cortex-os-framework", "cortex-os-roadmap"].includes(id)) return "system";
if (["hermes-webui", "hermes-agent", "llm-gateway"].includes(id) || source.startsWith("hermes-webui/")) return "runtime";
if (node.type === "profile") return "profiles";
if (node.type === "skill") return "capabilities";
if (["mcp_server", "cortex_tool", "sovereign_api", "external_dep", "credential"].includes(node.type)) return "capabilities";
if (["bte", "bte-mcp", "adwright", "adwright-mcp", "svrnty-vision", "deep-research", "sandcastle", "pi-code"].includes(id)) return "projects";
if (source && !source.startsWith("sot/")) return "projects";
if (node.type === "doc") {
const cat = _docCategory(node);
if (["framework", "protocols", "standards"].includes(cat)) return "governance";
return "knowledge";
}
return "knowledge";
}
function _groupRows(nodes, keyFn) {
const groups = new Map();
nodes.forEach((node) => {
const key = keyFn(node);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(node);
});
groups.forEach((items) => items.sort((a, b) => String(a.label || a.id).localeCompare(String(b.label || b.id))));
return [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])));
}
function _wrapGroupRows(groups, width, minGap) {
const rows = [];
groups.forEach(([group, items]) => {
let row = [];
let used = 0;
items.forEach((node) => {
const need = _estimatedLabelWidth(node) + minGap;
if (row.length && used + need > width) {
rows.push([group, row]);
row = [];
used = 0;
}
row.push(node);
used += need;
});
if (row.length) rows.push([group, row]);
});
return rows;
}
function _rowPositions(groups, opts) {
const positions = {};
const width = Math.max(opts.width || 1200, 900);
const top = opts.top || 80;
const rowGap = opts.rowGap || 120;
const minGap = opts.minGap || 78;
const rows = _wrapGroupRows(groups, Math.max(width - 180, 720), minGap);
rows.forEach(([_, items], rowIndex) => {
const totalLabelWidth = items.reduce((sum, node) => sum + _estimatedLabelWidth(node), 0);
const rowWidth = Math.max(width - 180, totalLabelWidth + Math.max(0, items.length - 1) * minGap);
const startX = (width - rowWidth) / 2;
const availableGap = items.length > 1 ? (rowWidth - totalLabelWidth) / (items.length - 1) : 0;
let cursor = startX;
items.forEach((node, i) => {
const labelWidth = _estimatedLabelWidth(node);
positions[node.id] = {
x: items.length === 1 ? width / 2 : cursor + labelWidth / 2,
y: top + rowIndex * rowGap,
};
cursor += labelWidth + Math.max(minGap, availableGap);
});
});
return positions;
}
function _layerRows(items, layerWidth) {
const innerWidth = Math.max(layerWidth - 120, 360);
const rows = [];
let row = [];
let used = 0;
items.forEach((node) => {
const need = _estimatedLabelWidth(node) + 56;
if (row.length && used + need > innerWidth) {
rows.push(row);
row = [];
used = 0;
}
row.push(node);
used += need;
});
if (row.length) rows.push(row);
return rows;
}
function spineLayout(g) {
const layerMap = new Map();
SPINE_LAYERS.forEach((layer) => layerMap.set(layer.key, []));
g.nodes.forEach((node) => {
const layer = _spineLayerKey(node);
if (!layerMap.has(layer)) layerMap.set("knowledge", []);
layerMap.get(layerMap.has(layer) ? layer : "knowledge").push(node);
});
layerMap.forEach((items) => {
items.sort((a, b) => {
const av = _labelPriority(a, _spineLayerKey(a));
const bv = _labelPriority(b, _spineLayerKey(b));
return av - bv || String(a.label || a.id).localeCompare(String(b.label || b.id));
});
});
const positions = {};
const bands = [];
let y = 80;
SPINE_LAYERS.forEach((layer, layerIndex) => {
const allItems = layerMap.get(layer.key) || [];
if (!allItems.length) return;
const collapsed = quietLayers.has(layer.key) && !disclosedLayers.has(layer.key);
const items = collapsed ? [] : allItems;
if (collapsed) {
const layerWidth = Math.max(900, Math.min(1800, layer.baseWidth || 900));
const x1 = -layerWidth / 2;
const x2 = layerWidth / 2;
const y1 = y;
const y2 = y + 52;
bands.push({ key: layer.key, label: layer.label, count: allItems.length, color: layer.color, x1, x2, y1, y2, collapsed: true });
y = y2 + 42;
return;
}
const density = items.reduce((sum, node) => sum + _estimatedLabelWidth(node), 0);
const layerWidth = Math.min(4200, Math.max(layer.baseWidth || 900, density * 0.72));
const rows = _layerRows(items, layerWidth);
const bandHeight = Math.max(138, rows.length * 92 + 74);
const x1 = -layerWidth / 2;
const x2 = layerWidth / 2;
const y1 = y;
const y2 = y + bandHeight;
bands.push({ key: layer.key, label: layer.label, count: items.length, color: layer.color, x1, x2, y1, y2, collapsed: false });
rows.forEach((row, rowIndex) => {
const rowWidth = row.reduce((sum, node) => sum + _estimatedLabelWidth(node), 0) + Math.max(0, row.length - 1) * 58;
let cursor = -rowWidth / 2;
row.forEach((node) => {
const w = _estimatedLabelWidth(node);
positions[node.id] = {
x: cursor + w / 2,
y: y1 + 78 + rowIndex * 92,
};
cursor += w + 58;
});
});
y = y2 + 70;
});
return { positions, bands };
}
function spinePositions(g) {
return spineLayout(g).positions;
}
function orgPositions(g) {
const bounds = document.getElementById("cy").getBoundingClientRect();
const profiles = g.nodes.filter((n) => n.type === "profile");
const profileIds = new Set(profiles.map((p) => p.id.replace(/-planb$/, "")));
const groups = [["system", g.nodes.filter((n) => ["readme", "architecture", "cortex-os-framework"].includes(n.id))]];
groups.push(["profiles", profiles]);
profiles.forEach((profile) => {
const shortId = profile.id.replace(/-planb$/, "");
groups.push([profile.id + "-skills", g.nodes.filter((n) =>
n.type === "skill" && (n.id.startsWith(profile.id + "::") || n.id.startsWith(shortId + "-"))
)]);
});
groups.push(["tools", g.nodes.filter((n) => ["mcp_server", "cortex_tool", "sovereign_api", "external_dep"].includes(n.type))]);
groups.push(["credentials", g.nodes.filter((n) => n.type === "credential")]);
groups.push(["sot", g.nodes.filter((n) => n.type === "doc" && !["readme", "architecture", "cortex-os-framework"].includes(n.id))]);
return _rowPositions(groups.filter(([, items]) => items.length), {
width: bounds.width,
top: 70,
rowGap: 120,
minGap: 86,
});
}
function mindmapPositions(g) {
const bounds = document.getElementById("cy").getBoundingClientRect();
const width = Math.max(bounds.width, 900);
const height = Math.max(bounds.height, 700);
const center = { x: width / 2, y: height / 2 };
const positions = {};
const rootIds = new Set(["readme", "architecture", "cortex-os-framework"]);
const roots = g.nodes.filter((n) => rootIds.has(n.id));
roots.forEach((node, i) => {
positions[node.id] = { x: center.x - 80 + i * 80, y: center.y };
});
const groups = _groupRows(g.nodes.filter((n) => !rootIds.has(n.id)), (n) => n.type || "other");
const maxRadius = Math.min(width, height) * 0.42;
groups.forEach(([_, items], groupIndex) => {
const angleStart = (Math.PI * 2 * groupIndex) / groups.length;
const angleEnd = (Math.PI * 2 * (groupIndex + 1)) / groups.length;
items.forEach((node, i) => {
const t = (i + 1) / (items.length + 1);
const angle = angleStart + (angleEnd - angleStart) * t;
const ring = Math.floor(i / Math.max(1, Math.ceil(items.length / 4)));
const radius = maxRadius * (0.35 + 0.16 * Math.min(ring, 4));
positions[node.id] = {
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius,
};
});
});
return positions;
}
function clearLayerOverlay() {
layerOverlayState = null;
const overlay = document.getElementById("layerOverlay");
if (overlay) overlay.innerHTML = "";
}
function renderLayerOverlay(bands) {
const overlay = document.getElementById("layerOverlay");
if (!overlay || !cy) return;
layerOverlayState = bands;
overlay.innerHTML = bands.map((band) =>
'<div class="layer-band' + (band.collapsed ? ' collapsed' : '') + '" data-layer="' + band.key + '">' +
'<div class="layer-band-label">' +
'<span>' + band.label + '</span>' +
'<span class="count">' + band.count + (band.collapsed ? ' hidden' : ' nodes') + '</span>' +
'</div>' +
'</div>'
).join("");
updateLayerOverlay();
}
function updateLayerOverlay() {
if (!layerOverlayState || !cy) return;
const overlay = document.getElementById("layerOverlay");
if (!overlay) return;
const z = cy.zoom();
const pan = cy.pan();
overlay.querySelectorAll(".layer-band").forEach((el, index) => {
const band = layerOverlayState[index];
if (!band) return;
const left = band.x1 * z + pan.x;
const top = band.y1 * z + pan.y;
const width = (band.x2 - band.x1) * z;
const height = (band.y2 - band.y1) * z;
el.style.left = left + "px";
el.style.top = top + "px";
el.style.width = width + "px";
el.style.height = height + "px";
el.style.borderColor = band.color + "44";
el.style.background = band.collapsed
? "linear-gradient(90deg, " + band.color + "0f, rgba(21,24,31,0.20))"
: "linear-gradient(90deg, " + band.color + "14, rgba(21,24,31,0.34))";
});
}
function setActiveView(name) {
document.querySelectorAll("button[data-layout], button[data-view]").forEach((button) => {
button.classList.toggle("active", button.dataset.layout === name || button.dataset.view === name);
});
window.__svrntyUmbrella.view = name;
}
function runPresetView(name) {
if (!cy || !graph) return;
let positions = null;
if (name === "spine") {
const layout = spineLayout(graph);
positions = layout.positions;
renderLayerOverlay(layout.bands);
} else {
clearLayerOverlay();
positions = name === "org" ? orgPositions(graph) : mindmapPositions(graph);
}
setActiveView(name);
cy.layout({
name: "preset",
positions: (node) => positions[node.id()] || node.position(),
animate: true,
fit: true,
padding: 48,
}).run();
setTimeout(resetGraphEmphasis, 250);
setTimeout(updateLayerOverlay, 260);
}
async function openSidePanel(node) {
document.getElementById("side").dataset.open = "true";
document.getElementById("sideTitle").textContent = node.label || node.id;
const meta = document.getElementById("sideMeta");
meta.innerHTML = "";
const metaFields = ["type", "tier", "status", "category", "owner", "role", "pin"];
metaFields.forEach(f => {
if (node[f] != null) {
const d = document.createElement("div");
d.innerHTML = `<b>${f}:</b> ${node[f]}`;
meta.appendChild(d);
}
});
if (node.governance) {
Object.entries(node.governance).forEach(([k, v]) => {
if (v != null) {
const d = document.createElement("div");
d.innerHTML = `<b>gov.${k}:</b> ${typeof v === "string" ? v : JSON.stringify(v)}`;
meta.appendChild(d);
}
});
}
if (node.description) {
const d = document.createElement("div");
d.style.marginTop = "8px";
d.style.color = "#e7eaf0";
d.textContent = node.description;
meta.appendChild(d);
}
// Body — fetch markdown if source_path
const body = document.getElementById("sideBody");
if (node.source_path) {
body.textContent = "loading…";
try {
const r = await fetch("/api/umbrella/doc?path=" + encodeURIComponent(node.source_path), { cache: "no-store" });
if (r.ok) {
const j = await r.json();
body.textContent = j.body || "(empty)";
} else {
body.textContent = "(no doc — " + r.status + ")";
}
} catch (e) {
body.textContent = "(fetch error: " + e + ")";
}
} else {
body.textContent = "(no source_path)";
}
// Edges
const edges = document.getElementById("sideEdges");
edges.innerHTML = "";
if (!cy) return;
const cyNode = cy.getElementById(node.id);
const outgoing = cyNode.connectedEdges().filter(e => e.source().id() === node.id);
const incoming = cyNode.connectedEdges().filter(e => e.target().id() === node.id);
if (outgoing.length) {
edges.innerHTML += `<h3>outgoing (${outgoing.length})</h3><ul>` +
outgoing.map(e =>
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.target().id()}">${e.target().id()}</button></li>`
).join("") + `</ul>`;
}
if (incoming.length) {
edges.innerHTML += `<h3>incoming (${incoming.length})</h3><ul>` +
incoming.map(e =>
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.source().id()}">${e.source().id()}</button></li>`
).join("") + `</ul>`;
}
edges.querySelectorAll("button[data-id]").forEach(b => {
b.addEventListener("click", () => {
const id = b.dataset.id;
const n = graph.nodes.find(n => n.id === id);
if (n) { openSidePanel(n); cy.getElementById(id).select(); }
});
});
}
function closeSidePanel() {
document.getElementById("side").dataset.open = "false";
if (cy) cy.elements().unselect();
}
function bindControls() {
document.getElementById("closeSide").addEventListener("click", closeSidePanel);
document.getElementById("reset").addEventListener("click", () => cy.fit(null, 30));
document.querySelectorAll("button[data-layout]").forEach(b => {
b.addEventListener("click", () => {
const name = b.dataset.layout;
clearLayerOverlay();
setActiveView(name);
const opts = name === "cose"
? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 }
: name === "breadthfirst"
? { name, animate: true, directed: true, padding: 10 }
: { name, animate: true };
cy.layout(opts).run();
setTimeout(resetGraphEmphasis, 250);
});
});
document.querySelectorAll("button[data-view]").forEach(b => {
b.addEventListener("click", () => runPresetView(b.dataset.view));
});
const search = document.getElementById("search");
search.addEventListener("input", () => {
const q = search.value.trim().toLowerCase();
cy.batch(() => {
cy.nodes().forEach(n => {
const hit = !q || n.data("id").toLowerCase().includes(q) ||
(n.data("raw").description || "").toLowerCase().includes(q);
n.style("opacity", hit ? 1 : 0.15);
});
});
});
}
async function init() {
try {
graph = await loadGraph();
renderStats(graph);
renderFilters(graph);
renderGraph(graph);
bindControls();
runPresetView("spine");
applyDefaultDisclosure();
window.__svrntyUmbrella = {
ready: true,
error: null,
nodes: graph.nodes.length,
edges: graph.edges.length,
view: window.__svrntyUmbrella.view || "spine",
};
} catch (e) {
document.getElementById("stats").textContent = "load failed: " + e.message;
window.__svrntyUmbrella = { ready: false, error: String(e.message || e), nodes: 0, edges: 0 };
console.error("[umbrella]", e);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();