This commit is contained in:
parent
4b1f2075ae
commit
28ffa92f6f
@ -45,10 +45,21 @@
|
|||||||
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.umbrella-controls button:hover { border-color: var(--accent); color: var(--accent); }
|
.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 {
|
.umbrella-filters {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex; flex-wrap: wrap; gap: 6px;
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
}
|
}
|
||||||
|
.umbrella-disclosure {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.chip {
|
.chip {
|
||||||
background: var(--bg); color: var(--fg-dim);
|
background: var(--bg); color: var(--fg-dim);
|
||||||
border: 1px solid #2a2f3a; border-radius: 999px;
|
border: 1px solid #2a2f3a; border-radius: 999px;
|
||||||
@ -56,9 +67,51 @@
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
.chip.active { color: var(--accent); border-color: var(--accent); background: rgba(110,231,183,0.08); }
|
.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-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 {
|
.umbrella-side {
|
||||||
position: absolute; top: 0; right: 0; bottom: 0;
|
position: absolute; top: 0; right: 0; bottom: 0;
|
||||||
width: 420px; background: var(--bg-2);
|
width: 420px; background: var(--bg-2);
|
||||||
|
|||||||
@ -16,13 +16,16 @@
|
|||||||
<button data-layout="cose">force</button>
|
<button data-layout="cose">force</button>
|
||||||
<button data-layout="breadthfirst">tier</button>
|
<button data-layout="breadthfirst">tier</button>
|
||||||
<button data-layout="concentric">center</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>
|
<button id="reset">reset</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="umbrella-filters" id="filters">
|
<div class="umbrella-filters" id="filters"></div>
|
||||||
<!-- filter chips injected by JS based on node types -->
|
<div class="umbrella-disclosure" id="disclosure"></div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="umbrella-canvas">
|
<main class="umbrella-canvas">
|
||||||
|
<div id="layerOverlay" class="umbrella-layer-overlay" aria-hidden="true"></div>
|
||||||
<div id="cy" class="umbrella-cy"></div>
|
<div id="cy" class="umbrella-cy"></div>
|
||||||
<aside id="side" class="umbrella-side" data-open="false">
|
<aside id="side" class="umbrella-side" data-open="false">
|
||||||
<button class="close" id="closeSide" title="close">×</button>
|
<button class="close" id="closeSide" title="close">×</button>
|
||||||
|
|||||||
@ -6,14 +6,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const TYPE_COLOR = {
|
const TYPE_COLOR = {
|
||||||
doc: "#6ee7b7",
|
doc: "#64748b",
|
||||||
profile: "#fbbf24",
|
profile: "#6ee7b7",
|
||||||
skill: "#a78bfa",
|
skill: "#a78bfa",
|
||||||
mcp_server: "#60a5fa",
|
mcp_server: "#f97316",
|
||||||
sovereign_api: "#f97316",
|
sovereign_api: "#f97316",
|
||||||
cortex_tool: "#94a3b8",
|
cortex_tool: "#60a5fa",
|
||||||
external_dep: "#f87171",
|
external_dep: "#f87171",
|
||||||
credential: "#475569",
|
credential: "#be647a",
|
||||||
};
|
};
|
||||||
const TYPE_SHAPE = {
|
const TYPE_SHAPE = {
|
||||||
doc: "round-rectangle",
|
doc: "round-rectangle",
|
||||||
@ -34,11 +34,24 @@
|
|||||||
cites: { color: "#38bdf8", style: "dotted" },
|
cites: { color: "#38bdf8", style: "dotted" },
|
||||||
derives_from: { color: "#f59e0b", style: "dashed" },
|
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 cy = null;
|
||||||
let graph = null;
|
let graph = null;
|
||||||
|
let layerOverlayState = null;
|
||||||
const activeTypes = new Set();
|
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() {
|
async function loadGraph() {
|
||||||
const res = await fetch("/api/umbrella", { cache: "no-store" });
|
const res = await fetch("/api/umbrella", { cache: "no-store" });
|
||||||
@ -59,18 +72,58 @@
|
|||||||
function renderFilters(g) {
|
function renderFilters(g) {
|
||||||
const types = [...new Set(g.nodes.map(n => n.type))].sort();
|
const types = [...new Set(g.nodes.map(n => n.type))].sort();
|
||||||
const wrap = document.getElementById("filters");
|
const wrap = document.getElementById("filters");
|
||||||
|
const disclosure = document.getElementById("disclosure");
|
||||||
wrap.innerHTML = "";
|
wrap.innerHTML = "";
|
||||||
|
disclosure.innerHTML = "";
|
||||||
types.forEach(t => {
|
types.forEach(t => {
|
||||||
activeTypes.add(t);
|
activeTypes.add(t);
|
||||||
const chip = document.createElement("button");
|
const chip = document.createElement("button");
|
||||||
chip.className = "chip active";
|
chip.className = "chip active" + (quietTypes.has(t) ? " disclosure-chip" : "");
|
||||||
chip.dataset.type = t;
|
chip.dataset.type = t;
|
||||||
chip.style.borderColor = TYPE_COLOR[t] || "#475569";
|
chip.style.borderColor = TYPE_COLOR[t] || "#475569";
|
||||||
chip.style.color = TYPE_COLOR[t] || "#e7eaf0";
|
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));
|
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) {
|
function toggleType(t, chipEl) {
|
||||||
@ -83,7 +136,9 @@
|
|||||||
if (!cy) return;
|
if (!cy) return;
|
||||||
cy.batch(() => {
|
cy.batch(() => {
|
||||||
cy.nodes().forEach(n => {
|
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");
|
n.style("display", visible ? "element" : "none");
|
||||||
});
|
});
|
||||||
cy.edges().forEach(e => {
|
cy.edges().forEach(e => {
|
||||||
@ -96,7 +151,14 @@
|
|||||||
|
|
||||||
function buildCyElements(g) {
|
function buildCyElements(g) {
|
||||||
const nodes = g.nodes.map(n => ({
|
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) => ({
|
const edges = g.edges.map((e, i) => ({
|
||||||
data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e },
|
data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e },
|
||||||
@ -115,21 +177,27 @@
|
|||||||
style: {
|
style: {
|
||||||
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
|
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
|
||||||
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
|
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
|
||||||
"label": "data(label)",
|
"label": ele => ele.data("important") ? ele.data("shortLabel") : "",
|
||||||
"color": "#e7eaf0",
|
"color": "#e7eaf0",
|
||||||
"text-valign": "bottom",
|
"text-valign": "bottom",
|
||||||
"text-margin-y": 4,
|
"text-margin-y": 4,
|
||||||
"font-size": 10,
|
"font-size": ele => ele.data("fontSize") || 10,
|
||||||
"font-family": "ui-monospace, monospace",
|
"font-family": "ui-monospace, monospace",
|
||||||
"width": 28,
|
"width": ele => ele.data("nodeWidth") || 28,
|
||||||
"height": 28,
|
"height": ele => ele.data("nodeHeight") || 28,
|
||||||
"border-width": 1,
|
"border-width": 1,
|
||||||
"border-color": "#0f1115",
|
"border-color": "#0f1115",
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": ele => Math.max(90, (ele.data("nodeWidth") || 28) + 44),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
selector: "node:selected",
|
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",
|
selector: "edge",
|
||||||
@ -150,8 +218,406 @@
|
|||||||
],
|
],
|
||||||
layout: { name: "cose", animate: false, idealEdgeLength: 80, nodeRepulsion: 12000 },
|
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", "node", (evt) => openSidePanel(evt.target.data("raw")));
|
||||||
cy.on("tap", (evt) => { if (evt.target === cy) closeSidePanel(); });
|
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) {
|
async function openSidePanel(node) {
|
||||||
@ -240,14 +706,20 @@
|
|||||||
document.querySelectorAll("button[data-layout]").forEach(b => {
|
document.querySelectorAll("button[data-layout]").forEach(b => {
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
const name = b.dataset.layout;
|
const name = b.dataset.layout;
|
||||||
|
clearLayerOverlay();
|
||||||
|
setActiveView(name);
|
||||||
const opts = name === "cose"
|
const opts = name === "cose"
|
||||||
? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 }
|
? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 }
|
||||||
: name === "breadthfirst"
|
: name === "breadthfirst"
|
||||||
? { name, animate: true, directed: true, padding: 10 }
|
? { name, animate: true, directed: true, padding: 10 }
|
||||||
: { name, animate: true };
|
: { name, animate: true };
|
||||||
cy.layout(opts).run();
|
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");
|
const search = document.getElementById("search");
|
||||||
search.addEventListener("input", () => {
|
search.addEventListener("input", () => {
|
||||||
const q = search.value.trim().toLowerCase();
|
const q = search.value.trim().toLowerCase();
|
||||||
@ -268,11 +740,14 @@
|
|||||||
renderFilters(graph);
|
renderFilters(graph);
|
||||||
renderGraph(graph);
|
renderGraph(graph);
|
||||||
bindControls();
|
bindControls();
|
||||||
|
runPresetView("spine");
|
||||||
|
applyDefaultDisclosure();
|
||||||
window.__svrntyUmbrella = {
|
window.__svrntyUmbrella = {
|
||||||
ready: true,
|
ready: true,
|
||||||
error: null,
|
error: null,
|
||||||
nodes: graph.nodes.length,
|
nodes: graph.nodes.length,
|
||||||
edges: graph.edges.length,
|
edges: graph.edges.length,
|
||||||
|
view: window.__svrntyUmbrella.view || "spine",
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("stats").textContent = "load failed: " + e.message;
|
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