765 lines
28 KiB
JavaScript
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();
|
|
}
|
|
})();
|