547 lines
18 KiB
HTML
547 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Ontology Graph — Ontoref{% endblock title %}
|
|
{% block nav_graph %}active{% endblock nav_graph %}
|
|
|
|
{% block head %}
|
|
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
|
|
<style>
|
|
#graph-root {
|
|
display: flex;
|
|
height: calc(100vh - 148px);
|
|
min-height: 400px;
|
|
gap: 0;
|
|
user-select: none;
|
|
}
|
|
#cy-wrapper {
|
|
flex: 1 1 auto;
|
|
min-width: 220px;
|
|
overflow: hidden;
|
|
border-radius: 0.5rem;
|
|
}
|
|
#cy { width: 100%; height: 100%; }
|
|
|
|
#resize-handle {
|
|
flex: 0 0 6px;
|
|
cursor: col-resize;
|
|
background: transparent;
|
|
position: relative;
|
|
z-index: 20;
|
|
transition: background 0.15s;
|
|
}
|
|
#resize-handle:hover,
|
|
#resize-handle.dragging { background: oklch(var(--p) / 0.5); }
|
|
#resize-handle::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 2px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 2px;
|
|
height: 40px;
|
|
border-radius: 99px;
|
|
background: oklch(var(--bc) / 0.25);
|
|
}
|
|
|
|
#detail-panel {
|
|
flex: 0 0 300px;
|
|
min-width: 160px;
|
|
max-width: 60%;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
border-radius: 0.5rem;
|
|
}
|
|
</style>
|
|
{% endblock head %}
|
|
|
|
{% block content %}
|
|
<input type="hidden" id="graph-slug" value="{% if slug %}{{ slug }}{% endif %}">
|
|
|
|
<!-- ADR modal -->
|
|
<dialog id="adr-modal" class="modal">
|
|
<div class="modal-box w-11/12 max-w-2xl">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="font-bold text-lg" id="adr-modal-title">ADR</h3>
|
|
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost">✕</button></form>
|
|
</div>
|
|
<div id="adr-modal-body" class="text-sm space-y-3 overflow-y-auto max-h-[60vh]">
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<h1 class="text-xl font-bold">Ontology Graph</h1>
|
|
<div class="flex flex-wrap gap-1 items-center">
|
|
<!-- Level filters (toggle = hide that level) -->
|
|
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
|
|
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
|
|
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
|
|
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
|
|
<!-- Pole filters -->
|
|
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
|
|
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
|
|
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
|
|
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
|
|
<!-- Layout -->
|
|
<div class="join">
|
|
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
|
|
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
|
|
</div>
|
|
<button id="btn-reset" class="btn btn-xs btn-ghost">Reset</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Split: graph | drag handle | detail -->
|
|
<div id="graph-root">
|
|
<div id="cy-wrapper" class="bg-base-200">
|
|
<div id="cy"></div>
|
|
</div>
|
|
|
|
<div id="resize-handle"></div>
|
|
|
|
<div id="detail-panel" class="bg-base-200 p-4 hidden">
|
|
<div class="flex justify-between items-start mb-2">
|
|
<h3 class="font-bold text-base leading-tight" id="d-name"></h3>
|
|
<button id="btn-close-panel" class="btn btn-xs btn-ghost ml-2 flex-shrink-0">✕</button>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
|
|
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
|
|
<div id="d-artifacts" class="hidden mb-3">
|
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Artifacts</p>
|
|
<ul id="d-artifact-list" class="text-xs font-mono text-base-content/60 space-y-1 break-all"></ul>
|
|
</div>
|
|
<div id="d-adrs" class="hidden mb-3">
|
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p>
|
|
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul>
|
|
</div>
|
|
<div id="d-edges" class="hidden">
|
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
|
|
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock content %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const GRAPH = {{ graph_json | safe }};
|
|
|
|
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6" };
|
|
const LEVEL_SHAPE = {
|
|
Axiom: "diamond",
|
|
Tension: "ellipse",
|
|
Practice: "round-rectangle",
|
|
Project: "hexagon",
|
|
Moment: "triangle",
|
|
};
|
|
const EDGE_STYLE = {
|
|
ManifestsIn: { color: "#6b7280" },
|
|
DependsOn: { color: "#ef4444" },
|
|
Resolves: { color: "#22c55e" },
|
|
Implies: { color: "#f59e0b" },
|
|
Complements: { color: "#3b82f6" },
|
|
Contradicts: { color: "#ec4899", dashed: true },
|
|
TensionWith: { color: "#f97316", dashed: true },
|
|
Contains: { color: "#a3a3a3" },
|
|
FlowsTo: { color: "#06b6d4" },
|
|
SpiralsWith: { color: "#8b5cf6" },
|
|
LimitedBy: { color: "#f43f5e" },
|
|
ValidatedBy: { color: "#84cc16" },
|
|
};
|
|
|
|
const nodes = (GRAPH.nodes || []).map(n => ({
|
|
data: {
|
|
id: n.id,
|
|
label: n.name || n.id,
|
|
pole: n.pole || "",
|
|
level: n.level || "",
|
|
description: n.description || "",
|
|
invariant: !!n.invariant,
|
|
artifact_paths: n.artifact_paths || [],
|
|
adrs: n.adrs || [],
|
|
color: POLE_COLOR[n.pole] || "#6b7280",
|
|
shape: LEVEL_SHAPE[n.level] || "ellipse",
|
|
}
|
|
}));
|
|
|
|
const edges = (GRAPH.edges || []).map(e => {
|
|
const s = EDGE_STYLE[e.kind] || {};
|
|
return {
|
|
data: {
|
|
id: `${e.from}__${e.to}__${e.kind}`,
|
|
source: e.from,
|
|
target: e.to,
|
|
kind: e.kind || "",
|
|
note: e.note || "",
|
|
color: s.color || "#6b7280",
|
|
dashed: !!s.dashed,
|
|
}
|
|
};
|
|
});
|
|
|
|
// ── Cytoscape ────────────────────────────────────────────────
|
|
|
|
const cy = cytoscape({
|
|
container: document.getElementById("cy"),
|
|
elements: { nodes, edges },
|
|
style: [
|
|
{
|
|
selector: "node",
|
|
style: {
|
|
"background-color": "data(color)",
|
|
"shape": "data(shape)",
|
|
"width": 32,
|
|
"height": 32,
|
|
"border-width": 2,
|
|
"border-color": "#111827",
|
|
"label": "data(label)",
|
|
"color": "#f9fafb",
|
|
"font-size": "10px",
|
|
"font-family": "ui-sans-serif, system-ui, sans-serif",
|
|
"font-weight": 500,
|
|
"text-valign": "bottom",
|
|
"text-halign": "center",
|
|
"text-margin-y": 6,
|
|
"text-wrap": "wrap",
|
|
"text-max-width": "100px",
|
|
"text-background-color": "#0f172a",
|
|
"text-background-opacity": 0.85,
|
|
"text-background-shape": "round-rectangle",
|
|
"text-background-padding": "3px",
|
|
"text-outline-width": 0,
|
|
}
|
|
},
|
|
{
|
|
selector: "node[?invariant]",
|
|
style: { "border-color": "#f59e0b", "border-width": 3 }
|
|
},
|
|
{
|
|
selector: "node:selected",
|
|
style: { "border-color": "#ffffff", "border-width": 3 }
|
|
},
|
|
{
|
|
selector: "node.faded",
|
|
style: { "opacity": 0.2, "text-opacity": 0.2 }
|
|
},
|
|
{
|
|
selector: "edge",
|
|
style: {
|
|
"line-color": "data(color)",
|
|
"target-arrow-color": "data(color)",
|
|
"target-arrow-shape": "triangle",
|
|
"curve-style": "bezier",
|
|
"width": 1.5,
|
|
"arrow-scale": 0.8,
|
|
"opacity": 0.6,
|
|
}
|
|
},
|
|
{
|
|
selector: "edge[?dashed]",
|
|
style: { "line-style": "dashed", "line-dash-pattern": [6, 4] }
|
|
},
|
|
{
|
|
selector: "edge.faded",
|
|
style: { "opacity": 0.05 }
|
|
},
|
|
{
|
|
selector: "edge.highlighted",
|
|
style: {
|
|
"opacity": 1,
|
|
"width": 2.5,
|
|
"label": "data(kind)",
|
|
"font-size": "9px",
|
|
"color": "#e5e7eb",
|
|
"text-background-color": "#0f172a",
|
|
"text-background-opacity": 0.85,
|
|
"text-background-padding": "2px",
|
|
"text-background-shape": "round-rectangle",
|
|
}
|
|
},
|
|
],
|
|
layout: buildBfsLayout(),
|
|
wheelSensitivity: 0.3,
|
|
boxSelectionEnabled: false,
|
|
minZoom: 0.1,
|
|
maxZoom: 4,
|
|
});
|
|
|
|
function buildBfsLayout() {
|
|
return {
|
|
name: "breadthfirst",
|
|
directed: true,
|
|
animate: false,
|
|
spacingFactor: 2.2,
|
|
padding: 48,
|
|
fit: true,
|
|
avoidOverlap: true,
|
|
nodeDimensionsIncludeLabels: true,
|
|
};
|
|
}
|
|
|
|
function buildCoseLayout() {
|
|
return {
|
|
name: "cose",
|
|
animate: false,
|
|
randomize: true,
|
|
componentSpacing: 120,
|
|
nodeRepulsion: 800000,
|
|
nodeOverlap: 80,
|
|
idealEdgeLength: 220,
|
|
edgeElasticity: 20,
|
|
nestingFactor: 1.0,
|
|
gravity: 2,
|
|
numIter: 3000,
|
|
initialTemp: 2000,
|
|
coolingFactor: 0.99,
|
|
minTemp: 1.0,
|
|
fit: true,
|
|
padding: 60,
|
|
};
|
|
}
|
|
|
|
// ── Filters ──────────────────────────────────────────────────
|
|
|
|
// Track which levels/poles are hidden
|
|
const hiddenLevels = new Set();
|
|
const hiddenPoles = new Set();
|
|
|
|
function applyFilters() {
|
|
cy.nodes().forEach(n => {
|
|
const hide = hiddenLevels.has(n.data("level")) || hiddenPoles.has(n.data("pole"));
|
|
if (hide) n.hide(); else n.show();
|
|
});
|
|
cy.edges().forEach(e => {
|
|
if (e.source().hidden() || e.target().hidden()) e.hide();
|
|
else e.show();
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const level = btn.dataset.level;
|
|
if (hiddenLevels.has(level)) {
|
|
hiddenLevels.delete(level);
|
|
btn.style.opacity = "1";
|
|
btn.style.textDecoration = "";
|
|
} else {
|
|
hiddenLevels.add(level);
|
|
btn.style.opacity = "0.4";
|
|
btn.style.textDecoration = "line-through";
|
|
}
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".filter-btn[data-pole]").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const pole = btn.dataset.pole;
|
|
if (hiddenPoles.has(pole)) {
|
|
hiddenPoles.delete(pole);
|
|
btn.style.opacity = "1";
|
|
btn.style.textDecoration = "";
|
|
} else {
|
|
hiddenPoles.add(pole);
|
|
btn.style.opacity = "0.4";
|
|
btn.style.textDecoration = "line-through";
|
|
}
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
// ── Layout buttons ───────────────────────────────────────────
|
|
|
|
const btnBfs = document.getElementById("btn-bfs");
|
|
const btnCose = document.getElementById("btn-cose");
|
|
|
|
btnBfs.addEventListener("click", () => {
|
|
cy.layout(buildBfsLayout()).run();
|
|
btnBfs.classList.add("btn-primary"); btnBfs.classList.remove("btn-ghost");
|
|
btnCose.classList.add("btn-ghost"); btnCose.classList.remove("btn-primary");
|
|
});
|
|
|
|
btnCose.addEventListener("click", () => {
|
|
cy.layout(buildCoseLayout()).run();
|
|
btnCose.classList.add("btn-primary"); btnCose.classList.remove("btn-ghost");
|
|
btnBfs.classList.add("btn-ghost"); btnBfs.classList.remove("btn-primary");
|
|
});
|
|
|
|
document.getElementById("btn-reset").addEventListener("click", () => {
|
|
cy.elements().removeClass("faded highlighted");
|
|
cy.fit(undefined, 48);
|
|
closePanel();
|
|
});
|
|
|
|
// ── Node detail panel ────────────────────────────────────────
|
|
|
|
const panel = document.getElementById("detail-panel");
|
|
const dName = document.getElementById("d-name");
|
|
const dBadges = document.getElementById("d-badges");
|
|
const dDesc = document.getElementById("d-description");
|
|
const dArtifacts = document.getElementById("d-artifacts");
|
|
const dList = document.getElementById("d-artifact-list");
|
|
const dAdrs = document.getElementById("d-adrs");
|
|
const dAdrList = document.getElementById("d-adr-list");
|
|
const dEdges = document.getElementById("d-edges");
|
|
const dEdgeList = document.getElementById("d-edge-list");
|
|
|
|
function closePanel() {
|
|
panel.classList.add("hidden");
|
|
cy.elements().removeClass("faded highlighted");
|
|
}
|
|
|
|
cy.on("tap", "node", evt => {
|
|
const d = evt.target.data();
|
|
panel.classList.remove("hidden");
|
|
|
|
dName.textContent = d.label;
|
|
dBadges.innerHTML =
|
|
`<span class="badge badge-xs badge-outline">${d.level}</span>` +
|
|
`<span class="badge badge-xs" style="background:${d.color};color:#111;border:none">${d.pole}</span>` +
|
|
(d.invariant ? `<span class="badge badge-xs badge-warning">invariant</span>` : "");
|
|
|
|
dDesc.textContent = d.description;
|
|
|
|
if (d.artifact_paths.length) {
|
|
dArtifacts.classList.remove("hidden");
|
|
dList.innerHTML = d.artifact_paths.map(p =>
|
|
`<li class="break-all"><code>${p}</code></li>`
|
|
).join("");
|
|
} else {
|
|
dArtifacts.classList.add("hidden");
|
|
}
|
|
|
|
if (d.adrs.length) {
|
|
dAdrs.classList.remove("hidden");
|
|
dAdrList.innerHTML = d.adrs.map(a =>
|
|
`<li><span class="text-success mr-1">◆</span>` +
|
|
`<button class="adr-link font-mono text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>`
|
|
).join("");
|
|
} else {
|
|
dAdrs.classList.add("hidden");
|
|
}
|
|
|
|
const conn = evt.target.connectedEdges();
|
|
if (conn.length) {
|
|
dEdges.classList.remove("hidden");
|
|
dEdgeList.innerHTML = conn.map(e => {
|
|
const isSrc = e.data("source") === d.id;
|
|
const other = isSrc
|
|
? cy.getElementById(e.data("target")).data("label")
|
|
: cy.getElementById(e.data("source")).data("label");
|
|
const arrow = isSrc ? "→" : "←";
|
|
return `<li class="flex gap-1"><span class="opacity-40 flex-shrink-0">${arrow}</span>` +
|
|
`<span class="text-base-content/80 flex-shrink-0">${e.data("kind")}</span>` +
|
|
`<span class="opacity-60 break-all">${other}</span></li>`;
|
|
}).join("");
|
|
} else {
|
|
dEdges.classList.add("hidden");
|
|
}
|
|
|
|
// Dim non-neighbours
|
|
cy.elements().addClass("faded").removeClass("highlighted");
|
|
evt.target.closedNeighborhood().removeClass("faded").addClass("highlighted");
|
|
});
|
|
|
|
cy.on("tap", evt => {
|
|
if (evt.target === cy) closePanel();
|
|
});
|
|
|
|
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
|
|
|
|
// ── Resizable split ───────────────────────────────────────────
|
|
|
|
const handle = document.getElementById("resize-handle");
|
|
const cyWrapper = document.getElementById("cy-wrapper");
|
|
const graphRoot = document.getElementById("graph-root");
|
|
|
|
let resizing = false;
|
|
|
|
handle.addEventListener("mousedown", e => {
|
|
resizing = true;
|
|
handle.classList.add("dragging");
|
|
document.body.style.cursor = "col-resize";
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener("mousemove", e => {
|
|
if (!resizing) return;
|
|
const rect = graphRoot.getBoundingClientRect();
|
|
const hW = handle.offsetWidth;
|
|
const cyW = Math.max(220, e.clientX - rect.left);
|
|
const panW = Math.max(160, rect.width - cyW - hW);
|
|
if (cyW + panW + hW <= rect.width + 2) {
|
|
cyWrapper.style.flex = `0 0 ${cyW}px`;
|
|
if (!panel.classList.contains("hidden")) {
|
|
panel.style.flex = `0 0 ${panW}px`;
|
|
}
|
|
cy.resize();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("mouseup", () => {
|
|
if (!resizing) return;
|
|
resizing = false;
|
|
handle.classList.remove("dragging");
|
|
document.body.style.cursor = "";
|
|
});
|
|
|
|
// ── ADR modal ─────────────────────────────────────────────────
|
|
|
|
const adrModal = document.getElementById("adr-modal");
|
|
const adrModalTitle = document.getElementById("adr-modal-title");
|
|
const adrModalBody = document.getElementById("adr-modal-body");
|
|
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
|
|
|
|
function renderAdrBody(data) {
|
|
if (data.error) {
|
|
return `<p class="text-error">${data.error}</p>`;
|
|
}
|
|
const rows = Object.entries(data)
|
|
.filter(([k]) => !["id"].includes(k))
|
|
.map(([k, v]) => {
|
|
const label = k.replace(/_/g, " ");
|
|
let val;
|
|
if (Array.isArray(v)) {
|
|
if (v.length === 0) return null;
|
|
val = `<ul class="list-disc pl-4 space-y-0.5">${v.map(item =>
|
|
typeof item === "object"
|
|
? `<li><pre class="text-xs whitespace-pre-wrap">${JSON.stringify(item, null, 2)}</pre></li>`
|
|
: `<li>${item}</li>`
|
|
).join("")}</ul>`;
|
|
} else if (typeof v === "object" && v !== null) {
|
|
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`;
|
|
} else {
|
|
val = `<span class="text-base-content/80">${v}</span>`;
|
|
}
|
|
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-0.5">${label}</p>${val}</div>`;
|
|
})
|
|
.filter(Boolean)
|
|
.join("");
|
|
return rows || `<p class="text-base-content/50">No details available.</p>`;
|
|
}
|
|
|
|
async function fetchAdr(id) {
|
|
adrModalTitle.textContent = id;
|
|
adrModalBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
|
|
adrModal.showModal();
|
|
|
|
const slug = GRAPH_SLUG ? `&slug=${encodeURIComponent(GRAPH_SLUG)}` : "";
|
|
try {
|
|
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${slug}`);
|
|
const data = await res.json();
|
|
adrModalBody.innerHTML = renderAdrBody(data);
|
|
} catch (err) {
|
|
adrModalBody.innerHTML = `<p class="text-error">Failed to load ADR: ${err}</p>`;
|
|
}
|
|
}
|
|
|
|
document.addEventListener("click", e => {
|
|
const btn = e.target.closest(".adr-link");
|
|
if (btn) fetchAdr(btn.dataset.adr);
|
|
});
|
|
</script>
|
|
{% endblock scripts %}
|