This commit is contained in:
parent
4b1f2075ae
commit
28ffa92f6f
@ -45,10 +45,21 @@
|
||||
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.umbrella-controls button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.umbrella-controls button.active {
|
||||
border-color: var(--accent-2);
|
||||
color: var(--accent-2);
|
||||
background: rgba(251,191,36,0.08);
|
||||
}
|
||||
.umbrella-filters {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.umbrella-disclosure {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
background: var(--bg); color: var(--fg-dim);
|
||||
border: 1px solid #2a2f3a; border-radius: 999px;
|
||||
@ -56,9 +67,51 @@
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.chip.active { color: var(--accent); border-color: var(--accent); background: rgba(110,231,183,0.08); }
|
||||
.chip.disclosure-chip { border-style: dashed; }
|
||||
.chip.disclosure-chip.active { border-style: solid; }
|
||||
|
||||
.umbrella-canvas { position: relative; display: grid; grid-template-columns: 1fr; }
|
||||
.umbrella-canvas { position: relative; display: grid; grid-template-columns: 1fr; overflow: hidden; }
|
||||
.umbrella-cy { position: absolute; inset: 0; }
|
||||
.umbrella-layer-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.umbrella-cy { z-index: 2; }
|
||||
.layer-band {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(231,234,240,0.10);
|
||||
border-radius: 8px;
|
||||
background: rgba(21,24,31,0.42);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
.layer-band.collapsed {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.layer-band-label {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-family: var(--mono);
|
||||
color: var(--fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 11px;
|
||||
background: rgba(15,17,21,0.72);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.layer-band-label .count {
|
||||
color: var(--fg-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.layer-band.collapsed .layer-band-label .count {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.umbrella-side {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: 420px; background: var(--bg-2);
|
||||
|
||||
@ -16,13 +16,16 @@
|
||||
<button data-layout="cose">force</button>
|
||||
<button data-layout="breadthfirst">tier</button>
|
||||
<button data-layout="concentric">center</button>
|
||||
<button data-view="spine">spine</button>
|
||||
<button data-view="org">org chart</button>
|
||||
<button data-view="mindmap">mindmap</button>
|
||||
<button id="reset">reset</button>
|
||||
</div>
|
||||
<div class="umbrella-filters" id="filters">
|
||||
<!-- filter chips injected by JS based on node types -->
|
||||
</div>
|
||||
<div class="umbrella-filters" id="filters"></div>
|
||||
<div class="umbrella-disclosure" id="disclosure"></div>
|
||||
</header>
|
||||
<main class="umbrella-canvas">
|
||||
<div id="layerOverlay" class="umbrella-layer-overlay" aria-hidden="true"></div>
|
||||
<div id="cy" class="umbrella-cy"></div>
|
||||
<aside id="side" class="umbrella-side" data-open="false">
|
||||
<button class="close" id="closeSide" title="close">×</button>
|
||||
|
||||
@ -6,14 +6,14 @@
|
||||
"use strict";
|
||||
|
||||
const TYPE_COLOR = {
|
||||
doc: "#6ee7b7",
|
||||
profile: "#fbbf24",
|
||||
doc: "#64748b",
|
||||
profile: "#6ee7b7",
|
||||
skill: "#a78bfa",
|
||||
mcp_server: "#60a5fa",
|
||||
mcp_server: "#f97316",
|
||||
sovereign_api: "#f97316",
|
||||
cortex_tool: "#94a3b8",
|
||||
cortex_tool: "#60a5fa",
|
||||
external_dep: "#f87171",
|
||||
credential: "#475569",
|
||||
credential: "#be647a",
|
||||
};
|
||||
const TYPE_SHAPE = {
|
||||
doc: "round-rectangle",
|
||||
@ -34,11 +34,24 @@
|
||||
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();
|
||||
window.__svrntyUmbrella = { ready: false, error: null, nodes: 0, edges: 0 };
|
||||
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" });
|
||||
@ -59,18 +72,58 @@
|
||||
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";
|
||||
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 = `${t} (${g.nodes.filter(n => n.type === t).length})`;
|
||||
chip.textContent = `${quietTypes.has(t) ? "show " : ""}${t} (${g.nodes.filter(n => n.type === t).length})`;
|
||||
chip.addEventListener("click", () => toggleType(t, chip));
|
||||
wrap.appendChild(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) {
|
||||
@ -83,7 +136,9 @@
|
||||
if (!cy) return;
|
||||
cy.batch(() => {
|
||||
cy.nodes().forEach(n => {
|
||||
const visible = activeTypes.has(n.data("type"));
|
||||
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 => {
|
||||
@ -96,7 +151,14 @@
|
||||
|
||||
function buildCyElements(g) {
|
||||
const nodes = g.nodes.map(n => ({
|
||||
data: { id: n.id, label: n.label || n.id, type: n.type, raw: 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 },
|
||||
@ -115,21 +177,27 @@
|
||||
style: {
|
||||
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
|
||||
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
|
||||
"label": "data(label)",
|
||||
"label": ele => ele.data("important") ? ele.data("shortLabel") : "",
|
||||
"color": "#e7eaf0",
|
||||
"text-valign": "bottom",
|
||||
"text-margin-y": 4,
|
||||
"font-size": 10,
|
||||
"font-size": ele => ele.data("fontSize") || 10,
|
||||
"font-family": "ui-monospace, monospace",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"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" },
|
||||
style: { "border-width": 3, "border-color": "#fbbf24", "label": "data(label)" },
|
||||
},
|
||||
{
|
||||
selector: "node.hover",
|
||||
style: { "label": "data(label)", "z-index": 20 },
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
@ -150,8 +218,406 @@
|
||||
],
|
||||
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) {
|
||||
@ -240,14 +706,20 @@
|
||||
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();
|
||||
@ -268,11 +740,14 @@
|
||||
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;
|
||||
|
||||
47
tests/unit/test_umbrella_static.py
Normal file
47
tests/unit/test_umbrella_static.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Static checks for the umbrella graph browser assets."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HTML = ROOT / "static" / "umbrella.html"
|
||||
JS = ROOT / "static" / "umbrella.js"
|
||||
|
||||
|
||||
def test_umbrella_exposes_spine_org_and_mindmap_controls():
|
||||
html = HTML.read_text()
|
||||
assert 'data-view="spine"' in html
|
||||
assert 'data-view="org"' in html
|
||||
assert 'data-view="mindmap"' in html
|
||||
assert 'id="disclosure"' in html
|
||||
|
||||
|
||||
def test_umbrella_implements_semantic_preset_views():
|
||||
js = JS.read_text()
|
||||
html = HTML.read_text()
|
||||
assert 'id="layerOverlay"' in html
|
||||
assert "const SPINE_LAYERS" in js
|
||||
assert "Governance / Protocols" in js
|
||||
assert "function spineLayout" in js
|
||||
assert "function spinePositions" in js
|
||||
assert "function orgPositions" in js
|
||||
assert "function mindmapPositions" in js
|
||||
assert 'name: "preset"' in js
|
||||
assert "positions: (node) => positions[node.id()] || node.position()" in js
|
||||
assert "window.__svrntyUmbrella.view" in js
|
||||
|
||||
|
||||
def test_umbrella_has_collision_reduction_rules():
|
||||
js = JS.read_text()
|
||||
assert "function _estimatedLabelWidth" in js
|
||||
assert "function _wrapGroupRows" in js
|
||||
assert "function applyLabelDensity" in js
|
||||
assert "function renderLayerOverlay" in js
|
||||
assert "function applyDefaultDisclosure" in js
|
||||
assert 'new Set(["credential"])' in js
|
||||
assert 'new Set(["knowledge"])' in js
|
||||
assert "function toggleLayerDisclosure" in js
|
||||
assert "function resetGraphEmphasis" in js
|
||||
assert "function focusNode" in js
|
||||
assert 'selector: "node.hover"' in js
|
||||
assert 'cy.on("zoom", () => applyLabelDensity())' in js
|
||||
assert "window.cy = cy" in js
|
||||
Loading…
Reference in New Issue
Block a user