Jesús Pérez da083fb9ec
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
.coder/m
2026-03-29 00:19:56 +00:00

909 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="/assets/vendor/cytoscape-navigator.js"></script>
<style>
#graph-root {
display: flex;
height: calc(100vh - 148px);
min-height: 400px;
gap: 0;
user-select: none;
}
#graph-root:fullscreen,
#graph-root:-webkit-full-screen {
height: 100dvh;
background: oklch(var(--b1));
}
#cy-wrapper {
flex: 1 1 auto;
min-width: 220px;
overflow: hidden;
border-radius: 0.5rem;
position: relative;
}
#cy { width: 100%; height: 100%; }
/* Floating controls — bottom-right */
#cy-controls {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
}
#cy-controls button {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid oklch(var(--bc) / 0.18);
background: oklch(var(--b2) / 0.90);
color: oklch(var(--bc));
font-size: 16px;
font-weight: 600;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, border-color 0.12s, color 0.12s;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
#cy-controls button:hover {
background: oklch(var(--p) / 0.85);
border-color: oklch(var(--p));
color: oklch(var(--pc));
}
#cy-controls button svg {
width: 13px;
height: 13px;
flex-shrink: 0;
pointer-events: none;
}
.ctrl-divider {
height: 1px;
background: oklch(var(--bc) / 0.15);
margin: 2px 0;
}
/* Minimap — bottom-left */
#cy-nav {
position: absolute;
bottom: 14px;
left: 14px;
z-index: 10;
width: 160px;
height: 96px;
border-radius: 6px;
overflow: hidden;
border: 1px solid oklch(var(--bc) / 0.15);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* navigator internals */
#cy-nav canvas { display: block; }
.cy-navigator-view {
border: 2px solid oklch(var(--p) / 0.7) !important;
background: oklch(var(--p) / 0.12) !important;
border-radius: 2px !important;
}
#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 %}">
<!-- Legend modal -->
<dialog id="legend-modal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<div class="flex justify-between items-center mb-5">
<h3 class="font-bold text-lg">Graph Legend</h3>
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost"></button></form>
</div>
<div class="space-y-6 text-sm overflow-y-auto max-h-[70vh] pr-1">
<!-- Levels -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Levels — abstraction of knowledge</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-warning flex-shrink-0 pointer-events-none select-none" style="min-width:80px">◆ Axiom</span>
<div>
<p class="font-medium leading-snug">Fundamental invariant</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">What the project holds to be unconditionally true. Axioms cannot change without a formal ADR. They anchor the rest of the graph — every Tension and Practice traces back to at least one Axiom.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-error flex-shrink-0 pointer-events-none select-none" style="min-width:80px">● Tension</span>
<div>
<p class="font-medium leading-snug">Active contradiction</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A force the project holds without resolving. Tensions are not problems to eliminate — they are productive oppositions that generate direction. Each Practice exists because a Tension demands a response.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-success flex-shrink-0 pointer-events-none select-none" style="min-width:80px">▪ Practice</span>
<div>
<p class="font-medium leading-snug">Concrete approach</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">How the project operationalizes a Tension. Practices are the most mutable layer — they evolve as the project learns. They map directly to artifacts, ADRs, and code.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Poles -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f59e0b;color:#111;border-color:#f59e0b;min-width:64px">Yang</span>
<div>
<p class="font-medium leading-snug">Active · building · outward</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that assert, create, or push outward. Yang principles drive the definition of what a project <em>does</em> and what it makes available to others.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#3b82f6;color:#fff;border-color:#3b82f6;min-width:64px">Yin</span>
<div>
<p class="font-medium leading-snug">Receptive · constraining · protective</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that receive, limit, or protect. Yin principles define what the project <em>refuses</em>, what boundaries it holds, and what it conserves.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6;min-width:64px">Spiral</span>
<div>
<p class="font-medium leading-snug">Dialectical · both/and · evolving</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Layouts -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Layouts — how the graph is arranged</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="join flex-shrink-0">
<span class="join-item btn btn-xs btn-primary pointer-events-none select-none">Hierarchy</span>
</div>
<div>
<p class="font-medium leading-snug">Breadth-first top-down</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Axioms at the root, Tensions in the middle, Practices at the leaves. Use this to trace causality: which invariants drive which contradictions, and which contradictions demand which responses.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="join flex-shrink-0">
<span class="join-item btn btn-xs btn-ghost pointer-events-none select-none">Force</span>
</div>
<div>
<p class="font-medium leading-snug">Physics simulation (COSE)</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes are pulled together by edges and pushed apart by repulsion. Heavily connected nodes cluster at the center. Use this to find hubs — nodes that influence many others — and isolated areas that may be undertested or underdocumented.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Visual cues -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Visual cues</p>
<div class="space-y-2.5">
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0" style="background:#f59e0b;outline:3px solid #f59e0b;outline-offset:1px"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">Gold border</span> — invariant node. Cannot be modified without creating a formal ADR.</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0" style="background:#6b7280;outline:2px solid #fff;outline-offset:1px"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">White border</span> — currently selected node.</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0 opacity-20" style="background:#6b7280"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">Dimmed nodes</span> — not connected to the selected node. Click the canvas to clear.</p>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Shortcuts -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Keyboard shortcuts</p>
<div class="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">f</kbd><span class="text-base-content/60">Fit all nodes in view</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">+</kbd><span class="text-base-content/60">Zoom in</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">g</kbd><span class="text-base-content/60">Toggle full screen</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs"></kbd><span class="text-base-content/60">Zoom out</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">Esc</kbd><span class="text-base-content/60">Close detail panel</span></div>
</div>
</section>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 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>
<button id="btn-legend" class="btn btn-xs btn-ghost" title="Graph legend">?</button>
</div>
</div>
<!-- Split: graph | drag handle | detail -->
<div id="graph-root">
<div id="cy-wrapper" class="bg-base-200">
<div id="cy"></div>
<!-- Minimap -->
<div id="cy-nav"></div>
<!-- Floating zoom + nav controls -->
<div id="cy-controls">
<button id="cy-zoom-in" title="Zoom in">+</button>
<button id="cy-zoom-out" title="Zoom out"></button>
<div class="ctrl-divider"></div>
<button id="cy-fit" title="Fit to view"></button>
<button id="cy-fullscreen" title="Full screen"></button>
</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 }};
// ── Icons ─────────────────────────────────────────────────────
const SVG_FIT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`;
const SVG_FS_ENTER = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`;
const SVG_FS_EXIT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>`;
document.getElementById('cy-fit').innerHTML = SVG_FIT;
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
// ── Graph data ────────────────────────────────────────────────
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(false),
wheelSensitivity: 0.3,
boxSelectionEnabled: false,
minZoom: 0.1,
maxZoom: 4,
});
// ── Minimap (navigator) ───────────────────────────────────────
if (typeof cy.navigator === 'function') {
cy.navigator({
container: document.getElementById('cy-nav'),
viewLiveFramerate: 0,
thumbnailEventFramerate: 30,
thumbnailLiveFramerate: false,
dblClickDelay: 200,
removeCustomContainer: false,
rerenderDelay: 100,
});
} else {
document.getElementById('cy-nav').style.display = 'none';
}
// ── Layout builders ───────────────────────────────────────────
function buildBfsLayout(animate) {
return {
name: "breadthfirst",
directed: true,
animate: !!animate,
animationDuration: 500,
animationEasing: 'ease-in-out-cubic',
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,
};
}
// ── Zoom at cursor ────────────────────────────────────────────
// Track last known mouse position over the canvas for cursor-centered zoom.
let lastCursorViewport = null;
document.getElementById('cy').addEventListener('mousemove', e => {
const rect = e.currentTarget.getBoundingClientRect();
lastCursorViewport = { x: e.clientX - rect.left, y: e.clientY - rect.top };
});
document.getElementById('cy').addEventListener('mouseleave', () => {
lastCursorViewport = null;
});
function zoomAt(factor) {
const oldZoom = cy.zoom();
const newZoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), oldZoom * factor));
if (newZoom === oldZoom) return;
if (lastCursorViewport) {
const pan = cy.pan();
const { x, y } = lastCursorViewport;
cy.animate({
zoom: newZoom,
pan: {
x: x - (x - pan.x) * (newZoom / oldZoom),
y: y - (y - pan.y) * (newZoom / oldZoom),
},
}, { duration: 180, easing: 'ease-in-out-quad' });
} else {
cy.animate({ zoom: newZoom }, { duration: 180, easing: 'ease-in-out-quad' });
}
}
// ── Floating controls ─────────────────────────────────────────
document.getElementById('cy-zoom-in').addEventListener('click', () => zoomAt(1.35));
document.getElementById('cy-zoom-out').addEventListener('click', () => zoomAt(1 / 1.35));
document.getElementById('cy-fit').addEventListener('click', () => {
cy.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 350, easing: 'ease-in-out-cubic' });
});
// ── Full-screen ───────────────────────────────────────────────
const graphRoot = document.getElementById('graph-root');
const btnFullscreen = document.getElementById('cy-fullscreen');
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement);
}
btnFullscreen.addEventListener('click', () => {
if (!isFullscreen()) {
const req = graphRoot.requestFullscreen || graphRoot.webkitRequestFullscreen;
if (req) req.call(graphRoot);
} else {
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document);
}
});
function onFullscreenChange() {
const fs = isFullscreen();
btnFullscreen.innerHTML = fs ? SVG_FS_EXIT : SVG_FS_ENTER;
btnFullscreen.title = fs ? 'Exit full screen' : 'Full screen';
requestAnimationFrame(() => { cy.resize(); cy.fit(undefined, 48); });
}
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// ── Filters ───────────────────────────────────────────────────
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(true)).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-legend").addEventListener("click", () => {
document.getElementById("legend-modal").showModal();
});
document.getElementById("btn-reset").addEventListener("click", () => {
cy.elements().removeClass("faded highlighted");
cy.animate(
{ fit: { eles: cy.elements(':visible'), padding: 48 } },
{ duration: 400, easing: 'ease-in-out-cubic' }
);
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 node = evt.target;
const d = node.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 = node.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");
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
// Animate center + zoom to selected node
cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: 'ease-in-out-cubic' }
);
});
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");
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]) => k !== "id")
.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);
});
// ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener('keydown', e => {
// Skip when focus is inside an input, textarea, or dialog
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;
switch (e.key) {
case 'f':
case 'F':
e.preventDefault();
cy.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 350, easing: 'ease-in-out-cubic' });
break;
case '+':
case '=':
e.preventDefault();
zoomAt(1.35);
break;
case '-':
e.preventDefault();
zoomAt(1 / 1.35);
break;
case 'g':
case 'G':
e.preventDefault();
btnFullscreen.click();
break;
case 'Escape':
// Only close panel; fullscreen exit is handled natively by the browser
if (!isFullscreen() && !panel.classList.contains('hidden')) {
e.preventDefault();
closePanel();
}
break;
}
});
</script>
{% endblock scripts %}